@deftai/directive-core 0.87.0 → 0.89.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 (193) hide show
  1. package/dist/authz/actions.d.ts +55 -0
  2. package/dist/authz/actions.js +125 -0
  3. package/dist/authz/classify.d.ts +23 -0
  4. package/dist/authz/classify.js +217 -0
  5. package/dist/authz/closed-verb.d.ts +42 -0
  6. package/dist/authz/closed-verb.js +279 -0
  7. package/dist/authz/evaluate.d.ts +41 -0
  8. package/dist/authz/evaluate.js +298 -0
  9. package/dist/authz/index.d.ts +15 -0
  10. package/dist/authz/index.js +15 -0
  11. package/dist/authz/origin.d.ts +28 -0
  12. package/dist/authz/origin.js +64 -0
  13. package/dist/authz/paths.d.ts +6 -0
  14. package/dist/authz/paths.js +19 -0
  15. package/dist/authz/store.d.ts +40 -0
  16. package/dist/authz/store.js +317 -0
  17. package/dist/authz/templates.d.ts +139 -0
  18. package/dist/authz/templates.js +241 -0
  19. package/dist/authz/types.d.ts +111 -0
  20. package/dist/authz/types.js +41 -0
  21. package/dist/authz/verb-classification.d.ts +44 -0
  22. package/dist/authz/verb-classification.js +237 -0
  23. package/dist/branch/evaluate.js +6 -1
  24. package/dist/cache/fetch.js +10 -5
  25. package/dist/cache/io.d.ts +9 -2
  26. package/dist/cache/io.js +30 -10
  27. package/dist/cache/scanner.d.ts +11 -1
  28. package/dist/cache/scanner.js +29 -4
  29. package/dist/cache/task-cache/store.js +11 -7
  30. package/dist/capacity/backfill.js +10 -4
  31. package/dist/category-b-namespace/index.js +10 -4
  32. package/dist/check/gate-lists.js +1 -0
  33. package/dist/codebase/default-extractor.js +3 -0
  34. package/dist/codebase/map.js +11 -4
  35. package/dist/content-contracts/skills/helpers.d.ts +10 -0
  36. package/dist/content-contracts/skills/helpers.js +35 -0
  37. package/dist/deposit/copy-tree.d.ts +19 -1
  38. package/dist/deposit/copy-tree.js +134 -5
  39. package/dist/doctor/doctor-state.js +28 -4
  40. package/dist/doctor/main.d.ts +10 -0
  41. package/dist/doctor/main.js +208 -0
  42. package/dist/doctor/types.d.ts +11 -0
  43. package/dist/escalation/actions.d.ts +63 -0
  44. package/dist/escalation/actions.js +137 -0
  45. package/dist/escalation/index.d.ts +8 -0
  46. package/dist/escalation/index.js +8 -0
  47. package/dist/escalation/paths.d.ts +3 -0
  48. package/dist/escalation/paths.js +10 -0
  49. package/dist/escalation/store.d.ts +17 -0
  50. package/dist/escalation/store.js +172 -0
  51. package/dist/escalation/types.d.ts +73 -0
  52. package/dist/escalation/types.js +43 -0
  53. package/dist/eval/crud-telemetry.js +30 -10
  54. package/dist/eval/health.js +22 -7
  55. package/dist/eval/readback.js +11 -10
  56. package/dist/eval/run.js +32 -16
  57. package/dist/events/attribution-enrichment.js +10 -4
  58. package/dist/finish-loop/directive-finish-loop.d.ts +37 -0
  59. package/dist/finish-loop/directive-finish-loop.js +239 -0
  60. package/dist/finish-loop/grant-gate.d.ts +31 -0
  61. package/dist/finish-loop/grant-gate.js +169 -0
  62. package/dist/finish-loop/index.d.ts +11 -0
  63. package/dist/finish-loop/index.js +11 -0
  64. package/dist/finish-loop/main.d.ts +35 -0
  65. package/dist/finish-loop/main.js +365 -0
  66. package/dist/finish-loop/pr-finish-loop.d.ts +34 -0
  67. package/dist/finish-loop/pr-finish-loop.js +217 -0
  68. package/dist/finish-loop/progress.d.ts +17 -0
  69. package/dist/finish-loop/progress.js +45 -0
  70. package/dist/finish-loop/queue.d.ts +16 -0
  71. package/dist/finish-loop/queue.js +65 -0
  72. package/dist/finish-loop/types.d.ts +52 -0
  73. package/dist/finish-loop/types.js +10 -0
  74. package/dist/fs/contained-write.d.ts +101 -0
  75. package/dist/fs/contained-write.js +281 -0
  76. package/dist/fs/projection-containment.d.ts +18 -0
  77. package/dist/fs/projection-containment.js +40 -0
  78. package/dist/hooks/classify/classify.d.ts +10 -0
  79. package/dist/hooks/classify/classify.js +37 -0
  80. package/dist/hooks/classify/index.d.ts +14 -0
  81. package/dist/hooks/classify/index.js +13 -0
  82. package/dist/hooks/classify/paths.d.ts +22 -0
  83. package/dist/hooks/classify/paths.js +72 -0
  84. package/dist/hooks/classify/payload.d.ts +16 -0
  85. package/dist/hooks/classify/payload.js +45 -0
  86. package/dist/hooks/classify/stdin.d.ts +12 -0
  87. package/dist/hooks/classify/stdin.js +63 -0
  88. package/dist/hooks/classify/tool-name.d.ts +18 -0
  89. package/dist/hooks/classify/tool-name.js +85 -0
  90. package/dist/hooks/classify/types.d.ts +41 -0
  91. package/dist/hooks/classify/types.js +7 -0
  92. package/dist/hooks/dispatcher.d.ts +30 -15
  93. package/dist/hooks/dispatcher.js +326 -134
  94. package/dist/hooks/fixtures/cases.d.ts +44 -0
  95. package/dist/hooks/fixtures/cases.js +595 -0
  96. package/dist/hooks/fixtures/index.d.ts +2 -0
  97. package/dist/hooks/fixtures/index.js +2 -0
  98. package/dist/hooks/index.d.ts +2 -0
  99. package/dist/hooks/index.js +2 -0
  100. package/dist/hooks/tools.d.ts +31 -0
  101. package/dist/hooks/tools.js +74 -0
  102. package/dist/index.d.ts +4 -0
  103. package/dist/index.js +5 -0
  104. package/dist/init-deposit/agent-hooks.d.ts +1 -1
  105. package/dist/init-deposit/agent-hooks.js +65 -10
  106. package/dist/init-deposit/gitignore.js +11 -3
  107. package/dist/init-deposit/headless-manifest.js +12 -4
  108. package/dist/init-deposit/hygiene.d.ts +16 -0
  109. package/dist/init-deposit/hygiene.js +26 -0
  110. package/dist/init-deposit/init-dispatch.js +28 -0
  111. package/dist/init-deposit/migrate.d.ts +1 -1
  112. package/dist/init-deposit/migrate.js +13 -3
  113. package/dist/init-deposit/prettierignore.js +12 -5
  114. package/dist/init-deposit/refresh.js +38 -7
  115. package/dist/init-deposit/scaffold.js +42 -29
  116. package/dist/init-deposit/xbrief-projections.js +17 -10
  117. package/dist/intake/candidates-log.js +46 -35
  118. package/dist/intake/github-body-cli.d.ts +6 -0
  119. package/dist/intake/github-body-cli.js +17 -0
  120. package/dist/intake/github-body.d.ts +46 -3
  121. package/dist/intake/github-body.js +191 -20
  122. package/dist/intake/issue-emit.d.ts +1 -0
  123. package/dist/intake/issue-emit.js +41 -23
  124. package/dist/intake/issue-ingest.js +11 -2
  125. package/dist/lifecycle/event-detect.js +12 -4
  126. package/dist/lifecycle/events.js +29 -20
  127. package/dist/lifecycle/lifecycle-hygiene.js +9 -3
  128. package/dist/packs/pack-render.d.ts +33 -0
  129. package/dist/packs/pack-render.js +172 -13
  130. package/dist/packs/quarantine-ext.d.ts +10 -0
  131. package/dist/packs/quarantine-ext.js +26 -2
  132. package/dist/plan-sequence/store.js +11 -4
  133. package/dist/policy/hotfix-criteria.d.ts +79 -0
  134. package/dist/policy/hotfix-criteria.js +197 -0
  135. package/dist/policy/index.d.ts +5 -0
  136. package/dist/policy/index.js +32 -1
  137. package/dist/policy/intent-ceiling.d.ts +79 -0
  138. package/dist/policy/intent-ceiling.js +181 -0
  139. package/dist/policy/no-deft-directive.d.ts +59 -0
  140. package/dist/policy/no-deft-directive.js +110 -0
  141. package/dist/policy/org-force-on-migration.d.ts +52 -0
  142. package/dist/policy/org-force-on-migration.js +269 -27
  143. package/dist/policy/require-human-merge.d.ts +75 -0
  144. package/dist/policy/require-human-merge.js +324 -0
  145. package/dist/policy/resolve.js +17 -6
  146. package/dist/policy/runtime-authority.d.ts +56 -2
  147. package/dist/policy/runtime-authority.js +297 -3
  148. package/dist/policy/write-fence.d.ts +78 -0
  149. package/dist/policy/write-fence.js +164 -0
  150. package/dist/pr-wait-mergeable/cascade.d.ts +10 -0
  151. package/dist/pr-wait-mergeable/cascade.js +28 -0
  152. package/dist/preflight/evaluate.js +10 -0
  153. package/dist/product-signal/consent.js +21 -5
  154. package/dist/product-signal/submit.js +22 -12
  155. package/dist/release/build-dist-runner.js +5 -2
  156. package/dist/release/build-dist.d.ts +19 -1
  157. package/dist/release/build-dist.js +50 -2
  158. package/dist/release/native-steps.js +7 -2
  159. package/dist/release-publish/pipeline.d.ts +13 -0
  160. package/dist/release-publish/pipeline.js +46 -1
  161. package/dist/scope/audit-log.js +19 -24
  162. package/dist/scope/decompose.js +10 -5
  163. package/dist/scope/decomposed-refs.js +18 -3
  164. package/dist/scope/demote.js +9 -2
  165. package/dist/scope/undo.js +9 -2
  166. package/dist/session/release-availability.js +26 -6
  167. package/dist/session/ritual-sentinel.js +19 -12
  168. package/dist/session/session-start-hook.d.ts +3 -0
  169. package/dist/session/session-start-hook.js +15 -0
  170. package/dist/session/session-start.js +37 -0
  171. package/dist/staleness-tickler/state.js +26 -6
  172. package/dist/swarm/launch.js +13 -3
  173. package/dist/swarm/routing.js +9 -3
  174. package/dist/task-surface/index.js +24 -9
  175. package/dist/triage/bootstrap/gitignore.js +53 -10
  176. package/dist/triage/cache-path.js +10 -3
  177. package/dist/triage/welcome/summary.js +11 -4
  178. package/dist/triage/welcome/writers.js +45 -16
  179. package/dist/validate-content/validate-links.js +5 -0
  180. package/dist/value/readback.js +11 -6
  181. package/dist/vbrief-activate/activate.js +12 -4
  182. package/dist/vbrief-build/project-definition-io.js +14 -6
  183. package/dist/verify-source/contained-writes.d.ts +59 -0
  184. package/dist/verify-source/contained-writes.js +272 -0
  185. package/dist/verify-source/cursor-tier1.js +7 -2
  186. package/dist/verify-source/index.d.ts +1 -0
  187. package/dist/verify-source/index.js +1 -0
  188. package/dist/verify-source/openclaw-tier1.js +19 -1
  189. package/dist/verify-source/verify-stubs.js +3 -0
  190. package/dist/xbrief-migrate/migrate-project.js +26 -12
  191. package/dist/xbrief-migrate/transforms.d.ts +4 -2
  192. package/dist/xbrief-migrate/transforms.js +37 -14
  193. package/package.json +16 -3
