@deftai/directive-core 0.107.0 → 0.108.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 (167) hide show
  1. package/dist/architecture/sor-preflight.js +2 -0
  2. package/dist/check/cached-orchestrator.js +28 -3
  3. package/dist/check/named-cause.js +18 -3
  4. package/dist/codebase/provider.js +7 -1
  5. package/dist/delivery-attempt/handoff.js +1 -1
  6. package/dist/design-critique/citation-grammar.d.ts +53 -0
  7. package/dist/design-critique/citation-grammar.js +303 -0
  8. package/dist/design-critique/completed-arc-record.d.ts +62 -0
  9. package/dist/design-critique/completed-arc-record.js +232 -0
  10. package/dist/freshness/bind.d.ts +32 -3
  11. package/dist/freshness/bind.js +58 -12
  12. package/dist/hooks/classify/host-session-identity.d.ts +76 -0
  13. package/dist/hooks/classify/host-session-identity.js +470 -0
  14. package/dist/hooks/classify/index.d.ts +3 -2
  15. package/dist/hooks/classify/index.js +3 -2
  16. package/dist/hooks/classify/paths.d.ts +7 -0
  17. package/dist/hooks/classify/paths.js +35 -0
  18. package/dist/hooks/classify/stdin.d.ts +5 -0
  19. package/dist/hooks/classify/stdin.js +27 -0
  20. package/dist/hooks/dispatcher.d.ts +52 -1
  21. package/dist/hooks/dispatcher.js +538 -45
  22. package/dist/hooks/fixtures/cases.d.ts +16 -2
  23. package/dist/hooks/fixtures/cases.js +175 -0
  24. package/dist/hooks/scope.js +3 -1
  25. package/dist/init-deposit/agent-hooks.d.ts +2 -1
  26. package/dist/init-deposit/agent-hooks.js +8 -1
  27. package/dist/init-deposit/hook-runtime-travel.d.ts +77 -0
  28. package/dist/init-deposit/hook-runtime-travel.js +269 -0
  29. package/dist/intake/clause-derivation.js +9 -3
  30. package/dist/intake/issue-ingest.js +62 -2
  31. package/dist/intake/platform-capabilities.d.ts +9 -2
  32. package/dist/intake/platform-capabilities.js +38 -9
  33. package/dist/orphan-active/evaluate.d.ts +25 -0
  34. package/dist/orphan-active/evaluate.js +193 -128
  35. package/dist/orphan-active/index.d.ts +1 -0
  36. package/dist/orphan-active/index.js +1 -0
  37. package/dist/orphan-active/issue-state.d.ts +121 -0
  38. package/dist/orphan-active/issue-state.js +278 -0
  39. package/dist/platform/cursor-managed-runtime.d.ts +95 -0
  40. package/dist/platform/cursor-managed-runtime.js +241 -0
  41. package/dist/platform/index.d.ts +1 -0
  42. package/dist/platform/index.js +1 -0
  43. package/dist/platform/platform-capabilities.d.ts +23 -1
  44. package/dist/platform/platform-capabilities.js +55 -15
  45. package/dist/policy/ceremony-dial.js +5 -9
  46. package/dist/policy/host-hooks.js +4 -9
  47. package/dist/policy/org-force-on-migration.js +5 -10
  48. package/dist/policy/plan-extensions.d.ts +14 -2
  49. package/dist/policy/plan-extensions.js +24 -3
  50. package/dist/policy/product-signal.js +5 -10
  51. package/dist/policy/require-human-merge.js +5 -9
  52. package/dist/policy/resolve.js +77 -16
  53. package/dist/policy/value-feedback.js +9 -18
  54. package/dist/pr-closeout-attestable/evaluate.d.ts +80 -0
  55. package/dist/pr-closeout-attestable/evaluate.js +306 -0
  56. package/dist/pr-closeout-attestable/index.d.ts +2 -0
  57. package/dist/pr-closeout-attestable/index.js +2 -0
  58. package/dist/pr-protected-issues/gh.d.ts +6 -2
  59. package/dist/pr-protected-issues/gh.js +7 -3
  60. package/dist/pr-wait-mergeable/cascade.d.ts +9 -1
  61. package/dist/pr-wait-mergeable/cascade.js +21 -1
  62. package/dist/pr-wait-mergeable/types.d.ts +2 -0
  63. package/dist/pr-wait-mergeable/wrappers.d.ts +6 -0
  64. package/dist/pr-wait-mergeable/wrappers.js +11 -0
  65. package/dist/preflight/evaluate.d.ts +15 -0
  66. package/dist/preflight/evaluate.js +29 -3
  67. package/dist/product-first-done-gate/acceptance-resolver.d.ts +14 -2
  68. package/dist/product-first-done-gate/acceptance-resolver.js +13 -2
  69. package/dist/product-first-done-gate/evaluate.js +17 -7
  70. package/dist/release/cli-drift-report.d.ts +68 -0
  71. package/dist/release/cli-drift-report.js +189 -0
  72. package/dist/release/index.d.ts +1 -0
  73. package/dist/release/index.js +1 -0
  74. package/dist/release/issue-state-fetch.d.ts +5 -3
  75. package/dist/release/issue-state-fetch.js +86 -21
  76. package/dist/release/native-steps.js +1 -0
  77. package/dist/release/pipeline.js +19 -0
  78. package/dist/release/types.d.ts +14 -0
  79. package/dist/render/constants.d.ts +4 -0
  80. package/dist/render/constants.js +11 -8
  81. package/dist/render/export-spec.js +31 -5
  82. package/dist/render/index.d.ts +1 -1
  83. package/dist/render/index.js +1 -1
  84. package/dist/render/prd-render.d.ts +4 -1
  85. package/dist/render/prd-render.js +82 -23
  86. package/dist/render/project-render.js +14 -7
  87. package/dist/render/scope-outlook.d.ts +2 -0
  88. package/dist/render/scope-outlook.js +3 -0
  89. package/dist/render/spec-render.js +2 -2
  90. package/dist/resolution/package-manager.d.ts +33 -4
  91. package/dist/resolution/package-manager.js +210 -9
  92. package/dist/review-monitor/constants.d.ts +7 -0
  93. package/dist/review-monitor/constants.js +7 -0
  94. package/dist/review-monitor/github-lease.d.ts +98 -1
  95. package/dist/review-monitor/github-lease.js +182 -1
  96. package/dist/review-monitor/lease-comment.d.ts +36 -0
  97. package/dist/review-monitor/lease-comment.js +103 -2
  98. package/dist/scm/call.js +12 -3
  99. package/dist/scm/design-critique-chip.d.ts +1 -0
  100. package/dist/scm/design-critique-chip.js +22 -6
  101. package/dist/scm/gh-rest.d.ts +9 -1
  102. package/dist/scm/gh-rest.js +103 -0
  103. package/dist/scm/readiness.d.ts +2 -0
  104. package/dist/scm/readiness.js +28 -4
  105. package/dist/scope/project-definition-sync.js +14 -7
  106. package/dist/session/git.d.ts +24 -0
  107. package/dist/session/git.js +138 -2
  108. package/dist/session/occupancy.d.ts +238 -12
  109. package/dist/session/occupancy.js +815 -29
  110. package/dist/session/ritual-sentinel.d.ts +5 -0
  111. package/dist/session/ritual-sentinel.js +5 -0
  112. package/dist/session/session-ready.d.ts +5 -1
  113. package/dist/session/session-ready.js +98 -8
  114. package/dist/session/session-start.d.ts +5 -1
  115. package/dist/session/session-start.js +32 -17
  116. package/dist/session/toolchain-preflight.d.ts +13 -3
  117. package/dist/session/toolchain-preflight.js +93 -18
  118. package/dist/session/verify-session-ritual.d.ts +11 -0
  119. package/dist/session/verify-session-ritual.js +60 -15
  120. package/dist/slice/constants.d.ts +1 -1
  121. package/dist/spec-authority/constants.d.ts +10 -2
  122. package/dist/spec-authority/constants.js +53 -8
  123. package/dist/spec-authority/resolver.d.ts +3 -0
  124. package/dist/spec-authority/resolver.js +55 -7
  125. package/dist/subprocess/max-buffer.d.ts +14 -0
  126. package/dist/subprocess/max-buffer.js +15 -0
  127. package/dist/swarm/complete-cohort.d.ts +2 -0
  128. package/dist/swarm/complete-cohort.js +21 -13
  129. package/dist/swarm/index.d.ts +1 -0
  130. package/dist/swarm/index.js +1 -0
  131. package/dist/swarm/launch-cli.js +53 -33
  132. package/dist/swarm/launch.d.ts +8 -0
  133. package/dist/swarm/launch.js +63 -43
  134. package/dist/swarm/pre-dispatch-cli.js +2 -1
  135. package/dist/swarm/pre-dispatch.js +10 -1
  136. package/dist/swarm/subagent-status-dir.d.ts +27 -0
  137. package/dist/swarm/subagent-status-dir.js +42 -0
  138. package/dist/swarm/worktrees.d.ts +17 -3
  139. package/dist/swarm/worktrees.js +48 -4
  140. package/dist/triage/scope/mutations-core.d.ts +2 -6
  141. package/dist/triage/scope/mutations-core.js +23 -55
  142. package/dist/triage/scope-drift/add-ignore.js +5 -39
  143. package/dist/triage/subscribe/index.d.ts +2 -6
  144. package/dist/triage/subscribe/index.js +15 -65
  145. package/dist/triage/welcome/writers.js +15 -74
  146. package/dist/vbrief-build/index.d.ts +2 -1
  147. package/dist/vbrief-build/index.js +2 -1
  148. package/dist/vbrief-build/parity-scenarios.js +5 -5
  149. package/dist/vbrief-build/project-definition-io.d.ts +63 -3
  150. package/dist/vbrief-build/project-definition-io.js +357 -31
  151. package/dist/vbrief-build/project-definition-mutation.d.ts +32 -0
  152. package/dist/vbrief-build/project-definition-mutation.js +43 -0
  153. package/dist/vbrief-validate/conformance.d.ts +1 -0
  154. package/dist/vbrief-validate/conformance.js +85 -8
  155. package/dist/vbrief-validate/main.js +2 -0
  156. package/dist/vbrief-validate/precutover.js +5 -10
  157. package/dist/verify-ac/clauses.d.ts +62 -2
  158. package/dist/verify-ac/clauses.js +183 -111
  159. package/dist/verify-ac/index.d.ts +1 -1
  160. package/dist/verify-ac/index.js +1 -1
  161. package/dist/verify-env/node-runtime.d.ts +8 -4
  162. package/dist/verify-env/node-runtime.js +9 -6
  163. package/dist/verify-env/toolchain-check.d.ts +35 -3
  164. package/dist/verify-env/toolchain-check.js +155 -36
  165. package/dist/verify-source/project-definition-mutation-boundary.d.ts +104 -0
  166. package/dist/verify-source/project-definition-mutation-boundary.js +326 -0
  167. package/package.json +7 -3
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Merge-time closeout attestability gate (#3781).
3
+ *
4
+ * A pull request may leave a brief unattested; it may not MERGE one whose issue
5
+ * it closes in the same act. The trigger is the PR's structured closing
6
+ * references, never the branch diff: CI runs before the merge and the issue
7
+ * closes on it, so at check time no orphan exists yet and a diff-keyed gate can
8
+ * never fail the PR that introduces the problem (PR #3786's own merge gate
9
+ * returned success while creating one). The brief also need not be in the diff —
10
+ * for #3598 it landed on master seventeen hours before its closing PR.
11
+ *
12
+ * The rule itself is not restated here. `evaluateAcceptanceEvidenceGate` is the
13
+ * single decision procedure `scope:complete` enforces, and this gate calls it so
14
+ * the two cannot drift.
15
+ */
16
+ import { type GateRunner } from "../orphan-active/issue-state.js";
17
+ import type { RunGhFn } from "../pr-protected-issues/types.js";
18
+ import { type StrictAcceptanceAxis } from "../scope/acceptance-evidence.js";
19
+ export type OutputStream = "stdout" | "stderr" | "none";
20
+ /** One acceptance criterion the merge would strand without evidence or disposition. */
21
+ export interface UnattestedCriterion {
22
+ /** Plan-item path, e.g. `items[3]` or `items[1].subItems[0]`. */
23
+ readonly path: string;
24
+ readonly title: string;
25
+ /** Verbatim detail from `evaluateAcceptanceEvidenceGate` — the reason it refused. */
26
+ readonly detail: string;
27
+ /**
28
+ * Strict axes inferred for this criterion (#3240). Non-empty means `merge` and
29
+ * `review` evidence cannot satisfy it, so the message must say which kind can.
30
+ */
31
+ readonly requiredAxes: readonly StrictAcceptanceAxis[];
32
+ }
33
+ /** An active/running brief the PR's closing reference would orphan on merge. */
34
+ export interface CloseoutFinding {
35
+ /** Project-root-relative brief path. */
36
+ readonly briefPath: string;
37
+ /** The closing-referenced issue this brief tracks. */
38
+ readonly issue: number;
39
+ readonly unattested: readonly UnattestedCriterion[];
40
+ }
41
+ export interface PrCloseoutAttestableResult {
42
+ /** 0 attestable / 1 unattested closeout / 2 config or lookup error. */
43
+ readonly code: 0 | 1 | 2;
44
+ readonly message: string;
45
+ readonly stream: OutputStream;
46
+ readonly prNumber: number;
47
+ /** Structured closing-issue references read from the forge. */
48
+ readonly closingIssues: readonly number[];
49
+ readonly findings: readonly CloseoutFinding[];
50
+ /** True when the closing-reference read resolved through `ghx`, a cached GET proxy. */
51
+ readonly proxied: boolean;
52
+ }
53
+ /** Reads the PR's structured closing-issue references; `null` on lookup failure. */
54
+ export type FetchClosingIssuesFn = (prNumber: number, repo: string | null, runGh: RunGhFn) => number[] | null;
55
+ export interface EvaluateOptions {
56
+ readonly repo?: string | null;
57
+ /**
58
+ * SCM read seam plus its freshness basis. Defaults to `makeGateRunner()`, which
59
+ * pins plain `gh` when present so a cached `ghx` GET cannot fail this gate open.
60
+ */
61
+ readonly runner?: GateRunner;
62
+ readonly quiet?: boolean;
63
+ /** Closing-reference seam so tests do not need a forge. */
64
+ readonly fetchClosingIssues?: FetchClosingIssuesFn;
65
+ }
66
+ /**
67
+ * Fail closed when merging `prNumber` would close an issue whose brief is still
68
+ * `running` in `active/` with acceptance criteria carrying neither
69
+ * `x-directive/evidence` nor `x-directive/disposition`.
70
+ *
71
+ * Exit contract: 0 attestable (including "this PR closes nothing") / 1 unattested
72
+ * closeout / 2 config or closing-reference lookup error. A lookup that cannot be
73
+ * resolved is 2, not 0 — the gate never green-lights a merge it could not check.
74
+ *
75
+ * The brief is read from `projectRoot`'s working tree, which at merge time is the
76
+ * PR head checkout. That is the tree the merge lands, and it is the same
77
+ * working-tree basis `verify:orphan-active` uses.
78
+ */
79
+ export declare function evaluate(projectRoot: string, prNumber: number, options?: EvaluateOptions): PrCloseoutAttestableResult;
80
+ //# sourceMappingURL=evaluate.d.ts.map
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Merge-time closeout attestability gate (#3781).
3
+ *
4
+ * A pull request may leave a brief unattested; it may not MERGE one whose issue
5
+ * it closes in the same act. The trigger is the PR's structured closing
6
+ * references, never the branch diff: CI runs before the merge and the issue
7
+ * closes on it, so at check time no orphan exists yet and a diff-keyed gate can
8
+ * never fail the PR that introduces the problem (PR #3786's own merge gate
9
+ * returned success while creating one). The brief also need not be in the diff —
10
+ * for #3598 it landed on master seventeen hours before its closing PR.
11
+ *
12
+ * The rule itself is not restated here. `evaluateAcceptanceEvidenceGate` is the
13
+ * single decision procedure `scope:complete` enforces, and this gate calls it so
14
+ * the two cannot drift.
15
+ */
16
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
17
+ import { join, relative, resolve } from "node:path";
18
+ import { hasArtifactSuffix, resolveLifecycleRoot } from "../layout/resolve.js";
19
+ import { makeGateRunner } from "../orphan-active/issue-state.js";
20
+ import { collectGithubRefs } from "../orphan-active/refs.js";
21
+ import { fetchClosingIssuesReferences } from "../pr-protected-issues/gh.js";
22
+ import { ACCEPTANCE_DISPOSITION_KEY, ACCEPTANCE_DISPOSITIONS, ACCEPTANCE_EVIDENCE_KEY, ACCEPTANCE_EVIDENCE_KINDS, evaluateAcceptanceEvidenceGate, inferRequiredStrictAxes, } from "../scope/acceptance-evidence.js";
23
+ import { resolveRepo } from "../triage/queue/repo.js";
24
+ function readJson(path) {
25
+ try {
26
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
27
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
28
+ ? parsed
29
+ : null;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ function planOf(data) {
36
+ const plan = data?.plan;
37
+ return typeof plan === "object" && plan !== null && !Array.isArray(plan)
38
+ ? plan
39
+ : null;
40
+ }
41
+ function relBriefPath(path, projectRoot) {
42
+ try {
43
+ return relative(resolve(projectRoot), resolve(path)).replace(/\\/g, "/");
44
+ }
45
+ catch {
46
+ return path.replace(/\\/g, "/");
47
+ }
48
+ }
49
+ function listActiveRunningBriefs(lifecycleRoot) {
50
+ const activeDir = join(lifecycleRoot, "active");
51
+ if (!existsSync(activeDir)) {
52
+ return [];
53
+ }
54
+ const out = [];
55
+ for (const entry of readdirSync(activeDir, { withFileTypes: true })) {
56
+ if (!entry.isFile() || !hasArtifactSuffix(entry.name)) {
57
+ continue;
58
+ }
59
+ const path = join(activeDir, entry.name);
60
+ const plan = planOf(readJson(path));
61
+ if (plan === null || String(plan.status ?? "").toLowerCase() !== "running") {
62
+ continue;
63
+ }
64
+ out.push({ path, plan });
65
+ }
66
+ return out.sort((a, b) => a.path.localeCompare(b.path));
67
+ }
68
+ /**
69
+ * Index plan items by the same path the acceptance gate reports, so a blocker can
70
+ * be mapped back to its item for axis inference. Traversal order mirrors
71
+ * `walkItems` in `scope/acceptance-evidence.ts`; the attestation rule stays there.
72
+ */
73
+ function indexPlanItems(items, pathPrefix, out) {
74
+ if (!Array.isArray(items)) {
75
+ return;
76
+ }
77
+ items.forEach((item, index) => {
78
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
79
+ return;
80
+ }
81
+ const obj = item;
82
+ const path = `${pathPrefix}[${index}]`;
83
+ out.set(path, obj);
84
+ indexPlanItems(obj.subItems, `${path}.subItems`, out);
85
+ indexPlanItems(obj.items, `${path}.items`, out);
86
+ });
87
+ }
88
+ const DISPOSITION_SHAPE = `${ACCEPTANCE_DISPOSITION_KEY} {disposition: ${ACCEPTANCE_DISPOSITIONS.join("|")}, ` +
89
+ `reason, provenance {kind: operator-cli|operator-session|human-event, actor: <non-agent>}, recorded_at}`;
90
+ function evidenceShape(kinds) {
91
+ return `${ACCEPTANCE_EVIDENCE_KEY} {kind: ${kinds}, pointer, recorded_at, recorded_by}`;
92
+ }
93
+ /**
94
+ * Name the exact shape this criterion needs. A single evidence.kind covers one
95
+ * axis, so two inferred axes cannot be satisfied by any kind — say that instead of
96
+ * printing a kind list that would still be refused (#3240 suitability rule).
97
+ */
98
+ function renderCriterion(criterion) {
99
+ const axes = criterion.requiredAxes;
100
+ const lines = [
101
+ ` - ${criterion.path} "${criterion.title}"`,
102
+ ` why: ${criterion.detail}`,
103
+ ];
104
+ if (axes.length > 1) {
105
+ lines.push(` axis: this criterion requires ${axes.join(" + ")}; no single evidence.kind covers`, " two axes, so evidence cannot satisfy it as written", ` needs: pin one axis with "requires": "${axes[0]}" then ` +
106
+ `${evidenceShape(axes[0])},`, " or split the criterion one axis per item,", ` or ${DISPOSITION_SHAPE}`);
107
+ return lines;
108
+ }
109
+ const kinds = axes.length === 1 ? axes[0] : ACCEPTANCE_EVIDENCE_KINDS.join("|");
110
+ lines.push(` needs: ${evidenceShape(kinds)}`, ` or ${DISPOSITION_SHAPE}`);
111
+ if (axes.length === 1) {
112
+ lines.push(` axis: this criterion requires ${axes[0]} — ` +
113
+ "merge and review evidence cannot satisfy it (#3240)");
114
+ }
115
+ return lines;
116
+ }
117
+ /**
118
+ * `gh` was absent so the closing-reference read went through `ghx`, a cached GET
119
+ * proxy this gate cannot inspect. Say so rather than implying a fresh read
120
+ * (#3767 / #3737).
121
+ */
122
+ const PROXIED_CAVEAT = " Note: `gh` was not on PATH, so the closing-reference read resolved through `ghx`, a cached\n" +
123
+ " GET proxy; freshness is bounded by that proxy, which this gate cannot inspect (#3737).";
124
+ function formatRefusal(prNumber, findings, projectRoot, proxied) {
125
+ const criteria = findings.reduce((sum, f) => sum + f.unattested.length, 0);
126
+ const briefNoun = findings.length === 1 ? "brief" : "briefs";
127
+ const criterionNoun = criteria === 1 ? "criterion" : "criteria";
128
+ const issues = findings.map((f) => `#${f.issue}`).join(", ");
129
+ const lines = [
130
+ `verify:pr-closeout-attestable: PR #${prNumber} closes ${issues}, leaving ${findings.length} ` +
131
+ `active/running ${briefNoun} with ${criteria} unattested acceptance ${criterionNoun} ` +
132
+ `(project_root=${projectRoot}).`,
133
+ " Merging now strands the brief on master: the issue closes, the brief stays running in",
134
+ " active/, and scope:complete then refuses it. A PR may leave a brief unattested; it may",
135
+ " not merge one whose issue is closing in the same act (#3781).",
136
+ " Unattested criteria:",
137
+ ];
138
+ for (const finding of findings) {
139
+ lines.push(` ${finding.briefPath} (closes #${finding.issue})`);
140
+ for (const criterion of finding.unattested) {
141
+ lines.push(...renderCriterion(criterion));
142
+ }
143
+ }
144
+ lines.push(" Evidence is not authenticated — recorded_by accepts any non-empty string. Record what you", " actually did: a pointer must be the artifact its kind names (a test run for test, this PR's", " merge for merge, a deployment for deploy). An agent may evidence a criterion; only", " human-origin provenance may waive one (#3240 / #2944).", " Remediation (performable by this PR's author): stamp the criteria above on the brief in this", " branch, commit, push, then re-run:", ` task verify:pr-closeout-attestable -- --pr ${prNumber}`, " Trigger is the PR's closing references, not the branch diff. A PR that leaves an unattested", " brief without closing its issue is unaffected.");
145
+ if (proxied) {
146
+ lines.push(PROXIED_CAVEAT);
147
+ }
148
+ return lines.join("\n");
149
+ }
150
+ function configError(prNumber, message, proxied = false) {
151
+ return {
152
+ code: 2,
153
+ message: `verify:pr-closeout-attestable: ${message}`,
154
+ stream: "stderr",
155
+ prNumber,
156
+ closingIssues: [],
157
+ findings: [],
158
+ proxied,
159
+ };
160
+ }
161
+ /**
162
+ * Fail closed when merging `prNumber` would close an issue whose brief is still
163
+ * `running` in `active/` with acceptance criteria carrying neither
164
+ * `x-directive/evidence` nor `x-directive/disposition`.
165
+ *
166
+ * Exit contract: 0 attestable (including "this PR closes nothing") / 1 unattested
167
+ * closeout / 2 config or closing-reference lookup error. A lookup that cannot be
168
+ * resolved is 2, not 0 — the gate never green-lights a merge it could not check.
169
+ *
170
+ * The brief is read from `projectRoot`'s working tree, which at merge time is the
171
+ * PR head checkout. That is the tree the merge lands, and it is the same
172
+ * working-tree basis `verify:orphan-active` uses.
173
+ */
174
+ export function evaluate(projectRoot, prNumber, options = {}) {
175
+ const root = resolve(projectRoot);
176
+ const quiet = options.quiet ?? false;
177
+ if (!existsSync(root)) {
178
+ return configError(prNumber, `project root does not exist: ${root}`);
179
+ }
180
+ let lifecycleRoot;
181
+ try {
182
+ lifecycleRoot = resolveLifecycleRoot(root);
183
+ }
184
+ catch (err) {
185
+ const message = err instanceof Error ? err.message : String(err);
186
+ // Consumers may still be on a legacy vbrief/-only layout (#2112). Closeout
187
+ // attestability applies to xbrief/active/ only — skip cleanly, not config fail.
188
+ if (message.includes("No xbrief/ layout found")) {
189
+ return {
190
+ code: 0,
191
+ message: quiet
192
+ ? ""
193
+ : "verify:pr-closeout-attestable: no xbrief/ lifecycle root; nothing to check.",
194
+ stream: quiet ? "none" : "stdout",
195
+ prNumber,
196
+ closingIssues: [],
197
+ findings: [],
198
+ proxied: false,
199
+ };
200
+ }
201
+ return configError(prNumber, message);
202
+ }
203
+ if (!existsSync(lifecycleRoot)) {
204
+ return {
205
+ code: 0,
206
+ message: quiet
207
+ ? ""
208
+ : "verify:pr-closeout-attestable: no xbrief/ lifecycle root; nothing to check.",
209
+ stream: quiet ? "none" : "stdout",
210
+ prNumber,
211
+ closingIssues: [],
212
+ findings: [],
213
+ proxied: false,
214
+ };
215
+ }
216
+ // Pin plain `gh` when it exists: `ghx` is a cached GET proxy and a stale
217
+ // closing-reference read would fail this gate open (#3767 / #3737).
218
+ const runner = options.runner ?? makeGateRunner();
219
+ const fetchClosing = options.fetchClosingIssues ?? fetchClosingIssuesReferences;
220
+ const repo = resolveRepo(options.repo, root);
221
+ if (repo === null || repo.length === 0) {
222
+ // Closing references are repository-scoped. Without the slug this gate could
223
+ // only compare bare numbers, and a same-numbered issue in an unrelated
224
+ // repository would block a valid merge.
225
+ return configError(prNumber, "cannot resolve OWNER/REPO for the closing-reference read. Pass --repo OWNER/REPO, " +
226
+ "set $GH_REPO, or run inside a checkout with a GitHub origin remote.", runner.proxied);
227
+ }
228
+ const linked = fetchClosing(prNumber, repo, runner.runGh);
229
+ if (linked === null) {
230
+ return configError(prNumber, `could not read closing-issue references for PR #${prNumber}` +
231
+ ` (repo=${repo}). ` +
232
+ "Refusing to certify the merge on an unverified lookup — retry after fixing gh auth, " +
233
+ "rate limit, or network.", runner.proxied);
234
+ }
235
+ const closingIssues = [...new Set(linked)].sort((a, b) => a - b);
236
+ if (closingIssues.length === 0) {
237
+ return {
238
+ code: 0,
239
+ message: quiet
240
+ ? ""
241
+ : `verify:pr-closeout-attestable: PR #${prNumber} closes no issue; closeout attestability does not apply.`,
242
+ stream: quiet ? "none" : "stdout",
243
+ prNumber,
244
+ closingIssues,
245
+ findings: [],
246
+ proxied: runner.proxied,
247
+ };
248
+ }
249
+ const closingSet = new Set(closingIssues);
250
+ const findings = [];
251
+ for (const brief of listActiveRunningBriefs(lifecycleRoot)) {
252
+ const { issues } = collectGithubRefs(brief.plan, repo);
253
+ // Match on (repo, number). Closing references are scoped to the PR's repository,
254
+ // so a bare-number match would let an unrelated brief tracking the same number in
255
+ // another repository block this merge. Refs with no repo of their own inherit the
256
+ // PR's repo from collectGithubRefs, which is the correct reading of a bare number.
257
+ // GitHub slugs are case-insensitive; a case-sensitive compare would let
258
+ // DeftAI/Directive vs deftai/directive miss and fail the gate open.
259
+ const issue = issues.find((ref) => ref.repo.toLowerCase() === repo.toLowerCase() && closingSet.has(ref.number))?.number;
260
+ if (issue === undefined) {
261
+ continue;
262
+ }
263
+ const gate = evaluateAcceptanceEvidenceGate(brief.plan);
264
+ if (gate.ok) {
265
+ continue;
266
+ }
267
+ const itemsByPath = new Map();
268
+ indexPlanItems(brief.plan.items, "items", itemsByPath);
269
+ const unattested = gate.reports
270
+ .filter((report) => report.outcome === "missing" || report.outcome === "invalid")
271
+ .map((report) => {
272
+ const item = itemsByPath.get(report.path);
273
+ return {
274
+ path: report.path,
275
+ title: report.title,
276
+ detail: report.detail,
277
+ requiredAxes: item === undefined ? [] : inferRequiredStrictAxes(item),
278
+ };
279
+ });
280
+ findings.push({ briefPath: relBriefPath(brief.path, root), issue, unattested });
281
+ }
282
+ if (findings.length > 0) {
283
+ return {
284
+ code: 1,
285
+ message: formatRefusal(prNumber, findings, root, runner.proxied),
286
+ stream: "stderr",
287
+ prNumber,
288
+ closingIssues,
289
+ findings,
290
+ proxied: runner.proxied,
291
+ };
292
+ }
293
+ const issueList = closingIssues.map((n) => `#${n}`).join(", ");
294
+ const pass = `verify:pr-closeout-attestable: PR #${prNumber} closes ${issueList}; ` +
295
+ "no active/running brief for those issues has unattested acceptance criteria.";
296
+ return {
297
+ code: 0,
298
+ message: quiet ? "" : runner.proxied ? `${pass}\n${PROXIED_CAVEAT}` : pass,
299
+ stream: quiet ? "none" : "stdout",
300
+ prNumber,
301
+ closingIssues,
302
+ findings: [],
303
+ proxied: runner.proxied,
304
+ };
305
+ }
306
+ //# sourceMappingURL=evaluate.js.map
@@ -0,0 +1,2 @@
1
+ export * from "./evaluate.js";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,2 @@
1
+ export * from "./evaluate.js";
2
+ //# sourceMappingURL=index.js.map
@@ -1,6 +1,10 @@
1
1
  import type { RunGhFn, RunGhResult } from "./types.js";
2
- /** UTF-8-safe gh capture via execFile (no shell) — mirrors _safe_subprocess.run_text (#1366). */
3
- export declare function defaultRunGh(cmd: readonly string[]): RunGhResult;
2
+ /**
3
+ * UTF-8-safe gh capture via execFile (no shell) — mirrors _safe_subprocess.run_text (#1366).
4
+ * `binaryOverride` lets a caller pin the SCM binary (e.g. plain `gh` instead of
5
+ * the `ghx` cached proxy) when its guarantee depends on an uncached read (#3767).
6
+ */
7
+ export declare function defaultRunGh(cmd: readonly string[], binaryOverride?: string): RunGhResult;
4
8
  /**
5
9
  * Run ``gh pr view <N> --json closingIssuesReferences`` and return linked issue
6
10
  * numbers. Returns ``null`` on external error (caller maps to EXIT_EXTERNAL_ERROR).
@@ -2,12 +2,16 @@ 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 } from "./constants.js";
5
- /** UTF-8-safe gh capture via execFile (no shell) — mirrors _safe_subprocess.run_text (#1366). */
6
- export function defaultRunGh(cmd) {
5
+ /**
6
+ * UTF-8-safe gh capture via execFile (no shell) — mirrors _safe_subprocess.run_text (#1366).
7
+ * `binaryOverride` lets a caller pin the SCM binary (e.g. plain `gh` instead of
8
+ * the `ghx` cached proxy) when its guarantee depends on an uncached read (#3767).
9
+ */
10
+ export function defaultRunGh(cmd, binaryOverride) {
7
11
  if (cmd.length === 0 || cmd[0] !== "gh") {
8
12
  return { returncode: -1, stdout: "", stderr: "expected gh as first argv element" };
9
13
  }
10
- const binary = resolveBinary();
14
+ const binary = binaryOverride ?? resolveBinary();
11
15
  const args = cmd.slice(1);
12
16
  try {
13
17
  const stdout = execFileSync(binary, args, {
@@ -1,7 +1,7 @@
1
1
  import { type EnforceMergeApprovalHeadInput, type MergeApprovalHeadResult } from "../policy/merge-approval-head.js";
2
2
  import { type AgentMergeEvaluateResult } from "../policy/require-human-merge.js";
3
3
  import { type SemanticGreenFn } from "./semantic-green.js";
4
- import type { MergeFn, MonitorFn, ProtectedCheckFn, WaitMergeableResult } from "./types.js";
4
+ import type { CloseoutAttestableFn, MergeFn, MonitorFn, ProtectedCheckFn, WaitMergeableResult } from "./types.js";
5
5
  /** Post-merge umbrella checklist + current-shape refresh (#1649). */
6
6
  export type UmbrellaReconcileFn = (projectRoot: string, repo: string) => void;
7
7
  export interface WaitMergeableOptions {
@@ -32,6 +32,14 @@ export interface WaitMergeableOptions {
32
32
  readonly skipMergeApprovalHeadGate?: boolean;
33
33
  /** Inject head-bound approval enforcer (unit tests). */
34
34
  readonly mergeApprovalHeadFn?: (input: EnforceMergeApprovalHeadInput) => MergeApprovalHeadResult;
35
+ /**
36
+ * When true, skip the merge-time closeout attestability gate (#3781).
37
+ * Default: same as skipHumanMergeGate, matching the head-gate convention —
38
+ * test harnesses that skip human-merge also skip this unless they opt in.
39
+ */
40
+ readonly skipCloseoutAttestableGate?: boolean;
41
+ /** Inject the closeout attestability gate (unit tests). */
42
+ readonly closeoutAttestableFn?: CloseoutAttestableFn;
35
43
  /** Inject PR HEAD fetch (unit tests; defaults to REST `pulls/<N>`). */
36
44
  readonly fetchPrHeadShaFn?: (prNumber: number, repo: string | null) => string | null;
37
45
  /**
@@ -6,7 +6,7 @@ import { classifyMonitorOutcome, parseMonitorPayload } from "./classify.js";
6
6
  import { EXIT_CONFIG_ERROR, EXIT_MERGED, EXIT_TIMEOUT_OR_ESCALATION } from "./constants.js";
7
7
  import { makeResult } from "./result.js";
8
8
  import { evaluateSemanticGreen } from "./semantic-green.js";
9
- import { runGhMerge, runMonitor, runProtectedCheck } from "./wrappers.js";
9
+ import { runCloseoutAttestableCheck, runGhMerge, runMonitor, runProtectedCheck, } from "./wrappers.js";
10
10
  /** Node module-not-found / missing script — not a protected-issue overlap (#2667). */
11
11
  function isProtectedCheckConfigFailure(stderr) {
12
12
  const tail = stderr.trim();
@@ -228,6 +228,26 @@ export function waitMergeableAndMerge(prNumber, repo, options) {
228
228
  }
229
229
  }
230
230
  }
231
+ // #3781: last gate before the merge call. A PR may leave a brief unattested; it
232
+ // may not merge one whose issue it closes in the same act. Keyed on the PR's
233
+ // closing references, so it fires even when the brief is not in the branch diff.
234
+ const skipCloseoutGate = options.skipCloseoutAttestableGate === true ||
235
+ (options.skipCloseoutAttestableGate === undefined && options.skipHumanMergeGate === true);
236
+ if (!skipCloseoutGate) {
237
+ const closeoutFn = options.closeoutAttestableFn ?? runCloseoutAttestableCheck;
238
+ const [coRc, , coStderr] = closeoutFn(prNumber, repo, projectRoot);
239
+ if (coRc !== 0) {
240
+ return makeResult({
241
+ prNumber,
242
+ repo,
243
+ outcome: coRc === 1 ? "closeout-unattested" : "config-error",
244
+ exitCode: coRc === 1 ? EXIT_TIMEOUT_OR_ESCALATION : EXIT_CONFIG_ERROR,
245
+ monitorResult: monitorPayload,
246
+ protectedCheck: protectedCheckPayload,
247
+ error: coStderr.trim(),
248
+ });
249
+ }
250
+ }
231
251
  const [mergeRc, mergeStdout, mergeStderr] = mergeFn(prNumber, repo, {
232
252
  matchHeadCommit,
233
253
  });
@@ -18,4 +18,6 @@ export type MonitorFn = (prNumber: number, repo: string, capMinutes: number, opt
18
18
  export type MergeFn = (prNumber: number, repo: string | null, options?: {
19
19
  readonly matchHeadCommit?: string | null;
20
20
  }) => SubprocessTriple;
21
+ /** Merge-time closeout attestability gate (#3781): 0 attestable / 1 unattested / 2 lookup. */
22
+ export type CloseoutAttestableFn = (prNumber: number, repo: string | null, projectRoot: string) => SubprocessTriple;
21
23
  //# sourceMappingURL=types.d.ts.map
@@ -32,6 +32,12 @@ export interface RunProtectedCheckOptions {
32
32
  }
33
33
  /** Invoke pr-protected-issues CLI and return (returncode, stdout, stderr). */
34
34
  export declare function runProtectedCheck(prNumber: number, repo: string | null, protectedIssues: readonly number[], options?: RunProtectedCheckOptions): SubprocessTriple;
35
+ /**
36
+ * Merge-time closeout attestability gate (#3781). In-process: the evaluator lives
37
+ * in this package, and a subprocess hop would only add a script-path failure mode
38
+ * to a gate that must fail closed.
39
+ */
40
+ export declare function runCloseoutAttestableCheck(prNumber: number, repo: string | null, projectRoot: string): SubprocessTriple;
35
41
  export interface RunMonitorOptions {
36
42
  readonly nodeExecutable?: string;
37
43
  readonly timeout?: number;
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import { dirname, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { evaluate as evaluateCloseoutAttestable } from "../pr-closeout-attestable/evaluate.js";
6
7
  import { resolveBinary } from "../scm/binary.js";
7
8
  import { SUBPROCESS_MAX_BUFFER } from "../subprocess/max-buffer.js";
8
9
  /** UTF-8-safe subprocess capture via spawnSync (no shell) — mirrors #1366. */
@@ -97,6 +98,16 @@ export function runProtectedCheck(prNumber, repo, protectedIssues, options = {})
97
98
  });
98
99
  return [result.returncode, result.stdout, result.stderr];
99
100
  }
101
+ /**
102
+ * Merge-time closeout attestability gate (#3781). In-process: the evaluator lives
103
+ * in this package, and a subprocess hop would only add a script-path failure mode
104
+ * to a gate that must fail closed.
105
+ */
106
+ export function runCloseoutAttestableCheck(prNumber, repo, projectRoot) {
107
+ const result = evaluateCloseoutAttestable(projectRoot, prNumber, { repo });
108
+ const text = result.message.length > 0 ? `${result.message}\n` : "";
109
+ return result.code === 0 ? [0, text, ""] : [result.code, "", text];
110
+ }
100
111
  /** Invoke pr-monitor CLI with --json and return (returncode, stdout, stderr). */
101
112
  export function runMonitor(prNumber, repo, capMinutes, options = {}) {
102
113
  const scriptPath = cliScriptPath("pr-monitor");
@@ -6,6 +6,19 @@ export declare const ACTIVE_FOLDER = "active";
6
6
  export declare const ELIGIBLE_STATUS = "running";
7
7
  /** Actionable redirect appended to every reject path (#810 / #2449). */
8
8
  export declare const ACTIVATE_HINT = "Run `task scope:activate -- {path}` (or legacy `task vbrief:activate -- {path}`) before spawning an implementation agent.";
9
+ /**
10
+ * Recovery for the origin-freshness reject (#3828).
11
+ *
12
+ * Freshness is only reached after the active/ + running checks pass, so
13
+ * `ACTIVATE_HINT` names a transition that cannot apply here: `activate` accepts
14
+ * `pending/` alone and fails with `Invalid transition: 'activate' requires file
15
+ * in pending/`. `block` -> `unblock` is in place (`targetFolder: null`), requires
16
+ * and restores `running`, and stamps `plan.updated` plus `xBRIEFInfo.updated`,
17
+ * the field this reject compares. It is interim: the pair also records a
18
+ * `blocked` -> `running` transition for a brief that was never blocked, a
19
+ * tradeoff accepted knowingly. #3857 owns the dedicated acknowledge verb.
20
+ */
21
+ export declare const ORIGIN_FRESHNESS_HINT: string;
9
22
  /** Lifecycle folder names eligible for implementation (#810). */
10
23
  export declare const ELIGIBLE_LIFECYCLE_DIRS: readonly ["xbrief/active", "vbrief/active"];
11
24
  export declare const PREFLIGHT_USAGE_HINT = "Expected: `task xbrief:preflight -- xbrief/active/<story>.xbrief.json` (legacy: `task vbrief:preflight -- <path>`).";
@@ -32,6 +45,8 @@ export interface EvaluateOptions {
32
45
  }
33
46
  /** Substitute `{path}` without `$`-pattern expansion in user paths (#1721). */
34
47
  export declare function formatActivateHint(path: string): string;
48
+ /** Substitute every `{path}` without `$`-pattern expansion in user paths (#1721). */
49
+ export declare function formatOriginFreshnessHint(path: string): string;
35
50
  /**
36
51
  * Pure evaluator — returns `{ exitCode, message }`. Never throws; every error
37
52
  * path collapses to exit 1 with an actionable message. Faithful to
@@ -11,6 +11,26 @@ export const ACTIVE_FOLDER = "active";
11
11
  export const ELIGIBLE_STATUS = "running";
12
12
  /** Actionable redirect appended to every reject path (#810 / #2449). */
13
13
  export const ACTIVATE_HINT = "Run `task scope:activate -- {path}` (or legacy `task vbrief:activate -- {path}`) before spawning an implementation agent.";
14
+ /**
15
+ * Recovery for the origin-freshness reject (#3828).
16
+ *
17
+ * Freshness is only reached after the active/ + running checks pass, so
18
+ * `ACTIVATE_HINT` names a transition that cannot apply here: `activate` accepts
19
+ * `pending/` alone and fails with `Invalid transition: 'activate' requires file
20
+ * in pending/`. `block` -> `unblock` is in place (`targetFolder: null`), requires
21
+ * and restores `running`, and stamps `plan.updated` plus `xBRIEFInfo.updated`,
22
+ * the field this reject compares. It is interim: the pair also records a
23
+ * `blocked` -> `running` transition for a brief that was never blocked, a
24
+ * tradeoff accepted knowingly. #3857 owns the dedicated acknowledge verb.
25
+ */
26
+ export const ORIGIN_FRESHNESS_HINT = "Recovery: this xBRIEF is already in active/ at status running. " +
27
+ "Once you have re-read the origin delta, stamp xBRIEFInfo.updated in place with " +
28
+ "`task scope:block -- {path}` then `task scope:unblock -- {path}`. " +
29
+ "That pair is interim: it records a blocked -> running transition for a brief " +
30
+ "that was never blocked, and #3857 owns the verb that records the " +
31
+ "acknowledgement honestly. If the origin could not be fetched or compared, " +
32
+ "restore `gh` REST access to the origin repository first -- a stamp does not " +
33
+ "clear that.";
14
34
  /** Lifecycle folder names eligible for implementation (#810). */
15
35
  export const ELIGIBLE_LIFECYCLE_DIRS = ["xbrief/active", "vbrief/active"];
16
36
  export const PREFLIGHT_USAGE_HINT = "Expected: `task xbrief:preflight -- xbrief/active/<story>.xbrief.json` (legacy: `task vbrief:preflight -- <path>`).";
@@ -18,8 +38,12 @@ export const PREFLIGHT_USAGE_HINT = "Expected: `task xbrief:preflight -- xbrief/
18
38
  export function formatActivateHint(path) {
19
39
  return ACTIVATE_HINT.replace("{path}", () => path);
20
40
  }
21
- function buildReject(path, reason) {
22
- return `${reason}\n ${PREFLIGHT_USAGE_HINT}\n ${formatActivateHint(path)}`;
41
+ /** Substitute every `{path}` without `$`-pattern expansion in user paths (#1721). */
42
+ export function formatOriginFreshnessHint(path) {
43
+ return ORIGIN_FRESHNESS_HINT.split("{path}").join(path);
44
+ }
45
+ function buildReject(path, reason, hint) {
46
+ return `${reason}\n ${PREFLIGHT_USAGE_HINT}\n ${hint ?? formatActivateHint(path)}`;
23
47
  }
24
48
  /** Map Node `JSON.parse` errors to CPython `json.JSONDecodeError.msg` for parity (#1721). */
25
49
  function nodeJsonErrorToPythonMsg(nodeMessage) {
@@ -167,7 +191,9 @@ export function evaluate(vbriefPath, options = {}) {
167
191
  return {
168
192
  exitCode: 1,
169
193
  parentLineage: lineage,
170
- message: buildReject(path, originFreshness.message),
194
+ // #3828: the brief is active + running by this point, so the activate
195
+ // hint would name a transition that hard-errors from the printed state.
196
+ message: buildReject(path, originFreshness.message, formatOriginFreshnessHint(path)),
171
197
  };
172
198
  }
173
199
  // #3425: fail closed when an applicable project invariant has no disposition.
@@ -93,18 +93,30 @@ export declare function resolveAcceptanceVerdict(reading: AcceptanceReading): Ac
93
93
  /** One line naming the predicate and the value it read (#3497). */
94
94
  export declare function formatAcceptanceVerdict(verdictResult: AcceptanceVerdict): string;
95
95
  /**
96
- * Clause-walk composition (#3323 / #3497).
96
+ * Clause-walk composition (#3323 / #3497 / #3826).
97
97
  *
98
98
  * A clause the shipped artifact contradicts (`failed`) always blocks. A clause the
99
99
  * static walk cannot decide (`unverifiable`) is evidence of nothing — it blocks only
100
100
  * when nothing else verified the product. A green executable acceptance run IS that
101
101
  * something: the product-first oracle already ran. Before #3497 an all-unverifiable
102
102
  * clause set refused an artifact whose stated command had just exited 0.
103
+ *
104
+ * #3826 excused a set the walk has no oracle for at all: a clause with no bound
105
+ * artifact path can only ever come back `unverifiable`, so demanding a positive
106
+ * `verified` from a set of them cannot be satisfied by doing the work correctly.
107
+ * Where the walk has no oracle, `failed === 0` is the strongest verdict available
108
+ * and `evaluateAcceptanceEvidenceGate` adjudicates those criteria at `scope:complete`.
109
+ *
110
+ * #3835 makes that excusal per clause. As a *set* predicate, `verified > 0` was
111
+ * re-armed for every clause by any single bound one, so one verified binding
112
+ * covered siblings that had their own oracle and did not meet it.
113
+ * `adjudicableUnverified` counts exactly those siblings.
103
114
  */
104
115
  export declare function clauseWalkBlocks(input: {
105
116
  readonly failed: number;
106
- readonly verified: number;
107
117
  readonly walked: number;
118
+ /** Clauses the walk had an oracle for and that did not come back `verified`. */
119
+ readonly adjudicableUnverified: number;
108
120
  readonly hasGreenExecutableRun: boolean;
109
121
  }): boolean;
110
122
  /** Which consumer is reading acceptance. Selects one shared option profile (#3497). */