@@ -1,8 +1,10 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
+ import { sortKeysDeep } from "../codebase/json.js";
3
4
  import { readCorePackageVersion } from "../engine-version.js";
4
- import { assertWriteTargetSafe } from "../fs/projection-containment.js";
5
+ import { containedWrite } from "../fs/contained-write.js";
5
6
  import { atomicWriteProjectDefinition, projectDefinitionMutationLock, } from "../vbrief-build/project-definition-io.js";
7
+ import { isNoDeftDirectivePresent } from "./no-deft-directive.js";
6
8
  import { migrateLegacyPolicyKey, PLAN_POLICY_KEY, readPlanPolicy } from "./plan-extensions.js";
7
9
  import { appendAuditLog, loadProjectDefinition, projectDefinitionPath } from "./resolve.js";
8
10
  import { detectOriginOrg, isTrustedOrgAutoEnable, } from "./value-feedback-autoenable.js";
@@ -20,6 +22,18 @@ const DEFAULT_PRODUCT_SIGNAL_SINK = "deftai/product-signal";
20
22
  function markerPath(projectRoot) {
21
23
  return resolve(projectRoot, ORG_FORCE_ON_MARKER_REL);
22
24
  }
25
+ /** Normalize undefined → null and deep-sort keys so marker snapshots compare stably. */
26
+ export function normalizePolicySnapshot(raw) {
27
+ return sortKeysDeep(raw === undefined ? null : raw);
28
+ }
29
+ /**
30
+ * Structural equality for policy snapshots (#2903).
31
+ * Key order independent via sortKeysDeep — discarded-PD recovery must not miss
32
+ * semantically equal blocks written with different JSON key order.
33
+ */
34
+ export function deepEqualPolicySnapshot(a, b) {
35
+ return JSON.stringify(normalizePolicySnapshot(a)) === JSON.stringify(normalizePolicySnapshot(b));
36
+ }
23
37
  function parseMarker(raw) {
24
38
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
25
39
  return null;
@@ -33,7 +47,7 @@ function parseMarker(raw) {
33
47
  typeof rec.directiveVersion !== "string") {
34
48
  return null;
35
49
  }
36
- return {
50
+ const marker = {
37
51
  version: ORG_FORCE_ON_MARKER_VERSION,
38
52
  appliedAt: rec.appliedAt,
39
53
  originOrg: rec.originOrg,
@@ -41,6 +55,23 @@ function parseMarker(raw) {
41
55
  productSignal: rec.productSignal,
42
56
  directiveVersion: rec.directiveVersion,
43
57
  };
58
+ // Optional #2903 previous* snapshots — preserve when present (including null).
59
+ if ("previousValueFeedback" in rec) {
60
+ return {
61
+ ...marker,
62
+ previousValueFeedback: rec.previousValueFeedback,
63
+ ...("previousProductSignal" in rec
64
+ ? { previousProductSignal: rec.previousProductSignal }
65
+ : {}),
66
+ };
67
+ }
68
+ if ("previousProductSignal" in rec) {
69
+ return {
70
+ ...marker,
71
+ previousProductSignal: rec.previousProductSignal,
72
+ };
73
+ }
74
+ return marker;
44
75
  }
45
76
  /** Read the #2822 migration marker when present and valid. */
46
77
  export function readOrgForceOnMarker(projectRoot) {
@@ -57,9 +88,13 @@ export function readOrgForceOnMarker(projectRoot) {
57
88
  }
58
89
  function writeOrgForceOnMarker(projectRoot, marker) {
59
90
  const path = markerPath(projectRoot);
60
- assertWriteTargetSafe(projectRoot, path);
61
- mkdirSync(join(path, ".."), { recursive: true });
62
- writeFileSync(path, `${JSON.stringify(marker, null, 2)}\n`, "utf8");
91
+ // #2951 Phase 2: product write sink routes through containedWrite.
92
+ containedWrite({
93
+ root: resolve(projectRoot),
94
+ target: path,
95
+ data: `${JSON.stringify(marker, null, 2)}\n`,
96
+ mode: "replace",
97
+ });
63
98
  }
64
99
  function readTypedBoolean(raw, key, fallback) {
65
100
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
@@ -81,6 +116,83 @@ function valueFeedbackNeedsForceOn(raw) {
81
116
  function productSignalNeedsForceOn(raw) {
82
117
  return !readTypedBoolean(raw, "enabled", false);
83
118
  }
119
+ /**
120
+ * Sentinel previous* for an unforced field that had no stored baseline (legacy
121
+ * partial recovery). Must not deep-equal any real policy snapshot — including
122
+ * null/undefined after clear-value-feedback — or the next update would re-force.
123
+ */
124
+ export const ORG_FORCE_ON_NO_BASELINE = {
125
+ "x-directive/org-force-on": "no-baseline",
126
+ };
127
+ /** Classic #2822 pre-migration all-false valueFeedback baseline (statusreport). */
128
+ export const PRE_MIGRATION_VALUE_FEEDBACK_BASELINE = {
129
+ enabled: false,
130
+ emitEvents: false,
131
+ sessionLine: false,
132
+ upstreamPrompt: false,
133
+ };
134
+ /** True when current VF looks like discarded pre-migration state (legacy markers). */
135
+ export function looksLikePreMigrationValueFeedback(raw) {
136
+ // Missing key (undefined/null) is NOT the classic baseline — that is the
137
+ // clear-value-feedback / never-typed shape. statusreport-class discard keeps
138
+ // an explicit all-false object in PROJECT-DEFINITION.
139
+ if (raw === undefined || raw === null) {
140
+ return false;
141
+ }
142
+ return deepEqualPolicySnapshot(raw, PRE_MIGRATION_VALUE_FEEDBACK_BASELINE);
143
+ }
144
+ /** True when current PS looks like discarded pre-migration state (legacy markers). */
145
+ export function looksLikePreMigrationProductSignal(raw) {
146
+ if (raw === undefined || raw === null) {
147
+ return true;
148
+ }
149
+ if (typeof raw !== "object" || Array.isArray(raw)) {
150
+ return false;
151
+ }
152
+ const block = raw;
153
+ if (block.enabled !== false) {
154
+ return false;
155
+ }
156
+ const keys = Object.keys(block);
157
+ // Bare {enabled:false} or default sink only — custom sinkRepo implies intent.
158
+ if (keys.length === 1) {
159
+ return true;
160
+ }
161
+ if (keys.length === 2 &&
162
+ typeof block.sinkRepo === "string" &&
163
+ block.sinkRepo.trim() === DEFAULT_PRODUCT_SIGNAL_SINK) {
164
+ return true;
165
+ }
166
+ return false;
167
+ }
168
+ /**
169
+ * Incomplete migration (#2903): marker present, PD still needs force-on, and
170
+ * current typed block still equals the pre-migration snapshot (or legacy marker
171
+ * has no previous* field and current still looks like the classic baseline).
172
+ *
173
+ * Exact restore of the pre-migration snapshot is treated as incomplete by design
174
+ * (company policy #2376 / #2822 / #2903): discarded working-tree force-on and
175
+ * "put the old all-false block back" are not distinguishable without git history,
176
+ * and trusted-org local collection should stay ON. True intentional opt-out must
177
+ * use a shape that differs from previous*, `policy:clear-value-feedback`, or
178
+ * root `.no-deft-directive`. Outbound product-signal still requires D17 consent.
179
+ *
180
+ * Legacy markers without previous*: only re-apply fields that still look like the
181
+ * classic pre-migration baseline so a distinct intentional sibling opt-out is kept.
182
+ */
183
+ export function isIncompleteForceOnField(needsForceOn, current, previousSnapshot, previousFieldPresent, field = "valueFeedback") {
184
+ if (!needsForceOn) {
185
+ return false;
186
+ }
187
+ // Legacy markers written before #2903 lack previous* — recover only classic
188
+ // baseline shapes (statusreport-class), not distinct intentional opt-outs.
189
+ if (!previousFieldPresent) {
190
+ return field === "valueFeedback"
191
+ ? looksLikePreMigrationValueFeedback(current)
192
+ : looksLikePreMigrationProductSignal(current);
193
+ }
194
+ return deepEqualPolicySnapshot(current, previousSnapshot);
195
+ }
84
196
  function ensurePolicyBlock(plan) {
85
197
  migrateLegacyPolicyKey(plan);
86
198
  const existing = plan[PLAN_POLICY_KEY];
@@ -94,31 +206,97 @@ function ensurePolicyBlock(plan) {
94
206
  }
95
207
  return plan[PLAN_POLICY_KEY];
96
208
  }
97
- function writeMarkerForSkip(projectRoot, options, valueFeedback, productSignal) {
209
+ function writeMarker(projectRoot, options, input) {
98
210
  const originOrg = detectOriginOrg(projectRoot, options.autoEnable) ?? "unknown";
99
211
  const now = options.now ?? new Date();
100
212
  writeOrgForceOnMarker(projectRoot, {
101
213
  version: ORG_FORCE_ON_MARKER_VERSION,
102
214
  appliedAt: now.toISOString().replace(/\.\d{3}Z$/, "Z"),
103
215
  originOrg,
104
- valueFeedback,
105
- productSignal,
216
+ valueFeedback: input.valueFeedback,
217
+ productSignal: input.productSignal,
106
218
  directiveVersion: readCorePackageVersion(),
219
+ previousValueFeedback: normalizePolicySnapshot(input.previousValueFeedback),
220
+ previousProductSignal: normalizePolicySnapshot(input.previousProductSignal),
107
221
  });
108
222
  }
223
+ function readPolicyFieldSnapshots(projectRoot) {
224
+ const [data, err] = loadProjectDefinition(projectRoot);
225
+ if (data === null) {
226
+ return { ok: false, skippedReason: err ?? "project-definition-missing" };
227
+ }
228
+ const policyBlock = readPlanPolicy(data.plan);
229
+ const policyObj = typeof policyBlock === "object" && policyBlock !== null && !Array.isArray(policyBlock)
230
+ ? policyBlock
231
+ : {};
232
+ const vfRaw = "valueFeedback" in policyObj ? policyObj.valueFeedback : undefined;
233
+ const psRaw = "productSignal" in policyObj ? policyObj.productSignal : undefined;
234
+ return {
235
+ ok: true,
236
+ vfRaw,
237
+ psRaw,
238
+ needsVf: valueFeedbackNeedsForceOn(vfRaw),
239
+ needsPs: productSignalNeedsForceOn(psRaw),
240
+ };
241
+ }
109
242
  /**
110
243
  * One-time trusted-org install/upgrade force-on for valueFeedback + productSignal
111
244
  * (#2822). Idempotent via durable marker; non-trusted orgs are unchanged.
245
+ *
246
+ * #2903 verify-on-skip: when the marker is present but PROJECT-DEFINITION still
247
+ * matches the pre-migration snapshot (discarded / never-landed force-on write),
248
+ * re-apply. Intentional post-migration shapes that differ from both the previous
249
+ * snapshot and the force-on block keep the skip.
112
250
  */
113
251
  export function runOrgForceOnMigration(projectRoot, options = {}) {
114
- if (readOrgForceOnMarker(projectRoot) !== null) {
252
+ // #2926: per-project opt-out wins over ambient trusted-org force-on (v1).
253
+ if (isNoDeftDirectivePresent(projectRoot)) {
115
254
  return {
116
255
  ran: false,
117
- skippedReason: "marker-present",
256
+ skippedReason: "no-deft-directive",
118
257
  valueFeedbackChanged: false,
119
258
  productSignalChanged: false,
120
259
  };
121
260
  }
261
+ const existingMarker = readOrgForceOnMarker(projectRoot);
262
+ if (existingMarker !== null) {
263
+ const snapshots = readPolicyFieldSnapshots(projectRoot);
264
+ if (!snapshots.ok) {
265
+ return {
266
+ ran: false,
267
+ skippedReason: "marker-present",
268
+ valueFeedbackChanged: false,
269
+ productSignalChanged: false,
270
+ };
271
+ }
272
+ const previousVfPresent = "previousValueFeedback" in existingMarker;
273
+ const previousPsPresent = "previousProductSignal" in existingMarker;
274
+ const incompleteVf = isIncompleteForceOnField(snapshots.needsVf, snapshots.vfRaw, existingMarker.previousValueFeedback, previousVfPresent, "valueFeedback");
275
+ const incompletePs = isIncompleteForceOnField(snapshots.needsPs, snapshots.psRaw, existingMarker.previousProductSignal, previousPsPresent, "productSignal");
276
+ // Marker present and either already force-on shaped, or intentional opt-out
277
+ // (current differs from stored previous snapshot) → keep skip.
278
+ if (!incompleteVf && !incompletePs) {
279
+ return {
280
+ ran: false,
281
+ skippedReason: "marker-present",
282
+ valueFeedbackChanged: false,
283
+ productSignalChanged: false,
284
+ };
285
+ }
286
+ // Incomplete migration: fall through to trusted-org apply with only the
287
+ // incomplete fields re-forced.
288
+ if (!isTrustedOrgAutoEnable(projectRoot, options.autoEnable)) {
289
+ return {
290
+ ran: false,
291
+ skippedReason: "marker-present",
292
+ valueFeedbackChanged: false,
293
+ productSignalChanged: false,
294
+ };
295
+ }
296
+ // Pre-lock incomplete check is a fast path only; applyForceOn revalidates
297
+ // under the mutation lock against the latest PD (#2903 TOCTOU).
298
+ return applyForceOn(projectRoot, options, { existingMarker });
299
+ }
122
300
  if (!isTrustedOrgAutoEnable(projectRoot, options.autoEnable)) {
123
301
  return {
124
302
  ran: false,
@@ -127,25 +305,22 @@ export function runOrgForceOnMigration(projectRoot, options = {}) {
127
305
  productSignalChanged: false,
128
306
  };
129
307
  }
130
- const [data, err] = loadProjectDefinition(projectRoot);
131
- if (data === null) {
308
+ const snapshots = readPolicyFieldSnapshots(projectRoot);
309
+ if (!snapshots.ok) {
132
310
  return {
133
311
  ran: false,
134
- skippedReason: err ?? "project-definition-missing",
312
+ skippedReason: snapshots.skippedReason,
135
313
  valueFeedbackChanged: false,
136
314
  productSignalChanged: false,
137
315
  };
138
316
  }
139
- const policyBlock = readPlanPolicy(data.plan);
140
- const policyObj = typeof policyBlock === "object" && policyBlock !== null && !Array.isArray(policyBlock)
141
- ? policyBlock
142
- : {};
143
- const vfRaw = "valueFeedback" in policyObj ? policyObj.valueFeedback : undefined;
144
- const psRaw = "productSignal" in policyObj ? policyObj.productSignal : undefined;
145
- const needsVf = valueFeedbackNeedsForceOn(vfRaw);
146
- const needsPs = productSignalNeedsForceOn(psRaw);
147
- if (!needsVf && !needsPs) {
148
- writeMarkerForSkip(projectRoot, options, false, false);
317
+ if (!snapshots.needsVf && !snapshots.needsPs) {
318
+ writeMarker(projectRoot, options, {
319
+ valueFeedback: false,
320
+ productSignal: false,
321
+ previousValueFeedback: snapshots.vfRaw,
322
+ previousProductSignal: snapshots.psRaw,
323
+ });
149
324
  return {
150
325
  ran: false,
151
326
  skippedReason: "already-enabled",
@@ -153,9 +328,15 @@ export function runOrgForceOnMigration(projectRoot, options = {}) {
153
328
  productSignalChanged: false,
154
329
  };
155
330
  }
331
+ return applyForceOn(projectRoot, options, { existingMarker: null });
332
+ }
333
+ function applyForceOn(projectRoot, options, intent) {
156
334
  const path = projectDefinitionPath(projectRoot);
157
335
  let valueFeedbackChanged = false;
158
336
  let productSignalChanged = false;
337
+ let ran = false;
338
+ let skippedReason = null;
339
+ const existing = intent.existingMarker;
159
340
  projectDefinitionMutationLock(projectRoot, () => {
160
341
  const parsed = JSON.parse(readFileSync(path, { encoding: "utf8" }));
161
342
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
@@ -176,6 +357,36 @@ export function runOrgForceOnMigration(projectRoot, options = {}) {
176
357
  const policy = ensurePolicyBlock(plan);
177
358
  const previousVf = policy.valueFeedback;
178
359
  const previousPs = policy.productSignal;
360
+ // Revalidate under lock so a concurrent intentional opt-out between the
361
+ // unlocked snapshot check and lock acquisition is not overwritten (#2903).
362
+ let needsVf;
363
+ let needsPs;
364
+ if (existing !== null) {
365
+ const previousVfPresent = "previousValueFeedback" in existing;
366
+ const previousPsPresent = "previousProductSignal" in existing;
367
+ needsVf = isIncompleteForceOnField(valueFeedbackNeedsForceOn(previousVf), previousVf, existing.previousValueFeedback, previousVfPresent, "valueFeedback");
368
+ needsPs = isIncompleteForceOnField(productSignalNeedsForceOn(previousPs), previousPs, existing.previousProductSignal, previousPsPresent, "productSignal");
369
+ }
370
+ else {
371
+ needsVf = valueFeedbackNeedsForceOn(previousVf);
372
+ needsPs = productSignalNeedsForceOn(previousPs);
373
+ }
374
+ if (!needsVf && !needsPs) {
375
+ // Under-lock state no longer needs force-on (concurrent opt-out or already ON).
376
+ if (existing === null) {
377
+ writeMarker(projectRoot, options, {
378
+ valueFeedback: false,
379
+ productSignal: false,
380
+ previousValueFeedback: previousVf,
381
+ previousProductSignal: previousPs,
382
+ });
383
+ skippedReason = "already-enabled";
384
+ }
385
+ else {
386
+ skippedReason = "marker-present";
387
+ }
388
+ return { changed: false };
389
+ }
179
390
  if (needsVf) {
180
391
  policy.valueFeedback = { ...FORCE_ON_VALUE_FEEDBACK_BLOCK };
181
392
  valueFeedbackChanged = valueFeedbackNeedsForceOn(previousVf);
@@ -193,18 +404,49 @@ export function runOrgForceOnMigration(projectRoot, options = {}) {
193
404
  if (valueFeedbackChanged || productSignalChanged) {
194
405
  atomicWriteProjectDefinition(path, rootData);
195
406
  }
407
+ // Marker previous*: forced fields record pre-apply current; unforced fields
408
+ // keep the prior marker snapshot so intentional opt-outs are not rewritten
409
+ // into the incomplete-migration equality path on the next update (#2903 P1).
410
+ // When the prior marker had no previous* (legacy) and the field was not forced,
411
+ // store ORG_FORCE_ON_NO_BASELINE — not null/live shape — so clear-value-feedback
412
+ // (undefined→null) and intentional opt-outs never deep-equal previous*.
413
+ const markerPreviousVf = needsVf
414
+ ? previousVf
415
+ : existing && "previousValueFeedback" in existing
416
+ ? existing.previousValueFeedback
417
+ : ORG_FORCE_ON_NO_BASELINE;
418
+ const markerPreviousPs = needsPs
419
+ ? previousPs
420
+ : existing && "previousProductSignal" in existing
421
+ ? existing.previousProductSignal
422
+ : ORG_FORCE_ON_NO_BASELINE;
196
423
  const actor = options.actor ?? "directive-update";
197
424
  appendAuditLog(projectRoot, [
198
425
  `actor=${actor}`,
199
426
  "org-force-on-v2822",
200
427
  `valueFeedback=${needsVf ? "forced-on" : "unchanged"}`,
201
428
  `productSignal=${needsPs ? "forced-on" : "unchanged"}`,
202
- `previousValueFeedback=${JSON.stringify(previousVf ?? null)}`,
203
- `previousProductSignal=${JSON.stringify(previousPs ?? null)}`,
429
+ `previousValueFeedback=${JSON.stringify(normalizePolicySnapshot(markerPreviousVf))}`,
430
+ `previousProductSignal=${JSON.stringify(normalizePolicySnapshot(markerPreviousPs))}`,
204
431
  ].join(" "));
205
- writeMarkerForSkip(projectRoot, options, needsVf, needsPs);
432
+ writeMarker(projectRoot, options, {
433
+ // OR with existing so partial re-apply does not clear install-force-on source.
434
+ valueFeedback: needsVf || (existing?.valueFeedback ?? false),
435
+ productSignal: needsPs || (existing?.productSignal ?? false),
436
+ previousValueFeedback: markerPreviousVf,
437
+ previousProductSignal: markerPreviousPs,
438
+ });
439
+ ran = true;
206
440
  return { changed: valueFeedbackChanged || productSignalChanged };
207
441
  });
442
+ if (!ran) {
443
+ return {
444
+ ran: false,
445
+ skippedReason: skippedReason ?? "marker-present",
446
+ valueFeedbackChanged: false,
447
+ productSignalChanged: false,
448
+ };
449
+ }
208
450
  return {
209
451
  ran: true,
210
452
  skippedReason: null,
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Human merge gate (#1193 R3 / Wave 2 of #2948).
3
+ *
4
+ * When requireHumanMerge is true, agents may open PRs but must not merge.
5
+ * Three enforcement surfaces (mirror #747):
6
+ * (1) merge preflight refuses agent-initiated merge
7
+ * (2) verify:branch aggregate surfaces the policy
8
+ * (3) branch-protection / setup docs require ≥1 human reviewer
9
+ *
10
+ * Override: `task policy:allow-bot-merge -- --confirm` + `DEFT_ALLOW_BOT_MERGE=1`.
11
+ */
12
+ export declare const FIELD_REQUIRE_HUMAN_MERGE = "plan.policy.requireHumanMerge";
13
+ export declare const FIELD_REQUIRE_HUMAN_MERGE_CLI_ALIAS = "requireHumanMerge";
14
+ export declare const FIELD_AUTO_DEPLOY_ON_MERGE = "plan.policy.autoDeployOnMerge";
15
+ /** Emergency env-var bypass for agent merge (#1193). */
16
+ export declare const ENV_ALLOW_BOT_MERGE = "DEFT_ALLOW_BOT_MERGE";
17
+ /** Legacy narrative key recognized at read time with deprecation warning. */
18
+ export declare const LEGACY_REQUIRE_HUMAN_MERGE_KEY = "Require human merge";
19
+ export type HumanMergeSource = "typed" | "legacy-narrative" | "auto-deploy-default" | "env-bypass" | "default";
20
+ export interface HumanMergePolicyResult {
21
+ /** True when agents must not merge. */
22
+ readonly requireHumanMerge: boolean;
23
+ readonly source: HumanMergeSource;
24
+ readonly deprecationWarning: string | null;
25
+ readonly error: string | null;
26
+ readonly autoDeployOnMerge: boolean;
27
+ }
28
+ /**
29
+ * Resolve effective human-merge policy (#1193).
30
+ *
31
+ * Order:
32
+ * 1. DEFT_ALLOW_BOT_MERGE=1 → requireHumanMerge effective false (env-bypass)
33
+ * 2. typed plan.policy.requireHumanMerge
34
+ * 3. legacy narrative "Require human merge"
35
+ * 4. autoDeployOnMerge true → default requireHumanMerge true
36
+ * 5. default false (no auto-deploy → agents may merge when other gates allow)
37
+ */
38
+ export declare function resolveHumanMergePolicy(projectRoot: string, env?: NodeJS.ProcessEnv): HumanMergePolicyResult;
39
+ /** Session-start / AGENTS disclosure line when the human merge gate is ON. */
40
+ export declare function humanMergeDisclosureLine(result: HumanMergePolicyResult): string | null;
41
+ export interface AgentMergeEvaluateResult {
42
+ readonly exitCode: 0 | 1;
43
+ readonly allowed: boolean;
44
+ readonly message: string;
45
+ readonly policy: HumanMergePolicyResult;
46
+ }
47
+ /**
48
+ * Merge preflight: refuse agent-initiated merge when requireHumanMerge is true.
49
+ * Surface (1) of the three-surface enforcement pattern.
50
+ */
51
+ export declare function evaluateAgentMerge(projectRoot: string, env?: NodeJS.ProcessEnv): AgentMergeEvaluateResult;
52
+ /**
53
+ * verify:branch aggregate surface (2): advisory line when gate is ON.
54
+ * Does not fail the branch gate on feature branches — merge refuse is surface (1).
55
+ */
56
+ export declare function humanMergeBranchNote(result: HumanMergePolicyResult): string | null;
57
+ export interface HumanMergePolicyField {
58
+ readonly name: string;
59
+ readonly current: boolean;
60
+ readonly default: boolean;
61
+ readonly source: string;
62
+ }
63
+ /** Inspector for policy:show --field=requireHumanMerge. */
64
+ export declare function inspectRequireHumanMerge(data: Record<string, unknown> | null, projectRoot?: string): HumanMergePolicyField;
65
+ export declare const ALLOW_BOT_MERGE_CAPABILITY_COST: string;
66
+ /** Write requireHumanMerge=false (allow bot merge) with audit trail. */
67
+ export declare function setRequireHumanMerge(projectRoot: string, options: {
68
+ requireHumanMerge: boolean;
69
+ actor?: string;
70
+ note?: string;
71
+ }): {
72
+ changed: boolean;
73
+ auditEntry: string;
74
+ };
75
+ //# sourceMappingURL=require-human-merge.d.ts.map