devlyn-cli 3.2.1 → 3.3.1

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 (423) hide show
  1. package/AGENTS.md +2 -2
  2. package/CLAUDE.md +2 -2
  3. package/README.md +12 -102
  4. package/bin/devlyn.js +13 -225
  5. package/bin/instruction-templates.json +8 -0
  6. package/config/skills/_shared/adapters/grok.md +1 -2
  7. package/config/skills/_shared/archive_run.py +15 -3
  8. package/config/skills/_shared/judge-role-evidence.py +21 -1
  9. package/config/skills/_shared/runtime-principles.md +1 -1
  10. package/config/skills/_shared/spec-verify-check.py +0 -653
  11. package/config/skills/_shared/state-phase-write.py +351 -25
  12. package/config/skills/_shared/task-complete.py +660 -150
  13. package/config/skills/_shared/terminal-claim-check.py +229 -4
  14. package/config/skills/_shared/verify-merge-findings.py +93 -2
  15. package/config/skills/devlyn:ideate/SKILL.md +3 -4
  16. package/config/skills/devlyn:ideate/references/elicitation.md +2 -35
  17. package/config/skills/devlyn:ideate/references/from-spec-mode.md +3 -18
  18. package/config/skills/devlyn:ideate/references/project-mode.md +1 -16
  19. package/config/skills/devlyn:ideate/references/spec-template.md +0 -2
  20. package/config/skills/devlyn:queue/SKILL.md +6 -4
  21. package/config/skills/devlyn:resolve/SKILL.md +16 -25
  22. package/config/skills/devlyn:resolve/references/free-form-mode.md +0 -14
  23. package/config/skills/devlyn:resolve/references/outer-loop.md +1 -1
  24. package/config/skills/devlyn:resolve/references/phases/build-gate.md +1 -1
  25. package/config/skills/devlyn:resolve/references/phases/cleanup.md +3 -3
  26. package/config/skills/devlyn:resolve/references/phases/implement.md +1 -1
  27. package/config/skills/devlyn:resolve/references/phases/probe-derive.md +1 -16
  28. package/config/skills/devlyn:resolve/references/phases/verify.md +4 -9
  29. package/config/skills/devlyn:resolve/references/state-schema.md +9 -7
  30. package/config/skills/devlyn:resolve/references/task-completion.md +47 -38
  31. package/{config/skills → optional-skills}/code-health-standards/SKILL.md +4 -13
  32. package/{config/skills → optional-skills}/code-review-standards/SKILL.md +4 -12
  33. package/{config/skills → optional-skills}/root-cause-analysis/SKILL.md +4 -12
  34. package/{config/skills → optional-skills}/ui-implementation-standards/SKILL.md +4 -14
  35. package/package.json +1 -57
  36. package/benchmark/auto-resolve/BENCHMARK-DESIGN.md +0 -289
  37. package/benchmark/auto-resolve/BENCHMARK-RESULTS.md +0 -341
  38. package/benchmark/auto-resolve/README.md +0 -696
  39. package/benchmark/auto-resolve/RUBRIC.md +0 -177
  40. package/benchmark/auto-resolve/fixtures/F1-cli-trivial-flag/NOTES.md +0 -34
  41. package/benchmark/auto-resolve/fixtures/F1-cli-trivial-flag/expected.json +0 -68
  42. package/benchmark/auto-resolve/fixtures/F1-cli-trivial-flag/metadata.json +0 -10
  43. package/benchmark/auto-resolve/fixtures/F1-cli-trivial-flag/setup.sh +0 -4
  44. package/benchmark/auto-resolve/fixtures/F1-cli-trivial-flag/spec.md +0 -44
  45. package/benchmark/auto-resolve/fixtures/F1-cli-trivial-flag/task.ko.txt +0 -8
  46. package/benchmark/auto-resolve/fixtures/F1-cli-trivial-flag/task.txt +0 -8
  47. package/benchmark/auto-resolve/fixtures/F10-persist-write-collision/NOTES.md +0 -68
  48. package/benchmark/auto-resolve/fixtures/F10-persist-write-collision/expected.json +0 -60
  49. package/benchmark/auto-resolve/fixtures/F10-persist-write-collision/metadata.json +0 -10
  50. package/benchmark/auto-resolve/fixtures/F10-persist-write-collision/setup.sh +0 -17
  51. package/benchmark/auto-resolve/fixtures/F10-persist-write-collision/spec.md +0 -51
  52. package/benchmark/auto-resolve/fixtures/F10-persist-write-collision/task.txt +0 -9
  53. package/benchmark/auto-resolve/fixtures/F10-persist-write-collision/verifiers/invalid.js +0 -29
  54. package/benchmark/auto-resolve/fixtures/F10-persist-write-collision/verifiers/parallel.js +0 -50
  55. package/benchmark/auto-resolve/fixtures/F11-batch-import-all-or-nothing/NOTES.md +0 -75
  56. package/benchmark/auto-resolve/fixtures/F11-batch-import-all-or-nothing/expected.json +0 -52
  57. package/benchmark/auto-resolve/fixtures/F11-batch-import-all-or-nothing/metadata.json +0 -10
  58. package/benchmark/auto-resolve/fixtures/F11-batch-import-all-or-nothing/setup.sh +0 -171
  59. package/benchmark/auto-resolve/fixtures/F11-batch-import-all-or-nothing/spec.md +0 -50
  60. package/benchmark/auto-resolve/fixtures/F11-batch-import-all-or-nothing/task.txt +0 -9
  61. package/benchmark/auto-resolve/fixtures/F12-webhook-raw-body-signature/NOTES.md +0 -89
  62. package/benchmark/auto-resolve/fixtures/F12-webhook-raw-body-signature/expected.json +0 -74
  63. package/benchmark/auto-resolve/fixtures/F12-webhook-raw-body-signature/metadata.json +0 -10
  64. package/benchmark/auto-resolve/fixtures/F12-webhook-raw-body-signature/setup.sh +0 -251
  65. package/benchmark/auto-resolve/fixtures/F12-webhook-raw-body-signature/spec.md +0 -57
  66. package/benchmark/auto-resolve/fixtures/F12-webhook-raw-body-signature/task.txt +0 -13
  67. package/benchmark/auto-resolve/fixtures/F12-webhook-raw-body-signature/verifiers/replay-malformed-body.js +0 -64
  68. package/benchmark/auto-resolve/fixtures/F15-frozen-diff-race-review/NOTES.md +0 -104
  69. package/benchmark/auto-resolve/fixtures/F15-frozen-diff-race-review/expected.json +0 -46
  70. package/benchmark/auto-resolve/fixtures/F15-frozen-diff-race-review/metadata.json +0 -10
  71. package/benchmark/auto-resolve/fixtures/F15-frozen-diff-race-review/setup.sh +0 -336
  72. package/benchmark/auto-resolve/fixtures/F15-frozen-diff-race-review/spec.md +0 -51
  73. package/benchmark/auto-resolve/fixtures/F15-frozen-diff-race-review/task.txt +0 -9
  74. package/benchmark/auto-resolve/fixtures/F16-cli-quote-tax-rules/NOTES.md +0 -38
  75. package/benchmark/auto-resolve/fixtures/F16-cli-quote-tax-rules/expected.json +0 -64
  76. package/benchmark/auto-resolve/fixtures/F16-cli-quote-tax-rules/metadata.json +0 -10
  77. package/benchmark/auto-resolve/fixtures/F16-cli-quote-tax-rules/setup.sh +0 -32
  78. package/benchmark/auto-resolve/fixtures/F16-cli-quote-tax-rules/spec.md +0 -63
  79. package/benchmark/auto-resolve/fixtures/F16-cli-quote-tax-rules/task.txt +0 -7
  80. package/benchmark/auto-resolve/fixtures/F16-cli-quote-tax-rules/verifiers/exact-success.js +0 -54
  81. package/benchmark/auto-resolve/fixtures/F16-cli-quote-tax-rules/verifiers/no-hardcoded-pricing.js +0 -47
  82. package/benchmark/auto-resolve/fixtures/F16-cli-quote-tax-rules/verifiers/stock-error.js +0 -45
  83. package/benchmark/auto-resolve/fixtures/F2-cli-medium-subcommand/NOTES.md +0 -57
  84. package/benchmark/auto-resolve/fixtures/F2-cli-medium-subcommand/expected-pair-plan-registry.json +0 -170
  85. package/benchmark/auto-resolve/fixtures/F2-cli-medium-subcommand/expected.json +0 -84
  86. package/benchmark/auto-resolve/fixtures/F2-cli-medium-subcommand/metadata.json +0 -21
  87. package/benchmark/auto-resolve/fixtures/F2-cli-medium-subcommand/pair-plan.sample-fail.json +0 -214
  88. package/benchmark/auto-resolve/fixtures/F2-cli-medium-subcommand/pair-plan.sample-pass.json +0 -223
  89. package/benchmark/auto-resolve/fixtures/F2-cli-medium-subcommand/setup.sh +0 -5
  90. package/benchmark/auto-resolve/fixtures/F2-cli-medium-subcommand/spec.md +0 -55
  91. package/benchmark/auto-resolve/fixtures/F2-cli-medium-subcommand/task.txt +0 -14
  92. package/benchmark/auto-resolve/fixtures/F21-cli-scheduler-priority/NOTES.md +0 -39
  93. package/benchmark/auto-resolve/fixtures/F21-cli-scheduler-priority/expected.json +0 -62
  94. package/benchmark/auto-resolve/fixtures/F21-cli-scheduler-priority/metadata.json +0 -10
  95. package/benchmark/auto-resolve/fixtures/F21-cli-scheduler-priority/setup.sh +0 -2
  96. package/benchmark/auto-resolve/fixtures/F21-cli-scheduler-priority/spec.md +0 -67
  97. package/benchmark/auto-resolve/fixtures/F21-cli-scheduler-priority/task.txt +0 -7
  98. package/benchmark/auto-resolve/fixtures/F21-cli-scheduler-priority/verifiers/error-order.js +0 -55
  99. package/benchmark/auto-resolve/fixtures/F21-cli-scheduler-priority/verifiers/priority-blocked.js +0 -48
  100. package/benchmark/auto-resolve/fixtures/F22-cli-ledger-close/NOTES.md +0 -35
  101. package/benchmark/auto-resolve/fixtures/F22-cli-ledger-close/expected.json +0 -56
  102. package/benchmark/auto-resolve/fixtures/F22-cli-ledger-close/metadata.json +0 -10
  103. package/benchmark/auto-resolve/fixtures/F22-cli-ledger-close/setup.sh +0 -2
  104. package/benchmark/auto-resolve/fixtures/F22-cli-ledger-close/spec.md +0 -64
  105. package/benchmark/auto-resolve/fixtures/F22-cli-ledger-close/task.txt +0 -7
  106. package/benchmark/auto-resolve/fixtures/F22-cli-ledger-close/verifiers/conflicting-duplicate.js +0 -34
  107. package/benchmark/auto-resolve/fixtures/F22-cli-ledger-close/verifiers/idempotent-close.js +0 -41
  108. package/benchmark/auto-resolve/fixtures/F23-cli-fulfillment-wave/NOTES.md +0 -39
  109. package/benchmark/auto-resolve/fixtures/F23-cli-fulfillment-wave/expected.json +0 -56
  110. package/benchmark/auto-resolve/fixtures/F23-cli-fulfillment-wave/metadata.json +0 -10
  111. package/benchmark/auto-resolve/fixtures/F23-cli-fulfillment-wave/setup.sh +0 -2
  112. package/benchmark/auto-resolve/fixtures/F23-cli-fulfillment-wave/spec.md +0 -76
  113. package/benchmark/auto-resolve/fixtures/F23-cli-fulfillment-wave/task.txt +0 -7
  114. package/benchmark/auto-resolve/fixtures/F23-cli-fulfillment-wave/verifiers/priority-rollback.js +0 -64
  115. package/benchmark/auto-resolve/fixtures/F23-cli-fulfillment-wave/verifiers/single-warehouse-fefo.js +0 -66
  116. package/benchmark/auto-resolve/fixtures/F25-cli-cart-promotion-rules/NOTES.md +0 -40
  117. package/benchmark/auto-resolve/fixtures/F25-cli-cart-promotion-rules/expected.json +0 -66
  118. package/benchmark/auto-resolve/fixtures/F25-cli-cart-promotion-rules/metadata.json +0 -10
  119. package/benchmark/auto-resolve/fixtures/F25-cli-cart-promotion-rules/setup.sh +0 -36
  120. package/benchmark/auto-resolve/fixtures/F25-cli-cart-promotion-rules/spec.md +0 -71
  121. package/benchmark/auto-resolve/fixtures/F25-cli-cart-promotion-rules/task.txt +0 -7
  122. package/benchmark/auto-resolve/fixtures/F25-cli-cart-promotion-rules/verifiers/catalog-source.js +0 -57
  123. package/benchmark/auto-resolve/fixtures/F25-cli-cart-promotion-rules/verifiers/exact-success.js +0 -63
  124. package/benchmark/auto-resolve/fixtures/F25-cli-cart-promotion-rules/verifiers/stock-error.js +0 -34
  125. package/benchmark/auto-resolve/fixtures/F26-cli-payout-ledger-rules/NOTES.md +0 -31
  126. package/benchmark/auto-resolve/fixtures/F26-cli-payout-ledger-rules/expected.json +0 -68
  127. package/benchmark/auto-resolve/fixtures/F26-cli-payout-ledger-rules/metadata.json +0 -10
  128. package/benchmark/auto-resolve/fixtures/F26-cli-payout-ledger-rules/setup.sh +0 -17
  129. package/benchmark/auto-resolve/fixtures/F26-cli-payout-ledger-rules/spec.md +0 -68
  130. package/benchmark/auto-resolve/fixtures/F26-cli-payout-ledger-rules/task.txt +0 -7
  131. package/benchmark/auto-resolve/fixtures/F26-cli-payout-ledger-rules/verifiers/conflicting-duplicate.js +0 -29
  132. package/benchmark/auto-resolve/fixtures/F26-cli-payout-ledger-rules/verifiers/exact-payout.js +0 -58
  133. package/benchmark/auto-resolve/fixtures/F26-cli-payout-ledger-rules/verifiers/rules-source.js +0 -56
  134. package/benchmark/auto-resolve/fixtures/F3-backend-contract-risk/NOTES.md +0 -35
  135. package/benchmark/auto-resolve/fixtures/F3-backend-contract-risk/expected-pair-plan-registry.json +0 -162
  136. package/benchmark/auto-resolve/fixtures/F3-backend-contract-risk/expected.json +0 -67
  137. package/benchmark/auto-resolve/fixtures/F3-backend-contract-risk/metadata.json +0 -19
  138. package/benchmark/auto-resolve/fixtures/F3-backend-contract-risk/setup.sh +0 -4
  139. package/benchmark/auto-resolve/fixtures/F3-backend-contract-risk/spec.md +0 -55
  140. package/benchmark/auto-resolve/fixtures/F3-backend-contract-risk/task.txt +0 -9
  141. package/benchmark/auto-resolve/fixtures/F31-cli-seat-rebalance/NOTES.md +0 -34
  142. package/benchmark/auto-resolve/fixtures/F31-cli-seat-rebalance/expected.json +0 -57
  143. package/benchmark/auto-resolve/fixtures/F31-cli-seat-rebalance/metadata.json +0 -10
  144. package/benchmark/auto-resolve/fixtures/F31-cli-seat-rebalance/setup.sh +0 -2
  145. package/benchmark/auto-resolve/fixtures/F31-cli-seat-rebalance/spec.md +0 -67
  146. package/benchmark/auto-resolve/fixtures/F31-cli-seat-rebalance/task.txt +0 -7
  147. package/benchmark/auto-resolve/fixtures/F31-cli-seat-rebalance/verifiers/duplicate-event-error.js +0 -35
  148. package/benchmark/auto-resolve/fixtures/F31-cli-seat-rebalance/verifiers/priority-transfer-rollback.js +0 -53
  149. package/benchmark/auto-resolve/fixtures/F32-cli-subscription-renewal/NOTES.md +0 -38
  150. package/benchmark/auto-resolve/fixtures/F32-cli-subscription-renewal/expected.json +0 -57
  151. package/benchmark/auto-resolve/fixtures/F32-cli-subscription-renewal/metadata.json +0 -10
  152. package/benchmark/auto-resolve/fixtures/F32-cli-subscription-renewal/setup.sh +0 -2
  153. package/benchmark/auto-resolve/fixtures/F32-cli-subscription-renewal/spec.md +0 -70
  154. package/benchmark/auto-resolve/fixtures/F32-cli-subscription-renewal/task.txt +0 -3
  155. package/benchmark/auto-resolve/fixtures/F32-cli-subscription-renewal/verifiers/duplicate-renewal-error.js +0 -42
  156. package/benchmark/auto-resolve/fixtures/F32-cli-subscription-renewal/verifiers/priority-credit-rollback.js +0 -70
  157. package/benchmark/auto-resolve/fixtures/F4-web-browser-design/NOTES.md +0 -47
  158. package/benchmark/auto-resolve/fixtures/F4-web-browser-design/expected.json +0 -57
  159. package/benchmark/auto-resolve/fixtures/F4-web-browser-design/metadata.json +0 -10
  160. package/benchmark/auto-resolve/fixtures/F4-web-browser-design/setup.sh +0 -6
  161. package/benchmark/auto-resolve/fixtures/F4-web-browser-design/spec.md +0 -48
  162. package/benchmark/auto-resolve/fixtures/F4-web-browser-design/task.txt +0 -9
  163. package/benchmark/auto-resolve/fixtures/F5-fix-loop-red-green/NOTES.md +0 -45
  164. package/benchmark/auto-resolve/fixtures/F5-fix-loop-red-green/expected.json +0 -65
  165. package/benchmark/auto-resolve/fixtures/F5-fix-loop-red-green/metadata.json +0 -10
  166. package/benchmark/auto-resolve/fixtures/F5-fix-loop-red-green/setup.sh +0 -55
  167. package/benchmark/auto-resolve/fixtures/F5-fix-loop-red-green/spec.md +0 -48
  168. package/benchmark/auto-resolve/fixtures/F5-fix-loop-red-green/task.txt +0 -7
  169. package/benchmark/auto-resolve/fixtures/F6-dep-audit-native-module/NOTES.md +0 -43
  170. package/benchmark/auto-resolve/fixtures/F6-dep-audit-native-module/expected.json +0 -77
  171. package/benchmark/auto-resolve/fixtures/F6-dep-audit-native-module/metadata.json +0 -10
  172. package/benchmark/auto-resolve/fixtures/F6-dep-audit-native-module/setup.sh +0 -4
  173. package/benchmark/auto-resolve/fixtures/F6-dep-audit-native-module/spec.md +0 -48
  174. package/benchmark/auto-resolve/fixtures/F6-dep-audit-native-module/task.txt +0 -10
  175. package/benchmark/auto-resolve/fixtures/F7-out-of-scope-trap/NOTES.md +0 -57
  176. package/benchmark/auto-resolve/fixtures/F7-out-of-scope-trap/expected.json +0 -76
  177. package/benchmark/auto-resolve/fixtures/F7-out-of-scope-trap/metadata.json +0 -10
  178. package/benchmark/auto-resolve/fixtures/F7-out-of-scope-trap/setup.sh +0 -36
  179. package/benchmark/auto-resolve/fixtures/F7-out-of-scope-trap/spec.md +0 -45
  180. package/benchmark/auto-resolve/fixtures/F7-out-of-scope-trap/task.txt +0 -7
  181. package/benchmark/auto-resolve/fixtures/F8-known-limit-ambiguous/NOTES.md +0 -53
  182. package/benchmark/auto-resolve/fixtures/F8-known-limit-ambiguous/expected.json +0 -63
  183. package/benchmark/auto-resolve/fixtures/F8-known-limit-ambiguous/metadata.json +0 -10
  184. package/benchmark/auto-resolve/fixtures/F8-known-limit-ambiguous/setup.sh +0 -4
  185. package/benchmark/auto-resolve/fixtures/F8-known-limit-ambiguous/spec.md +0 -48
  186. package/benchmark/auto-resolve/fixtures/F8-known-limit-ambiguous/task.txt +0 -1
  187. package/benchmark/auto-resolve/fixtures/F9-e2e-ideate-to-resolve/NOTES.md +0 -105
  188. package/benchmark/auto-resolve/fixtures/F9-e2e-ideate-to-resolve/expected.json +0 -74
  189. package/benchmark/auto-resolve/fixtures/F9-e2e-ideate-to-resolve/metadata.json +0 -10
  190. package/benchmark/auto-resolve/fixtures/F9-e2e-ideate-to-resolve/setup.sh +0 -28
  191. package/benchmark/auto-resolve/fixtures/F9-e2e-ideate-to-resolve/spec.md +0 -59
  192. package/benchmark/auto-resolve/fixtures/F9-e2e-ideate-to-resolve/task.txt +0 -5
  193. package/benchmark/auto-resolve/fixtures/SCHEMA.md +0 -188
  194. package/benchmark/auto-resolve/fixtures/retired/F27-cli-subscription-proration/NOTES.md +0 -37
  195. package/benchmark/auto-resolve/fixtures/retired/F27-cli-subscription-proration/RETIRED.md +0 -13
  196. package/benchmark/auto-resolve/fixtures/retired/F27-cli-subscription-proration/expected.json +0 -56
  197. package/benchmark/auto-resolve/fixtures/retired/F27-cli-subscription-proration/metadata.json +0 -10
  198. package/benchmark/auto-resolve/fixtures/retired/F27-cli-subscription-proration/setup.sh +0 -18
  199. package/benchmark/auto-resolve/fixtures/retired/F27-cli-subscription-proration/spec.md +0 -69
  200. package/benchmark/auto-resolve/fixtures/retired/F27-cli-subscription-proration/task.txt +0 -7
  201. package/benchmark/auto-resolve/fixtures/retired/F27-cli-subscription-proration/verifiers/exact-proration.js +0 -48
  202. package/benchmark/auto-resolve/fixtures/retired/F27-cli-subscription-proration/verifiers/rules-source-and-conflict.js +0 -79
  203. package/benchmark/auto-resolve/fixtures/retired/F28-cli-return-authorization/NOTES.md +0 -54
  204. package/benchmark/auto-resolve/fixtures/retired/F28-cli-return-authorization/RETIRED.md +0 -7
  205. package/benchmark/auto-resolve/fixtures/retired/F28-cli-return-authorization/expected.json +0 -67
  206. package/benchmark/auto-resolve/fixtures/retired/F28-cli-return-authorization/metadata.json +0 -10
  207. package/benchmark/auto-resolve/fixtures/retired/F28-cli-return-authorization/setup.sh +0 -2
  208. package/benchmark/auto-resolve/fixtures/retired/F28-cli-return-authorization/spec.md +0 -67
  209. package/benchmark/auto-resolve/fixtures/retired/F28-cli-return-authorization/task.txt +0 -5
  210. package/benchmark/auto-resolve/fixtures/retired/F28-cli-return-authorization/verifiers/policy-precedence.js +0 -72
  211. package/benchmark/auto-resolve/fixtures/retired/F28-cli-return-authorization/verifiers/validation-and-immutability.js +0 -43
  212. package/benchmark/auto-resolve/fixtures/retired/F28-cli-return-authorization/verifiers/validation-boundary.js +0 -116
  213. package/benchmark/auto-resolve/fixtures/retired/F30-cli-credit-hold-settlement/NOTES.md +0 -35
  214. package/benchmark/auto-resolve/fixtures/retired/F30-cli-credit-hold-settlement/RETIRED.md +0 -12
  215. package/benchmark/auto-resolve/fixtures/retired/F30-cli-credit-hold-settlement/expected.json +0 -58
  216. package/benchmark/auto-resolve/fixtures/retired/F30-cli-credit-hold-settlement/metadata.json +0 -10
  217. package/benchmark/auto-resolve/fixtures/retired/F30-cli-credit-hold-settlement/setup.sh +0 -2
  218. package/benchmark/auto-resolve/fixtures/retired/F30-cli-credit-hold-settlement/spec.md +0 -73
  219. package/benchmark/auto-resolve/fixtures/retired/F30-cli-credit-hold-settlement/task.txt +0 -17
  220. package/benchmark/auto-resolve/fixtures/retired/F30-cli-credit-hold-settlement/verifiers/mixed-idempotent-settlement.js +0 -53
  221. package/benchmark/auto-resolve/fixtures/retired/F30-cli-credit-hold-settlement/verifiers/rejection-boundaries.js +0 -74
  222. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/NOTES.md +0 -28
  223. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/RETIRED.md +0 -15
  224. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/expected.json +0 -57
  225. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/metadata.json +0 -10
  226. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/setup.sh +0 -50
  227. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/spec.md +0 -59
  228. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/task.txt +0 -9
  229. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/verifiers/ordered-settlement.js +0 -88
  230. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/verifiers/reject-invalid.js +0 -45
  231. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/NOTES.md +0 -30
  232. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/RETIRED.md +0 -16
  233. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/expected.json +0 -64
  234. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/metadata.json +0 -10
  235. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/setup.sh +0 -21
  236. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/spec.md +0 -58
  237. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/task.txt +0 -9
  238. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/verifiers/dup-op.js +0 -54
  239. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/verifiers/priority-apply.js +0 -82
  240. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/verifiers/rollback.js +0 -77
  241. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/NOTES.md +0 -99
  242. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/RETIRED.md +0 -18
  243. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/expected.json +0 -64
  244. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/metadata.json +0 -10
  245. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/setup.sh +0 -4
  246. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/spec.md +0 -61
  247. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/task.txt +0 -9
  248. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/verifiers/correctness-small.js +0 -135
  249. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/verifiers/scale.js +0 -100
  250. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/verifiers/validation.js +0 -75
  251. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/NOTES.md +0 -93
  252. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/RETIRED.md +0 -16
  253. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/expected.json +0 -65
  254. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/metadata.json +0 -10
  255. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/setup.sh +0 -18
  256. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/spec.md +0 -66
  257. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/task.txt +0 -9
  258. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/verifiers/correctness-small.js +0 -84
  259. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/verifiers/scale.js +0 -184
  260. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/verifiers/validation.js +0 -74
  261. package/benchmark/auto-resolve/fixtures/retired/F9-e2e-ideate-to-preflight/NOTES.md +0 -60
  262. package/benchmark/auto-resolve/fixtures/retired/F9-e2e-ideate-to-preflight/RETIRED.md +0 -29
  263. package/benchmark/auto-resolve/fixtures/retired/F9-e2e-ideate-to-preflight/expected.json +0 -73
  264. package/benchmark/auto-resolve/fixtures/retired/F9-e2e-ideate-to-preflight/metadata.json +0 -10
  265. package/benchmark/auto-resolve/fixtures/retired/F9-e2e-ideate-to-preflight/setup.sh +0 -28
  266. package/benchmark/auto-resolve/fixtures/retired/F9-e2e-ideate-to-preflight/spec.md +0 -58
  267. package/benchmark/auto-resolve/fixtures/retired/F9-e2e-ideate-to-preflight/task.txt +0 -5
  268. package/benchmark/auto-resolve/fixtures/test-repo/README.md +0 -12
  269. package/benchmark/auto-resolve/fixtures/test-repo/bin/cli.js +0 -63
  270. package/benchmark/auto-resolve/fixtures/test-repo/package-lock.json +0 -823
  271. package/benchmark/auto-resolve/fixtures/test-repo/package.json +0 -22
  272. package/benchmark/auto-resolve/fixtures/test-repo/playwright.config.js +0 -17
  273. package/benchmark/auto-resolve/fixtures/test-repo/server/index.js +0 -37
  274. package/benchmark/auto-resolve/fixtures/test-repo/tests/cli.test.js +0 -25
  275. package/benchmark/auto-resolve/fixtures/test-repo/tests/server.test.js +0 -58
  276. package/benchmark/auto-resolve/fixtures/test-repo/web/index.html +0 -37
  277. package/benchmark/auto-resolve/results/20260507-f10-f11-tier1-full-pipeline/headroom-gate.json +0 -25
  278. package/benchmark/auto-resolve/results/20260507-f10-f11-tier1-full-pipeline/headroom-gate.md +0 -10
  279. package/benchmark/auto-resolve/results/20260508-f22-exact-error-headroom/headroom-gate.json +0 -18
  280. package/benchmark/auto-resolve/results/20260508-f22-exact-error-headroom/headroom-gate.md +0 -9
  281. package/benchmark/auto-resolve/results/20260508-f26-headroom/headroom-gate.json +0 -18
  282. package/benchmark/auto-resolve/results/20260508-f26-headroom/headroom-gate.md +0 -9
  283. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F16-cli-quote-tax-rules/l2_risk_probes/result.json +0 -28
  284. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F23-cli-fulfillment-wave/l2_risk_probes/result.json +0 -28
  285. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F25-cli-cart-promotion-rules/l2_risk_probes/result.json +0 -30
  286. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/full-pipeline-pair-gate.json +0 -82
  287. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/full-pipeline-pair-gate.md +0 -18
  288. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/headroom-gate.json +0 -46
  289. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/headroom-gate.md +0 -17
  290. package/benchmark/auto-resolve/results/20260511-f12-webhook-headroom/headroom-gate.json +0 -28
  291. package/benchmark/auto-resolve/results/20260511-f12-webhook-headroom/headroom-gate.md +0 -15
  292. package/benchmark/auto-resolve/results/20260511-f15-concurrency-headroom/headroom-gate.json +0 -28
  293. package/benchmark/auto-resolve/results/20260511-f15-concurrency-headroom/headroom-gate.md +0 -15
  294. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/F21-cli-scheduler-priority/l2_risk_probes/result.json +0 -32
  295. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/full-pipeline-pair-gate.json +0 -44
  296. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/full-pipeline-pair-gate.md +0 -16
  297. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/headroom-gate.json +0 -18
  298. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/headroom-gate.md +0 -9
  299. package/benchmark/auto-resolve/results/20260511-f3-http-error-headroom/headroom-gate.json +0 -28
  300. package/benchmark/auto-resolve/results/20260511-f3-http-error-headroom/headroom-gate.md +0 -15
  301. package/benchmark/auto-resolve/results/20260512-f2-medium-headroom/headroom-gate.json +0 -28
  302. package/benchmark/auto-resolve/results/20260512-f2-medium-headroom/headroom-gate.md +0 -15
  303. package/benchmark/auto-resolve/results/20260512-f31-seat-rebalance-headroom/headroom-gate.json +0 -28
  304. package/benchmark/auto-resolve/results/20260512-f31-seat-rebalance-headroom/headroom-gate.md +0 -15
  305. package/benchmark/auto-resolve/results/20260512-f32-subscription-renewal-headroom/headroom-gate.json +0 -28
  306. package/benchmark/auto-resolve/results/20260512-f32-subscription-renewal-headroom/headroom-gate.md +0 -15
  307. package/benchmark/auto-resolve/results/20260512-f4-web-headroom/headroom-gate.json +0 -28
  308. package/benchmark/auto-resolve/results/20260512-f4-web-headroom/headroom-gate.md +0 -15
  309. package/benchmark/auto-resolve/results/20260512-f5-fixloop-headroom/headroom-gate.json +0 -28
  310. package/benchmark/auto-resolve/results/20260512-f5-fixloop-headroom/headroom-gate.md +0 -15
  311. package/benchmark/auto-resolve/results/20260512-f6-checksum-headroom/headroom-gate.json +0 -28
  312. package/benchmark/auto-resolve/results/20260512-f6-checksum-headroom/headroom-gate.md +0 -15
  313. package/benchmark/auto-resolve/results/20260512-f7-scope-headroom/headroom-gate.json +0 -28
  314. package/benchmark/auto-resolve/results/20260512-f7-scope-headroom/headroom-gate.md +0 -15
  315. package/benchmark/auto-resolve/results/20260512-f9-e2e-headroom/headroom-gate.json +0 -28
  316. package/benchmark/auto-resolve/results/20260512-f9-e2e-headroom/headroom-gate.md +0 -15
  317. package/benchmark/auto-resolve/run-real-benchmark.md +0 -303
  318. package/benchmark/auto-resolve/scripts/audit-headroom-rejections.py +0 -441
  319. package/benchmark/auto-resolve/scripts/audit-pair-evidence.py +0 -1256
  320. package/benchmark/auto-resolve/scripts/build-pair-eligible-manifest.py +0 -306
  321. package/benchmark/auto-resolve/scripts/check-f9-artifacts.py +0 -268
  322. package/benchmark/auto-resolve/scripts/collect-swebench-predictions.py +0 -108
  323. package/benchmark/auto-resolve/scripts/compile-report.py +0 -493
  324. package/benchmark/auto-resolve/scripts/fetch-swebench-instances.py +0 -129
  325. package/benchmark/auto-resolve/scripts/frozen-verify-gate.py +0 -434
  326. package/benchmark/auto-resolve/scripts/full-pipeline-pair-gate.py +0 -612
  327. package/benchmark/auto-resolve/scripts/headroom-gate.py +0 -378
  328. package/benchmark/auto-resolve/scripts/iter-0033c-compare.py +0 -683
  329. package/benchmark/auto-resolve/scripts/iter-0033c-l1-summary.py +0 -97
  330. package/benchmark/auto-resolve/scripts/judge-opus-pass.sh +0 -542
  331. package/benchmark/auto-resolve/scripts/judge.sh +0 -565
  332. package/benchmark/auto-resolve/scripts/oracle-scope-tier-a.py +0 -256
  333. package/benchmark/auto-resolve/scripts/oracle-scope-tier-b.py +0 -287
  334. package/benchmark/auto-resolve/scripts/oracle-test-fidelity.py +0 -328
  335. package/benchmark/auto-resolve/scripts/pair-candidate-frontier.py +0 -469
  336. package/benchmark/auto-resolve/scripts/pair-plan-idgen.py +0 -401
  337. package/benchmark/auto-resolve/scripts/pair-plan-lint.py +0 -475
  338. package/benchmark/auto-resolve/scripts/pair-rejected-fixtures.sh +0 -103
  339. package/benchmark/auto-resolve/scripts/pair_evidence_contract.py +0 -270
  340. package/benchmark/auto-resolve/scripts/prepare-swebench-frozen-case.py +0 -273
  341. package/benchmark/auto-resolve/scripts/prepare-swebench-frozen-corpus.py +0 -148
  342. package/benchmark/auto-resolve/scripts/prepare-swebench-solver-worktree.py +0 -210
  343. package/benchmark/auto-resolve/scripts/recent-benchmark-summary.py +0 -232
  344. package/benchmark/auto-resolve/scripts/run-fixture.sh +0 -1024
  345. package/benchmark/auto-resolve/scripts/run-frozen-verify-pair.sh +0 -683
  346. package/benchmark/auto-resolve/scripts/run-full-pipeline-pair-candidate.sh +0 -458
  347. package/benchmark/auto-resolve/scripts/run-headroom-candidate.sh +0 -336
  348. package/benchmark/auto-resolve/scripts/run-iter-0033c.sh +0 -208
  349. package/benchmark/auto-resolve/scripts/run-suite.sh +0 -251
  350. package/benchmark/auto-resolve/scripts/run-swebench-frozen-corpus.sh +0 -310
  351. package/benchmark/auto-resolve/scripts/run-swebench-solver-batch.sh +0 -257
  352. package/benchmark/auto-resolve/scripts/ship-gate.py +0 -391
  353. package/benchmark/auto-resolve/scripts/solo-ceiling-avoidance.py +0 -53
  354. package/benchmark/auto-resolve/scripts/solo-headroom-hypothesis.py +0 -77
  355. package/benchmark/auto-resolve/scripts/swebench-frozen-matrix.py +0 -478
  356. package/benchmark/auto-resolve/scripts/test-audit-headroom-rejections.sh +0 -288
  357. package/benchmark/auto-resolve/scripts/test-audit-pair-evidence.sh +0 -1672
  358. package/benchmark/auto-resolve/scripts/test-benchmark-arg-parsing.sh +0 -1015
  359. package/benchmark/auto-resolve/scripts/test-build-pair-eligible-manifest.sh +0 -491
  360. package/benchmark/auto-resolve/scripts/test-check-f9-artifacts.sh +0 -91
  361. package/benchmark/auto-resolve/scripts/test-frozen-verify-gate.sh +0 -517
  362. package/benchmark/auto-resolve/scripts/test-full-pipeline-pair-gate.sh +0 -610
  363. package/benchmark/auto-resolve/scripts/test-headroom-gate.sh +0 -401
  364. package/benchmark/auto-resolve/scripts/test-iter-0033c-compare.sh +0 -525
  365. package/benchmark/auto-resolve/scripts/test-iter-0033c-l1-summary.sh +0 -254
  366. package/benchmark/auto-resolve/scripts/test-lint-fixtures.sh +0 -580
  367. package/benchmark/auto-resolve/scripts/test-pair-candidate-frontier.sh +0 -591
  368. package/benchmark/auto-resolve/scripts/test-run-full-pipeline-pair-candidate.sh +0 -497
  369. package/benchmark/auto-resolve/scripts/test-run-headroom-candidate.sh +0 -401
  370. package/benchmark/auto-resolve/scripts/test-run-swebench-solver-batch.sh +0 -111
  371. package/benchmark/auto-resolve/scripts/test-ship-gate.sh +0 -1189
  372. package/benchmark/auto-resolve/scripts/test-swebench-frozen-case.sh +0 -1221
  373. package/benchmark/auto-resolve/shadow-fixtures/S1-cli-lang-flag/NOTES.md +0 -28
  374. package/benchmark/auto-resolve/shadow-fixtures/S1-cli-lang-flag/expected.json +0 -63
  375. package/benchmark/auto-resolve/shadow-fixtures/S1-cli-lang-flag/metadata.json +0 -10
  376. package/benchmark/auto-resolve/shadow-fixtures/S1-cli-lang-flag/setup.sh +0 -3
  377. package/benchmark/auto-resolve/shadow-fixtures/S1-cli-lang-flag/spec.md +0 -47
  378. package/benchmark/auto-resolve/shadow-fixtures/S1-cli-lang-flag/task.txt +0 -1
  379. package/benchmark/auto-resolve/shadow-fixtures/S2-cli-inventory-reservation/NOTES.md +0 -34
  380. package/benchmark/auto-resolve/shadow-fixtures/S2-cli-inventory-reservation/expected.json +0 -53
  381. package/benchmark/auto-resolve/shadow-fixtures/S2-cli-inventory-reservation/metadata.json +0 -10
  382. package/benchmark/auto-resolve/shadow-fixtures/S2-cli-inventory-reservation/setup.sh +0 -3
  383. package/benchmark/auto-resolve/shadow-fixtures/S2-cli-inventory-reservation/spec.md +0 -50
  384. package/benchmark/auto-resolve/shadow-fixtures/S2-cli-inventory-reservation/task.txt +0 -1
  385. package/benchmark/auto-resolve/shadow-fixtures/S2-cli-inventory-reservation/verifiers/duplicate-order-error.js +0 -27
  386. package/benchmark/auto-resolve/shadow-fixtures/S2-cli-inventory-reservation/verifiers/priority-stock-reservation.js +0 -44
  387. package/benchmark/auto-resolve/shadow-fixtures/S3-cli-ticket-assignment/NOTES.md +0 -34
  388. package/benchmark/auto-resolve/shadow-fixtures/S3-cli-ticket-assignment/expected.json +0 -55
  389. package/benchmark/auto-resolve/shadow-fixtures/S3-cli-ticket-assignment/metadata.json +0 -10
  390. package/benchmark/auto-resolve/shadow-fixtures/S3-cli-ticket-assignment/setup.sh +0 -3
  391. package/benchmark/auto-resolve/shadow-fixtures/S3-cli-ticket-assignment/spec.md +0 -52
  392. package/benchmark/auto-resolve/shadow-fixtures/S3-cli-ticket-assignment/task.txt +0 -1
  393. package/benchmark/auto-resolve/shadow-fixtures/S3-cli-ticket-assignment/verifiers/duplicate-ticket-error.js +0 -29
  394. package/benchmark/auto-resolve/shadow-fixtures/S3-cli-ticket-assignment/verifiers/priority-agent-assignment.js +0 -48
  395. package/benchmark/auto-resolve/shadow-fixtures/S4-cli-return-routing/NOTES.md +0 -34
  396. package/benchmark/auto-resolve/shadow-fixtures/S4-cli-return-routing/expected.json +0 -55
  397. package/benchmark/auto-resolve/shadow-fixtures/S4-cli-return-routing/metadata.json +0 -10
  398. package/benchmark/auto-resolve/shadow-fixtures/S4-cli-return-routing/setup.sh +0 -3
  399. package/benchmark/auto-resolve/shadow-fixtures/S4-cli-return-routing/spec.md +0 -55
  400. package/benchmark/auto-resolve/shadow-fixtures/S4-cli-return-routing/task.txt +0 -1
  401. package/benchmark/auto-resolve/shadow-fixtures/S4-cli-return-routing/verifiers/duplicate-return-error.js +0 -43
  402. package/benchmark/auto-resolve/shadow-fixtures/S4-cli-return-routing/verifiers/priority-return-routing.js +0 -70
  403. package/benchmark/auto-resolve/shadow-fixtures/S5-cli-credit-grant-ledger/NOTES.md +0 -37
  404. package/benchmark/auto-resolve/shadow-fixtures/S5-cli-credit-grant-ledger/expected.json +0 -54
  405. package/benchmark/auto-resolve/shadow-fixtures/S5-cli-credit-grant-ledger/metadata.json +0 -10
  406. package/benchmark/auto-resolve/shadow-fixtures/S5-cli-credit-grant-ledger/setup.sh +0 -3
  407. package/benchmark/auto-resolve/shadow-fixtures/S5-cli-credit-grant-ledger/spec.md +0 -59
  408. package/benchmark/auto-resolve/shadow-fixtures/S5-cli-credit-grant-ledger/task.txt +0 -1
  409. package/benchmark/auto-resolve/shadow-fixtures/S5-cli-credit-grant-ledger/verifiers/credit-ledger-priority.js +0 -98
  410. package/benchmark/auto-resolve/shadow-fixtures/S5-cli-credit-grant-ledger/verifiers/duplicate-charge-error.js +0 -38
  411. package/benchmark/auto-resolve/shadow-fixtures/S6-cli-refund-window-ledger/NOTES.md +0 -36
  412. package/benchmark/auto-resolve/shadow-fixtures/S6-cli-refund-window-ledger/expected.json +0 -56
  413. package/benchmark/auto-resolve/shadow-fixtures/S6-cli-refund-window-ledger/metadata.json +0 -10
  414. package/benchmark/auto-resolve/shadow-fixtures/S6-cli-refund-window-ledger/setup.sh +0 -3
  415. package/benchmark/auto-resolve/shadow-fixtures/S6-cli-refund-window-ledger/spec.md +0 -59
  416. package/benchmark/auto-resolve/shadow-fixtures/S6-cli-refund-window-ledger/task.txt +0 -1
  417. package/benchmark/auto-resolve/shadow-fixtures/S6-cli-refund-window-ledger/verifiers/duplicate-refund-error.js +0 -41
  418. package/benchmark/auto-resolve/shadow-fixtures/S6-cli-refund-window-ledger/verifiers/priority-refund-ledger.js +0 -65
  419. package/config/skills/_shared/pair-plan-schema.md +0 -298
  420. package/scripts/lint-fixtures.sh +0 -352
  421. package/scripts/lint-shadow-fixtures.sh +0 -58
  422. package/scripts/lint-skills.sh +0 -4265
  423. package/scripts/test-owner-phases.py +0 -238
@@ -185,18 +185,15 @@ def allocate(args):
185
185
  require(re.fullmatch(r"[A-Za-z0-9_.-]+", args.remote), "unsafe remote name")
186
186
  receipt = {"task": args.task, "repository": args.repository, "remote": args.remote,
187
187
  "base": args.base, "branch": args.branch, "common_gitdir": str(common),
188
- "anchor": str(work), "linked": bool(args.worktree), "allocation": "allocating"}
188
+ "anchor": str(work), "linked": True, "allocation": "allocating"}
189
189
  policy(receipt, None)
190
190
  receipt["remote_url"] = remote_url(receipt)
191
191
  require("\n" not in receipt["remote_url"]["push"] and receipt["remote_url"]["push"] == receipt["remote_url"]["fetch"], "split/multiple remote URLs are unsupported")
192
192
  require(not ref_sha(receipt, "refs/heads/"+args.branch), "existing branch cannot be adopted")
193
- require(git(work, "symbolic-ref", "HEAD") == "refs/heads/" + args.base, "allocate from the retained base checkout")
194
- require(not git(work, "status", "--porcelain", "--untracked-files=all"), "allocation requires clean tracked/untracked contents")
195
- receipt["baseline"] = git(work, "rev-parse", "HEAD")
196
- target = Path(args.worktree).absolute() if args.worktree else work
193
+ receipt["baseline"] = remote_base(receipt)
194
+ target = Path(args.worktree).absolute()
197
195
  require(target == target.resolve(), "worktree path must not traverse symlinks")
198
- if args.worktree:
199
- require(not target.exists() and not target.is_relative_to(work) and not work.is_relative_to(target), "linked worktree must be an absent disjoint path")
196
+ require(not target.exists() and all(not target.is_relative_to(p) and not p.is_relative_to(target) for p in map(Path, registrations(receipt))), "linked worktree must be an absent path disjoint from every registered worktree")
200
197
  receipt["worktree"] = str(target)
201
198
  key = hashlib.sha256(args.branch.encode()).hexdigest()[:24]
202
199
  directory = common / "devlyn-completion" / key
@@ -210,15 +207,13 @@ def allocate(args):
210
207
  # Persist prospective intent BEFORE native creation. An interrupted allocation
211
208
  # stays visibly incomplete; a later call may not adopt whatever now exists.
212
209
  atomic_json(path, receipt)
213
- if args.worktree:
214
- git(work, "worktree", "add", "-b", args.branch, str(target), receipt["baseline"])
215
- else:
216
- git(work, "switch", "-c", args.branch, receipt["baseline"])
210
+ gref(receipt, "worktree", "add", "-b", args.branch, str(target), receipt["baseline"])
217
211
  receipt["worktree_gitdir"] = git(target, "rev-parse", "--absolute-git-dir")
218
212
  receipt["worktree_identity"] = workspace_identity(target, Path(receipt["worktree_gitdir"]))
219
213
  receipt["allocation"] = "owned"
220
214
  atomic_json(path, receipt)
221
- return {"status": "ALLOCATED", "receipt": str(path), "worktree": str(target), "scratch": str(scratch)}
215
+ return {"status": "ALLOCATED", "receipt": str(path), "worktree": str(target), "scratch": str(scratch),
216
+ "reconciled": reconcile(common, path, work)}
222
217
 
223
218
 
224
219
  def pipeline_acceptance(work, acceptance, files, directory):
@@ -298,8 +293,6 @@ def bind_acceptance(receipt, path, supplied):
298
293
  if supplied:
299
294
  require(file_record(Path(supplied))["sha256"] == receipt["acceptance_digest"], "acceptance changed; resume with the original acceptance")
300
295
  verify_files(path.parent / "custody", receipt["files"])
301
- if work.exists() and not receipt.get("cleanup_started"):
302
- verify_files(work, receipt["files"])
303
296
  return
304
297
  require(supplied, "first completion requires explicit root acceptance")
305
298
  acceptance_path = Path(supplied).absolute()
@@ -377,6 +370,13 @@ def repo_policy(receipt):
377
370
  return info
378
371
 
379
372
 
373
+ def remote_base(receipt):
374
+ # Never write FETCH_HEAD: a concurrent `git pull` in the anchor would merge ours.
375
+ tracking = "refs/remotes/" + receipt["remote"] + "/" + receipt["base"]
376
+ gref(receipt, "fetch", "--no-tags", "--no-write-fetch-head", receipt["remote"], "+refs/heads/" + receipt["base"] + ":" + tracking)
377
+ return gref(receipt, "rev-parse", "--verify", tracking)
378
+
379
+
380
380
  def remote_head(receipt, branch):
381
381
  rows = gref(receipt, "ls-remote", "--heads", receipt["remote"], "refs/heads/"+branch).splitlines()
382
382
  require(len(rows) <= 1, "ambiguous remote ref")
@@ -395,24 +395,35 @@ def workspace_identity(work, gitdir):
395
395
  return [[p.stat().st_dev, p.stat().st_ino] for p in (work, gitdir)]
396
396
 
397
397
 
398
- def inspect_workspace(receipt, *, allow_returned=False):
398
+ def inspect_workspace(receipt):
399
399
  work = Path(receipt["worktree"])
400
400
  row = registrations(receipt).get(str(work))
401
401
  require(row and "locked" not in row and "prunable" not in row and "detached" not in row, "worktree missing, detached or locked; retain and inspect registration")
402
402
  expected = "refs/heads/" + receipt["branch"]
403
- returned = allow_returned and not receipt["linked"] and row.get("branch") == "refs/heads/"+receipt["base"] and receipt.get("cleanup_started")
404
- require(row.get("branch") == expected or returned, "foreign worktree branch; retain workspace")
403
+ require(row.get("branch") == expected, "foreign worktree branch; retain workspace")
405
404
  require(git(work, "rev-parse", "--absolute-git-dir") == receipt["worktree_gitdir"] and str(Path(git(work, "rev-parse", "--path-format=absolute", "--git-common-dir")).resolve()) == receipt["common_gitdir"], "worktree Gitdir ownership changed")
406
405
  require(workspace_identity(work, Path(receipt["worktree_gitdir"])) == receipt["worktree_identity"], "worktree/registration was replaced; receipt no longer owns it")
407
- if not returned:
408
- require(git(work, "rev-parse", "HEAD") == receipt.get("publish_sha", receipt["baseline"]), "task HEAD changed")
406
+ require(git(work, "rev-parse", "HEAD") == receipt.get("publish_sha", receipt["baseline"]), "task HEAD changed")
409
407
  require(not git(work, "status", "--porcelain", "--untracked-files=all"), "dirty/untracked workspace; retain files and commit only accepted scope")
410
- return returned
411
408
 
412
409
 
413
- def stopped_writers(work, *, linked):
414
- require(not linked or not Path.cwd().resolve().is_relative_to(work), "caller cwd is inside removable worktree; yield it and resume from outside")
415
- require(not linked or not Path(__file__).resolve().is_relative_to(work), "invoke the installed helper outside the removable worktree so resume remains available")
410
+ def outside(work):
411
+ require(not Path.cwd().resolve().is_relative_to(work), "caller cwd is inside removable worktree; yield it and resume from outside")
412
+ require(not Path(__file__).resolve().is_relative_to(work), "invoke the installed helper outside the removable worktree so resume remains available")
413
+
414
+
415
+ def removable(receipt, work):
416
+ inspect_workspace(receipt)
417
+ verify_files(work, receipt["files"])
418
+ # Git lists a nested repository or worktree under an ignored path as `dir/`;
419
+ # native removal would delete it with its uncommitted work.
420
+ nested = [p for p in git(work, "ls-files", "--others", "--ignored", "--exclude-standard", "-z").split("\0") if p.endswith("/")]
421
+ require(not nested, "nested repository or worktree inside the task tree: " + ", ".join(nested) + "; retain")
422
+ stopped_writers(work)
423
+
424
+
425
+ def stopped_writers(work):
426
+ outside(work)
416
427
  # The owner assertion covers its actual children; an OS observation catches
417
428
  # other currently open files/cwds, not future writers or a universal lease.
418
429
  if sys.platform == "darwin":
@@ -431,10 +442,15 @@ def stopped_writers(work, *, linked):
431
442
  try:
432
443
  links = [process / "cwd", *(process / "fd").iterdir()]
433
444
  for link in links:
434
- target = Path(os.readlink(link))
445
+ try:
446
+ target = Path(os.readlink(link))
447
+ except FileNotFoundError:
448
+ if link == process / "cwd":
449
+ break
450
+ continue
435
451
  require(not target.is_absolute() or not target.is_relative_to(work), f"active process {process.name} uses task files; stop/yield it before resume")
436
452
  except FileNotFoundError:
437
- continue # Process or fd exited during observation.
453
+ continue # Process exited during observation.
438
454
  except PermissionError as exc:
439
455
  raise CompletionError("unknown process access; retain tree until writer cessation can be established") from exc
440
456
  else:
@@ -478,7 +494,7 @@ def clean_scratch(receipt, path, writers_stopped):
478
494
  require(workspace_identity(scratch, path.parent) == identity, "scratch directory was replaced; retain")
479
495
  require(shutil.rmtree.avoids_symlink_attacks, "safe scratch removal unsupported on this platform; retain")
480
496
  require(not any(mount.is_relative_to(scratch) for mount in scratch_mounts()), "scratch contains a mounted filesystem; retain")
481
- stopped_writers(scratch, linked=True)
497
+ stopped_writers(scratch)
482
498
  size = 0
483
499
  for directory, dirs, files in os.walk(scratch, followlinks=False, onerror=raise_walk_error):
484
500
  require(not any(name.casefold() == ".git" for name in dirs + files), "scratch contains Git recovery data; move it to retained custody before cleanup")
@@ -487,7 +503,7 @@ def clean_scratch(receipt, path, writers_stopped):
487
503
  info = item.lstat()
488
504
  require(info.st_dev == identity[0][0], "scratch contains a mounted filesystem; retain")
489
505
  size += info.st_size if stat.S_ISREG(info.st_mode) else 0
490
- stopped_writers(scratch, linked=True)
506
+ stopped_writers(scratch)
491
507
  require(workspace_identity(scratch, path.parent) == identity, "scratch changed before removal; retain")
492
508
  require(not any(mount.is_relative_to(scratch) for mount in scratch_mounts()), "scratch mount appeared before removal; retain")
493
509
  for item in scratch.iterdir():
@@ -510,57 +526,47 @@ def clean_scratch_command(args):
510
526
  "receipt": str(path), "scratch_cleanup": result, "product_verdict_unchanged": True}
511
527
 
512
528
 
513
- def cleanup(receipt, path, pr, writers_stopped):
529
+ def cleanup(receipt, path, pr):
514
530
  require(pr["state"] == "MERGED" and pr.get("mergedAt") and pr.get("mergeCommit", {}).get("oid"), "actual matching merge evidence is required")
515
- require(writers_stopped, "wait actual children, stop/yield known writers, then resume with --writers-stopped")
516
531
  sha = receipt["publish_sha"]
517
532
  branch_ref = "refs/heads/" + receipt["branch"]
533
+ # The recovery ref keeps the accepted commit reachable, so squash and
534
+ # rebase merges need only the merge commit on base.
518
535
  require(ref_sha(receipt, receipt["recovery_ref"]) == sha, "recovery reachability changed; retain resources")
519
536
  verify_files(path.parent / "custody", receipt["files"])
520
537
  require(read_json(path.parent / "manifest.json") == receipt["files"], "custody manifest changed")
521
- gref(receipt, "fetch", "--no-tags", receipt["remote"], "refs/heads/"+receipt["base"])
522
- base_sha = gref(receipt, "rev-parse", "FETCH_HEAD")
523
- gref(receipt, "merge-base", "--is-ancestor", pr["mergeCommit"]["oid"], base_sha)
524
- gref(receipt, "merge-base", "--is-ancestor", sha, pr["mergeCommit"]["oid"])
538
+ gref(receipt, "merge-base", "--is-ancestor", pr["mergeCommit"]["oid"], remote_base(receipt))
525
539
  gref(receipt, "merge-base", "--is-ancestor", receipt["source_sha"], sha)
526
540
  work = Path(receipt["worktree"])
527
541
  local = ref_sha(receipt, branch_ref)
528
- require(local == sha or (local is None and receipt.get("cleanup_started")), "local task ref changed; retain")
529
- if work.exists():
530
- returned = inspect_workspace(receipt, allow_returned=True)
531
- if not returned:
532
- verify_files(work, receipt["files"])
533
- if receipt["linked"]:
542
+ require(local in {None, sha}, "local task ref changed; retain")
543
+ if receipt["linked"]:
544
+ if work.exists():
545
+ require(receipt.get("writers_released"), "wait actual children, stop/yield known writers, then resume with --writers-stopped")
534
546
  require(work != Path(receipt["anchor"]) and work != Path(receipt["common_gitdir"]).parent, "cannot remove retained/main checkout")
535
- ignored = git(work, "ls-files", "--others", "--ignored", "--exclude-standard", "-z").split("\0")
536
- require(all(not p or p in receipt["files"] for p in ignored), "unknown ignored content; retain tree and arrange explicit custody outside this helper")
537
- stopped_writers(work, linked=True)
538
- inspect_workspace(receipt)
539
- verify_files(work, receipt["files"])
540
- receipt["cleanup_started"] = True
541
- atomic_json(path, receipt)
547
+ removable(receipt, work)
548
+ devlyn = safe_path(work, ".devlyn")
549
+ if devlyn.exists():
550
+ require(devlyn.is_dir(), ".devlyn records must be a directory; retain tree")
551
+ paths = [str((Path(directory) / name).relative_to(work))
552
+ for directory, dirs, files in os.walk(devlyn, onerror=raise_walk_error)
553
+ for name in files + [name for name in dirs if (Path(directory) / name).is_symlink()]]
554
+ if paths:
555
+ records = snapshot_files(work, paths)
556
+ destination = path.parent / "records"
557
+ if not destination.exists():
558
+ with tempfile.TemporaryDirectory(prefix=".records-", dir=path.parent) as temporary:
559
+ shutil.copytree(devlyn, Path(temporary) / ".devlyn", symlinks=True)
560
+ verify_files(Path(temporary), records)
561
+ Path(temporary).rename(destination)
562
+ verify_files(destination, records)
563
+ verify_files(work, records)
564
+ removable(receipt, work)
542
565
  gref(receipt, "worktree", "remove", str(work))
543
566
  else:
544
- # In-place ignored user data stays in the retained checkout. Native
545
- # switch --no-overwrite-ignore prevents a base file replacing it.
546
- stopped_writers(work, linked=False)
547
- base_ref = "refs/heads/" + receipt["base"]
548
- old_base = ref_sha(receipt, base_ref)
549
- require(old_base, "local base branch is missing")
550
- gref(receipt, "merge-base", "--is-ancestor", old_base, base_sha)
551
- receipt["cleanup_started"] = True
552
- atomic_json(path, receipt)
553
- inspect_workspace(receipt, allow_returned=True)
554
- if not returned:
555
- other = [row for p, row in registrations(receipt).items() if p != str(work) and row.get("branch") == base_ref]
556
- require(not other, "base checked out elsewhere; retain task checkout")
557
- gref(receipt, "update-ref", base_ref, base_sha, old_base)
558
- git(work, "switch", "--no-overwrite-ignore", receipt["base"])
559
- else:
560
- git(work, "merge", "--no-overwrite-ignore", "--ff-only", base_sha)
561
- else:
562
- require(receipt["linked"] and receipt.get("cleanup_started") and str(work) not in registrations(receipt), "missing/replaced worktree was not removed by this receipt")
563
- require(all(row.get("branch") != branch_ref for row in registrations(receipt).values()), "task branch is still checked out; retain ref")
567
+ require(str(work) not in registrations(receipt), "missing worktree is still registered; retain")
568
+ checked_out = [p for p, row in registrations(receipt).items() if row.get("branch") == branch_ref]
569
+ require(not checked_out, "task branch is still checked out at " + ", ".join(checked_out) + "; switch it off the task branch before cleanup")
564
570
  # Check ownership/ref identity once more immediately before each deletion.
565
571
  repo_policy(receipt)
566
572
  require(remote_url(receipt) == receipt["remote_url"], "remote changed before deletion")
@@ -578,12 +584,12 @@ def cleanup(receipt, path, pr, writers_stopped):
578
584
 
579
585
 
580
586
  @contextlib.contextmanager
581
- def locked_receipt(path):
587
+ def locked_receipt(path, *, blocking=True):
582
588
  require(path.name == "receipt.json" and path.parent.parent.name == "devlyn-completion" and path == path.resolve(), "receipt must be its original external nonsymlink path")
583
589
  require(not (path.parent / "lock").is_symlink(), "receipt lock must not be a symlink")
584
590
  with contextlib.ExitStack() as stack:
585
591
  try:
586
- stack.enter_context(shared("platform-support")["file_lock"](path.parent / "lock", blocking=True))
592
+ stack.enter_context(shared("platform-support")["file_lock"](path.parent / "lock", blocking=blocking))
587
593
  except (ImportError, OSError, AttributeError) as exc:
588
594
  raise CompletionError(f"receipt lock unavailable: {path}: {exc}") from exc
589
595
  receipt = read_json(path)
@@ -593,29 +599,73 @@ def locked_receipt(path):
593
599
  yield receipt
594
600
 
595
601
 
602
+ def completion_result(receipt, path, status):
603
+ released = receipt.get("writers_released", False)
604
+ resume = shlex.join([sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(path)] +
605
+ (["--writers-stopped"] if released else []))
606
+ scratch = {"status": "NOT_OWNED"}
607
+ if "scratch_identity" in receipt:
608
+ try:
609
+ scratch = clean_scratch(receipt, path, released)
610
+ except (CompletionError, OSError, ValueError, KeyError, TypeError, IndexError) as error:
611
+ scratch = {"status": "RETAINED", "reason": str(error), "resume": shlex.join([
612
+ sys.executable, str(Path(__file__).resolve()), "clean-scratch", "--receipt", str(path), "--writers-stopped"])}
613
+ receipt["scratch_cleanup"] = scratch
614
+ try:
615
+ atomic_json(path, receipt)
616
+ except OSError as record_error:
617
+ scratch["record_error"] = str(record_error)
618
+ return {"status": "CLEANUP_PENDING" if status == "COMPLETE" and scratch["status"] == "RETAINED" else status,
619
+ "delivery_status": status, "receipt": str(path), "pr": receipt.get("pr_url"), "resume": resume,
620
+ "acceptance": (receipt.get("acceptance") or {}).get("kind"), "product_verdict_unchanged": True,
621
+ "scratch_path": str(path.parent / "scratch") if "scratch_identity" in receipt else None,
622
+ "scratch_cleanup": scratch}
623
+
624
+
625
+ def reconcile(common, allocated, anchor):
626
+ results = []
627
+ for path in sorted((common / "devlyn-completion").glob("*/receipt.json")):
628
+ if path == allocated:
629
+ continue
630
+ try:
631
+ receipt = read_json(path)
632
+ if receipt.get("allocation") != "owned" or not receipt.get("acceptance") or not receipt.get("pr_number") or receipt.get("local_only"):
633
+ continue
634
+ if receipt.get("status") == "COMPLETE" and (not receipt.get("writers_released") or "scratch_identity" not in receipt or receipt.get("scratch_cleanup", {}).get("status") == "CLEAN"):
635
+ continue
636
+ with locked_receipt(path, blocking=False) as receipt:
637
+ if receipt.get("status") == "COMPLETE":
638
+ if receipt.get("writers_released") and "scratch_identity" in receipt and receipt.get("scratch_cleanup", {}).get("status") != "CLEAN":
639
+ results.append(completion_result(receipt, path, "COMPLETE"))
640
+ continue
641
+ if not receipt.get("acceptance") or not receipt.get("pr_number") or receipt.get("local_only"):
642
+ continue
643
+ pr = json.loads(gh(receipt, "pr", "view", str(receipt["pr_number"]), "--json", PR_FIELDS))
644
+ if pr["state"] != "MERGED":
645
+ continue
646
+ validate_pr(receipt, pr)
647
+ require(not receipt["linked"] or Path(receipt["worktree"]) != anchor, "cannot clean the current allocation's anchor checkout: " + str(anchor))
648
+ require(remote_url(receipt) == receipt["remote_url"], "remote URL changed since allocation")
649
+ repo_policy(receipt)
650
+ receipt["merge"] = pr
651
+ atomic_json(path, receipt)
652
+ cleanup(receipt, path, pr)
653
+ results.append(completion_result(receipt, path, "COMPLETE"))
654
+ except (Exception, SystemExit) as error:
655
+ results.append({"receipt": str(path), "status": "RETAINED", "reason": str(error)})
656
+ return results
657
+
658
+
596
659
  def complete(args):
597
660
  path = Path(args.receipt).absolute()
598
- resume = shlex.join([sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(path)] +
599
- (["--writers-stopped"] if args.writers_stopped else []))
600
661
  with locked_receipt(path) as receipt:
662
+ if args.writers_stopped:
663
+ if receipt["linked"]:
664
+ outside(Path(receipt["worktree"]))
665
+ receipt["writers_released"] = True
666
+ atomic_json(path, receipt)
601
667
  def result(status):
602
- scratch = {"status": "NOT_OWNED"}
603
- if "scratch_identity" in receipt:
604
- try:
605
- scratch = clean_scratch(receipt, path, args.writers_stopped)
606
- except (CompletionError, OSError, ValueError, KeyError, TypeError, IndexError) as error:
607
- scratch = {"status": "RETAINED", "reason": str(error), "resume": shlex.join([
608
- sys.executable, str(Path(__file__).resolve()), "clean-scratch", "--receipt", str(path), "--writers-stopped"])}
609
- receipt["scratch_cleanup"] = scratch
610
- try:
611
- atomic_json(path, receipt)
612
- except OSError as record_error:
613
- scratch["record_error"] = str(record_error)
614
- return {"status": "CLEANUP_PENDING" if status == "COMPLETE" and scratch["status"] == "RETAINED" else status,
615
- "delivery_status": status, "receipt": str(path), "pr": receipt.get("pr_url"), "resume": resume,
616
- "acceptance": (receipt.get("acceptance") or {}).get("kind"), "product_verdict_unchanged": True,
617
- "scratch_path": str(path.parent / "scratch") if "scratch_identity" in receipt else None,
618
- "scratch_cleanup": scratch}
668
+ return completion_result(receipt, path, status)
619
669
  if args.local_only or receipt.get("local_only"):
620
670
  receipt["local_only"] = True
621
671
  atomic_json(path, receipt)
@@ -628,17 +678,13 @@ def complete(args):
628
678
  if receipt.get("status") == "COMPLETE":
629
679
  verify_files(path.parent / "custody", receipt["files"])
630
680
  return result("COMPLETE")
631
- if not receipt.get("cleanup_started"):
681
+ if not receipt.get("acceptance"):
632
682
  # Before first binding the owner has committed the product; inspect
633
683
  # uses the actual branch head until acceptance pins its exact SHA.
634
684
  current = dict(receipt, publish_sha=ref_sha(receipt, "refs/heads/"+receipt["branch"]))
635
685
  inspect_workspace(current)
636
686
  bind_acceptance(receipt, path, args.acceptance)
637
- if not receipt.get("cleanup_started"):
638
- inspect_workspace(receipt)
639
687
  info = repo_policy(receipt)
640
- if mode == "auto":
641
- require(info["mergeCommitAllowed"], "repository disallows merge commits; use pr mode or resolve policy with repository owner")
642
688
  if receipt.get("pr_number"):
643
689
  pr = json.loads(gh(receipt, "pr", "view", str(receipt["pr_number"]), "--json", PR_FIELDS))
644
690
  else:
@@ -649,11 +695,11 @@ def complete(args):
649
695
  validate_pr(receipt, pr)
650
696
  actual = remote_head(receipt, receipt["branch"])
651
697
  if not pr or pr["state"] != "MERGED":
698
+ inspect_workspace(receipt)
699
+ verify_files(Path(receipt["worktree"]), receipt["files"])
652
700
  require(actual in {None, receipt["publish_sha"]}, "remote task head changed; refusing to overwrite or merge")
653
701
  require(actual is not None or (not pr and not receipt.get("pushed")), "published task ref disappeared; retain and inspect")
654
702
  if actual is None:
655
- inspect_workspace(receipt)
656
- verify_files(Path(receipt["worktree"]), receipt["files"])
657
703
  gref(receipt, "push", receipt["remote"], receipt["publish_sha"]+":refs/heads/"+receipt["branch"])
658
704
  receipt["pushed"] = True
659
705
  atomic_json(path, receipt)
@@ -665,21 +711,33 @@ def complete(args):
665
711
  validate_pr(receipt, pr)
666
712
  receipt.update(pr_number=pr["number"], pr_url=pr["url"])
667
713
  atomic_json(path, receipt)
668
- if mode == "pr":
669
- return result("PR")
714
+ if mode == "pr" and pr["state"] == "OPEN":
715
+ if pr.get("autoMergeRequest"):
716
+ gh(receipt, "pr", "merge", str(pr["number"]), "--disable-auto")
717
+ pr = json.loads(gh(receipt, "pr", "view", str(pr["number"]), "--json", PR_FIELDS))
718
+ validate_pr(receipt, pr)
719
+ require(not pr.get("autoMergeRequest"), "auto-merge request remains on owned PR; retain and inspect")
720
+ if pr["state"] == "OPEN":
721
+ return result("PR")
670
722
  if pr["state"] != "MERGED":
671
723
  inspect_workspace(receipt)
672
724
  verify_files(Path(receipt["worktree"]), receipt["files"])
673
725
  require(remote_head(receipt, receipt["branch"]) == receipt["publish_sha"], "remote head race before merge")
726
+ refused = None
674
727
  if not pr.get("autoMergeRequest"):
675
- gh(receipt, "pr", "merge", str(pr["number"]), "--auto", "--merge", "--match-head-commit", receipt["publish_sha"])
728
+ try:
729
+ require(info["mergeCommitAllowed"], "repository disallows merge commits")
730
+ gh(receipt, "pr", "merge", str(pr["number"]), "--auto", "--merge", "--match-head-commit", receipt["publish_sha"])
731
+ except CompletionError as error:
732
+ refused = str(error)
676
733
  pr = json.loads(gh(receipt, "pr", "view", str(pr["number"]), "--json", PR_FIELDS))
677
734
  validate_pr(receipt, pr)
678
735
  if pr["state"] != "MERGED":
679
- return result("PENDING")
736
+ # Merge only when the repository allows it; a refused request leaves the PR for a person.
737
+ return dict(result("PR"), merge_refused=refused) if refused and not pr.get("autoMergeRequest") else result("PENDING")
680
738
  receipt["merge"] = pr
681
739
  atomic_json(path, receipt)
682
- cleanup(receipt, path, pr, args.writers_stopped)
740
+ cleanup(receipt, path, pr)
683
741
  return result("COMPLETE")
684
742
 
685
743
 
@@ -692,7 +750,7 @@ def main():
692
750
  for name in ("task", "branch", "repository", "base"):
693
751
  allocation.add_argument("--"+name, required=True)
694
752
  allocation.add_argument("--remote", default="origin")
695
- allocation.add_argument("--worktree")
753
+ allocation.add_argument("--worktree", required=True)
696
754
  completion = actions.add_parser("complete")
697
755
  completion.add_argument("--receipt", required=True)
698
756
  completion.add_argument("--acceptance")
@@ -742,7 +800,7 @@ def remote(ref):
742
800
  return r.stdout.strip() if r.returncode == 0 else None
743
801
  if a[:2] == ['repo', 'view']:
744
802
  assert a[2:] == ['github.com/test/project', '--json', 'nameWithOwner,url,defaultBranchRef,mergeCommitAllowed'], 'unsupported gh repo view arguments: '+str(a)
745
- print(json.dumps({'nameWithOwner':'test/project', 'url':'https://github.com/test/project', 'defaultBranchRef':{'name':'main'}, 'mergeCommitAllowed':d.get('merge_allowed',True)}))
803
+ print(json.dumps({'nameWithOwner':'test/project', 'url':'https://github.com/test/project', 'defaultBranchRef':{'name':d.get('default_branch','main')}, 'mergeCommitAllowed':d.get('merge_allowed',True)}))
746
804
  elif a[:2] == ['pr', 'list']:
747
805
  assert '--repo' in a and a[a.index('--repo')+1] == 'github.com/test/project'
748
806
  print(json.dumps([d['pr']] if d.get('pr') else []))
@@ -756,6 +814,10 @@ elif a[:2] == ['pr', 'create']:
756
814
  if d.pop('interrupt_create',False): save(); sys.exit(1)
757
815
  elif a[:2] == ['pr', 'view']:
758
816
  print(json.dumps(d['pr']))
817
+ elif a[:2] == ['pr', 'merge'] and '--disable-auto' in a:
818
+ d['disables'] = d.get('disables',0)+1
819
+ d['pr']['autoMergeRequest'] = None
820
+ save()
759
821
  elif a[:2] == ['pr', 'merge']:
760
822
  assert '--admin' not in a and '--delete-branch' not in a
761
823
  assert '--auto' in a and '--merge' in a and '--match-head-commit' in a
@@ -837,7 +899,7 @@ class CompletionTests(unittest.TestCase):
837
899
  self.env["PATH"] = str(self.bin) + os.pathsep + os.environ["PATH"]
838
900
  self.run_cmd(["git", "init", "--bare", "--initial-branch=main", str(self.bare)])
839
901
  self.run_cmd(["git", "init", "--initial-branch=main", str(self.work)])
840
- (self.work / ".gitignore").write_text(".devlyn/\nignored/\n", encoding="utf-8")
902
+ (self.work / ".gitignore").write_text(".devlyn/\nignored/\nnode_modules/\n.env\n", encoding="utf-8")
841
903
  (self.work / "product").write_text("baseline\n", encoding="utf-8")
842
904
  self.g("add", ".")
843
905
  self.g("commit", "-m", "baseline")
@@ -862,33 +924,61 @@ class CompletionTests(unittest.TestCase):
862
924
  r = self.run_cmd([sys.executable, str(Path(__file__).resolve()), *map(str, args)], success=success, cwd=cwd)
863
925
  return json.loads(r.stdout), r
864
926
 
865
- def allocate(self, linked=False, base="main"):
866
- args = ["allocate", "--repo", self.work, "--task", "fixture", "--branch", "task/fixture", "--repository", "test/project", "--base", base]
867
- if linked:
868
- args += ["--worktree", self.root / "linked"]
927
+ def allocate(self, name="fixture", base="main"):
928
+ args = ["allocate", "--repo", self.work, "--task", name, "--branch", "task/"+name, "--repository", "test/project", "--base", base,
929
+ "--worktree", self.root / ("linked" if name == "fixture" else name)]
869
930
  result, _ = self.cli(*args)
870
931
  self.receipt = Path(result["receipt"])
871
932
  self.task = Path(result["worktree"])
872
933
  return result
873
934
 
935
+ def allocate_legacy(self):
936
+ common = Path(self.g("rev-parse", "--absolute-git-dir"))
937
+ key = hashlib.sha256(b"task/fixture").hexdigest()[:24]
938
+ receipt = {"task": "fixture", "repository": "test/project", "remote": "origin", "base": "main", "branch": "task/fixture",
939
+ "common_gitdir": str(common), "anchor": str(self.work), "linked": False, "allocation": "owned",
940
+ "baseline": self.g("rev-parse", "HEAD"), "worktree": str(self.work), "id": key,
941
+ "recovery_ref": "refs/devlyn/completed/"+key, "worktree_gitdir": str(common),
942
+ "remote_url": dict.fromkeys(("literal", "fetch", "push"), "https://github.com/test/project.git")}
943
+ self.receipt = common / "devlyn-completion" / key / "receipt.json"
944
+ self.receipt.parent.mkdir(parents=True)
945
+ scratch = self.receipt.parent / "scratch"
946
+ scratch.mkdir()
947
+ receipt["scratch_identity"] = workspace_identity(scratch, self.receipt.parent)
948
+ self.g("switch", "-c", "task/fixture")
949
+ receipt["worktree_identity"] = workspace_identity(self.work, common)
950
+ self.receipt.write_text(json.dumps(receipt), encoding="utf-8")
951
+ self.task = self.work
952
+
953
+ def merge_pr(self):
954
+ self.configure(pending=False)
955
+ self.run_cmd(["gh", "pr", "merge", "1", "--auto", "--merge", "--match-head-commit", self.sha])
956
+
957
+ def anchor_state(self, work=None):
958
+ work = work or self.work
959
+ return (self.g("symbolic-ref", "HEAD", work=work), self.g("rev-parse", "HEAD", work=work),
960
+ self.g("status", "--porcelain", "--untracked-files=all", work=work), (work / "product").read_bytes(),
961
+ (Path(self.g("rev-parse", "--absolute-git-dir", work=work)) / "index").read_bytes())
962
+
874
963
  def test_allocate_base_with_same_named_tag(self):
875
964
  self.g("tag", "main")
876
- result = self.allocate(linked=True)
965
+ result = self.allocate()
877
966
  self.assertEqual(result["status"], "ALLOCATED")
878
967
  self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["base"], "main")
879
968
 
880
969
  def test_allocate_base_preserves_unicode_whitespace(self):
881
970
  base = "main\u00a0"
882
971
  self.g("switch", "-c", base)
972
+ self.g("push", "origin", "HEAD:refs/heads/"+base)
883
973
  result = self.allocate(base=base)
884
974
  self.assertEqual(result["status"], "ALLOCATED")
885
975
  self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["base"], base)
886
976
 
887
977
  def assert_base_identity_rejected(self, actual, base):
888
978
  result, process = self.cli("allocate", "--repo", self.work, "--task", "fixture",
889
- "--branch", "task/fixture", "--repository", "test/project", "--base", base, success=False)
979
+ "--branch", "task/fixture", "--repository", "test/project", "--base", base, "--worktree", self.root / "linked", success=False)
890
980
  self.assertNotEqual(process.returncode, 0)
891
- self.assertIn("retained base checkout", result["reason"])
981
+ self.assertIn("refs/heads/"+base, result["reason"])
892
982
  head = self.run_cmd(["git", "-C", str(self.work), "symbolic-ref", "HEAD"]).stdout
893
983
  self.assertEqual(head, "refs/heads/" + actual + "\n")
894
984
  self.assertFalse((self.work / ".git/devlyn-completion").exists())
@@ -897,6 +987,8 @@ class CompletionTests(unittest.TestCase):
897
987
 
898
988
  def test_allocate_rejects_trimmed_base_alias(self):
899
989
  self.g("switch", "-c", "main\u00a0")
990
+ self.g("push", "origin", "HEAD:refs/heads/main\u00a0")
991
+ self.run_cmd(["git", "--git-dir", str(self.bare), "update-ref", "-d", "refs/heads/main"])
900
992
  self.assert_base_identity_rejected("main\u00a0", "main")
901
993
 
902
994
  def test_allocate_rejects_short_base_alias(self):
@@ -904,6 +996,29 @@ class CompletionTests(unittest.TestCase):
904
996
  self.g("tag", "main")
905
997
  self.assert_base_identity_rejected("main", "heads/main")
906
998
 
999
+ def test_allocate_uses_remote_base_with_dirty_foreign_anchor(self):
1000
+ baseline = self.g("rev-parse", "main")
1001
+ (self.work / "product").write_text("remote advance\n", encoding="utf-8")
1002
+ self.g("commit", "-am", "remote advance")
1003
+ self.g("push", "origin", "main")
1004
+ remote = self.g("rev-parse", "main")
1005
+ self.g("switch", "-c", "busy")
1006
+ (self.work / "product").write_text("local advance\n", encoding="utf-8")
1007
+ self.g("commit", "-am", "local advance")
1008
+ ahead = self.g("rev-parse", "HEAD")
1009
+ (self.work / "product").write_text("busy edits\n", encoding="utf-8")
1010
+ (self.work / "untracked").write_bytes(b"owner data")
1011
+ for name, local in (("behind", baseline), ("ahead", ahead)):
1012
+ self.g("update-ref", "refs/heads/main", local)
1013
+ before = self.anchor_state()
1014
+ result = self.allocate(name)
1015
+ self.assertEqual(result["status"], "ALLOCATED")
1016
+ self.assertEqual(json.loads(self.receipt.read_text())["baseline"], remote)
1017
+ self.assertEqual(self.g("rev-parse", "HEAD", work=self.task), remote)
1018
+ self.assertEqual(self.anchor_state(), before)
1019
+ self.assertEqual((self.work / "untracked").read_bytes(), b"owner data")
1020
+ self.assertFalse((self.work / ".git" / "FETCH_HEAD").exists())
1021
+
907
1022
  @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
908
1023
  def test_disposable_scratch_cleans_for_local_only_and_preserves_source(self):
909
1024
  result = self.allocate()
@@ -934,6 +1049,29 @@ class CompletionTests(unittest.TestCase):
934
1049
  child.wait(timeout=5)
935
1050
  self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
936
1051
 
1052
+ def test_linux_writer_scan_continues_after_vanished_fd(self):
1053
+ from unittest.mock import patch
1054
+ process = Path("/proc") / str(os.getpid() + 1)
1055
+ vanished, active = process / "fd/0", process / "fd/1"
1056
+ iterdir, readlink = Path.iterdir, os.readlink
1057
+ def entries(path):
1058
+ if path == Path("/proc"):
1059
+ return iter([process])
1060
+ if path == process / "fd":
1061
+ return iter([vanished, active])
1062
+ return iterdir(path)
1063
+ def target(path, *args, **kwargs):
1064
+ if path == process / "cwd":
1065
+ return str(self.root)
1066
+ if path == vanished:
1067
+ raise FileNotFoundError(path)
1068
+ if path == active:
1069
+ return str(self.work / "product")
1070
+ return readlink(path, *args, **kwargs)
1071
+ with patch.object(sys, "platform", "linux"), patch.object(Path, "iterdir", entries), patch.object(os, "readlink", target):
1072
+ with self.assertRaisesRegex(CompletionError, "active process " + process.name):
1073
+ stopped_writers(self.work)
1074
+
937
1075
  @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
938
1076
  def test_scratch_rejects_redirect_and_git_data_but_does_not_follow_child_links(self):
939
1077
  scratch = Path(self.allocate()["scratch"])
@@ -988,7 +1126,7 @@ class CompletionTests(unittest.TestCase):
988
1126
  scratch = Path(self.allocate()["scratch"])
989
1127
  self.accept()
990
1128
  (scratch / ".git").mkdir()
991
- result, _ = self.cli("complete", "--receipt", self.receipt, "--acceptance", self.acceptance, "--writers-stopped")
1129
+ result, _ = self.cli("complete", "--receipt", self.receipt, "--acceptance", self.acceptance, "--writers-stopped", "--mode", "auto")
992
1130
  self.assertEqual(result["status"], "CLEANUP_PENDING")
993
1131
  self.assertEqual(result["delivery_status"], "COMPLETE")
994
1132
  self.assertEqual(result["scratch_cleanup"]["status"], "RETAINED")
@@ -1111,7 +1249,23 @@ class CompletionTests(unittest.TestCase):
1111
1249
  result, _ = self.complete("--mode", "pr")
1112
1250
  self.assertEqual(result["status"], "PR")
1113
1251
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
1114
- self.assertEqual(json.loads(self.data.read_text(encoding="utf-8"))["pushs"], 1)
1252
+ d = json.loads(self.data.read_text(encoding="utf-8"))
1253
+ self.assertEqual((d["pushs"], d.get("merges", 0), d["pr"]["autoMergeRequest"]), (1, 0, None))
1254
+
1255
+ def test_pr_mode_disables_only_owned_auto_merge(self):
1256
+ self.allocate(); self.accept()
1257
+ self.configure(pending=True)
1258
+ result, _ = self.complete("--mode", "auto")
1259
+ self.assertEqual(result["status"], "PENDING")
1260
+ result, _ = self.complete("--mode", "pr")
1261
+ self.assertEqual(result["status"], "PR")
1262
+ d = json.loads(self.data.read_text(encoding="utf-8"))
1263
+ self.assertEqual((d["disables"], d["pr"]["autoMergeRequest"]), (1, None))
1264
+ self.configure(pr=dict(d["pr"], isCrossRepository=True, autoMergeRequest={"enabledAt": "now"}))
1265
+ _, r = self.complete("--mode", "pr", success=False)
1266
+ self.assertIn("PR head repository differs", json.loads(r.stdout)["reason"])
1267
+ d = json.loads(self.data.read_text(encoding="utf-8"))
1268
+ self.assertEqual((d["disables"], d["pr"]["autoMergeRequest"]), (1, {"enabledAt": "now"}))
1115
1269
 
1116
1270
  def test_remote_rewrite_after_allocation_is_rejected(self):
1117
1271
  self.allocate(); self.accept()
@@ -1158,28 +1312,373 @@ class CompletionTests(unittest.TestCase):
1158
1312
  d = json.loads(self.data.read_text(encoding="utf-8"))
1159
1313
  self.assertEqual((d["creates"], d["merges"], d["pushs"]), (1, 1, 1))
1160
1314
 
1161
- def test_in_place_retains_ignored_data(self):
1162
- self.allocate(); self.accept()
1315
+ def test_legacy_in_place_retains_checked_out_branch_then_retires_refs(self):
1316
+ self.allocate_legacy(); self.accept()
1163
1317
  (self.task / "ignored").mkdir()
1164
1318
  (self.task / "ignored" / "user.txt").write_text("retain me", encoding="utf-8")
1319
+ self.complete("--mode", "pr", "--writers-stopped")
1320
+ self.merge_pr()
1321
+ before = self.anchor_state()
1322
+ receipt, task = self.receipt, self.task
1323
+ result = self.allocate("next")
1324
+ self.assertEqual(result["reconciled"][0]["status"], "RETAINED")
1325
+ self.assertIn(str(task), result["reconciled"][0]["reason"])
1326
+ self.assertIn("switch", result["reconciled"][0]["reason"])
1327
+ self.assertEqual(self.anchor_state(), before)
1328
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1329
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
1330
+ self.g("switch", "main")
1331
+ (self.work / "product").write_text("owner edits\n", encoding="utf-8")
1332
+ (self.work / "untracked").write_bytes(b"owner data")
1333
+ before = self.anchor_state()
1334
+ result = self.allocate("retired")
1335
+ self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
1336
+ self.assertEqual(self.anchor_state(), before)
1337
+ self.assertEqual((self.work / "untracked").read_bytes(), b"owner data")
1338
+ self.assertEqual((task / "ignored/user.txt").read_text(encoding="utf-8"), "retain me")
1339
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
1340
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
1341
+ self.receipt, self.task = receipt, task
1342
+ again, _ = self.complete(acceptance=False)
1343
+ self.assertEqual(again["status"], "COMPLETE")
1344
+
1345
+ def test_pr_mode_cleans_after_human_merge(self):
1346
+ self.allocate(); self.accept()
1347
+ self.complete("--mode", "pr")
1348
+ self.merge_pr()
1349
+ before = self.data.read_bytes()
1350
+ result, _ = self.complete("--mode", "pr", "--writers-stopped")
1351
+ self.assertEqual(result["status"], "COMPLETE")
1352
+ self.assertFalse(self.task.exists())
1353
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
1354
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
1355
+ saved, current = json.loads(before), json.loads(self.data.read_bytes())
1356
+ self.assertEqual((current["merges"], current.get("disables", 0)), (saved["merges"], saved.get("disables", 0)))
1357
+
1358
+ @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
1359
+ def test_nested_repository_or_worktree_retains_tree(self):
1360
+ self.allocate(); self.accept()
1361
+ _, r = self.cli("allocate", "--repo", self.work, "--task", "inner", "--branch", "task/inner", "--repository", "test/project",
1362
+ "--base", "main", "--worktree", self.task / "ignored" / "inner", success=False)
1363
+ self.assertIn("disjoint from every registered worktree", json.loads(r.stdout)["reason"])
1364
+ nested = self.task / "ignored" / "nested"
1365
+ self.g("worktree", "add", "-b", "nested/work", str(nested), "main")
1366
+ (nested / "product").write_text("unsaved nested work\n", encoding="utf-8")
1367
+ clone = self.task / "ignored" / "clone"
1368
+ self.run_cmd(["git", "init", "--initial-branch=main", str(clone)])
1369
+ for remove in (lambda: self.g("worktree", "remove", "--force", str(nested)), lambda: shutil.rmtree(clone)):
1370
+ _, r = self.complete("--mode", "auto", "--writers-stopped", success=False)
1371
+ self.assertIn("nested repository or worktree", json.loads(r.stdout)["reason"])
1372
+ self.assertTrue(self.task.exists())
1373
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1374
+ if nested.exists():
1375
+ self.assertEqual((nested / "product").read_text(encoding="utf-8"), "unsaved nested work\n")
1376
+ remove()
1377
+ result, _ = self.complete("--mode", "auto", "--writers-stopped")
1378
+ self.assertEqual(result["status"], "COMPLETE")
1379
+ self.assertFalse(self.task.exists())
1380
+
1381
+ @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
1382
+ def test_default_mode_merges_when_possible_and_cleans(self):
1383
+ self.allocate(); self.accept()
1165
1384
  result, _ = self.complete("--writers-stopped")
1166
1385
  self.assertEqual(result["status"], "COMPLETE")
1167
- self.assertEqual(self.g("branch", "--show-current"), "main")
1168
- self.assertEqual((self.task / "ignored/user.txt").read_text(encoding="utf-8"), "retain me")
1386
+ self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("merges"), 1)
1387
+ self.assertFalse(self.task.exists())
1388
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
1389
+
1390
+ def test_release_is_refused_from_inside_the_task_tree(self):
1391
+ self.allocate(); self.accept()
1392
+ _, r = self.complete("--mode", "pr", "--writers-stopped", cwd=self.task, success=False)
1393
+ self.assertIn("caller cwd is inside", json.loads(r.stdout)["reason"])
1394
+ self.assertNotIn("writers_released", json.loads(self.receipt.read_text(encoding="utf-8")))
1395
+
1396
+ @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
1397
+ def test_squash_merge_cleans_and_recovery_ref_keeps_commit(self):
1398
+ self.allocate(); self.accept()
1399
+ result, _ = self.complete("--mode", "pr", "--writers-stopped")
1400
+ self.assertEqual(result["status"], "PR")
1401
+ bare = ["git", "--git-dir", str(self.bare)]
1402
+ base = self.run_cmd(bare + ["rev-parse", "refs/heads/main"]).stdout.strip()
1403
+ tree = self.run_cmd(bare + ["rev-parse", self.sha + "^{tree}"]).stdout.strip()
1404
+ squash = self.run_cmd(bare + ["commit-tree", tree, "-p", base, "-m", "squash fixture"]).stdout.strip()
1405
+ self.run_cmd(bare + ["update-ref", "refs/heads/main", squash, base])
1406
+ pr = json.loads(self.data.read_text(encoding="utf-8"))["pr"]
1407
+ self.configure(pr=dict(pr, state="MERGED", mergedAt="now", mergeCommit={"oid": squash}))
1408
+ result, _ = self.complete()
1409
+ self.assertEqual(result["status"], "COMPLETE")
1410
+ self.assertFalse(self.task.exists())
1169
1411
  self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
1170
1412
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
1171
- again, _ = self.complete("--writers-stopped", acceptance=False)
1172
- self.assertEqual(again["status"], "COMPLETE")
1413
+ self.assertEqual(self.g("rev-parse", json.loads(self.receipt.read_text(encoding="utf-8"))["recovery_ref"]), self.sha)
1414
+ self.assertFalse((self.work / ".git" / "FETCH_HEAD").exists())
1415
+
1416
+ def test_linked_ignored_output_disposed_and_records_preserved(self):
1417
+ self.allocate(); self.accept()
1418
+ for relative, raw in (("node_modules/x", b"build"), (".env", b"SECRET=value\n"), (".devlyn/runs/extra/log", b"extra\x00bytes\n")):
1419
+ path = self.task / relative
1420
+ path.parent.mkdir(parents=True, exist_ok=True)
1421
+ path.write_bytes(raw)
1422
+ path.chmod(0o640)
1423
+ records = snapshot_files(self.task, [".devlyn"])
1424
+ (self.task / "untracked").write_bytes(b"owner data")
1425
+ _, process = self.complete("--mode", "auto", "--writers-stopped", success=False)
1426
+ self.assertNotEqual(process.returncode, 0)
1427
+ self.assertTrue(self.task.exists())
1428
+ self.assertEqual((self.task / "untracked").read_bytes(), b"owner data")
1429
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1430
+ (self.task / "untracked").unlink()
1431
+ result, _ = self.complete("--mode", "auto", "--writers-stopped")
1432
+ self.assertEqual(result["status"], "COMPLETE")
1433
+ self.assertFalse(self.task.exists())
1434
+ verify_files(self.receipt.parent / "records", records)
1435
+ self.assertEqual(snapshot_files(self.receipt.parent / "records", [".devlyn"]), records)
1436
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
1437
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
1438
+
1439
+ def test_reconcile_released_merge_and_retains_without_release(self):
1440
+ self.allocate(); self.accept()
1441
+ result, _ = self.complete("--mode", "pr")
1442
+ self.assertEqual(result["status"], "PR")
1443
+ receipt, task = self.receipt, self.task
1444
+ self.merge_pr()
1445
+ result = self.allocate("unreleased")
1446
+ self.assertEqual(len(result["reconciled"]), 1)
1447
+ self.assertEqual(result["reconciled"][0]["status"], "RETAINED")
1448
+ self.assertIn("writers", result["reconciled"][0]["reason"])
1449
+ self.assertTrue(task.exists())
1450
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1451
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
1452
+ # Reopen only the fixture PR to exercise the owner's pre-merge release.
1453
+ d = json.loads(self.data.read_text())
1454
+ self.configure(pr=dict(d["pr"], state="OPEN", mergedAt=None, mergeCommit=None))
1455
+ self.receipt, self.task = receipt, task
1456
+ result, _ = self.complete("--mode", "pr", "--writers-stopped")
1457
+ self.assertEqual(result["status"], "PR")
1458
+ self.assertTrue(json.loads(receipt.read_text())["writers_released"])
1459
+ self.merge_pr()
1460
+ server = json.loads(self.data.read_text())
1461
+ result = self.allocate("released")
1462
+ self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
1463
+ self.assertFalse(task.exists())
1464
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
1465
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
1466
+ current = json.loads(self.data.read_text())
1467
+ self.assertEqual((current["pushs"], current["creates"], current["merges"], current.get("disables", 0)),
1468
+ (server["pushs"], server["creates"], server["merges"], server.get("disables", 0)))
1469
+
1470
+ def test_reconcile_checks_repo_policy_before_cleanup(self):
1471
+ self.allocate(); self.accept()
1472
+ self.complete("--mode", "pr", "--writers-stopped")
1473
+ self.merge_pr()
1474
+ receipt, task = self.receipt, self.task
1475
+ self.configure(default_branch="changed")
1476
+ result = self.allocate("next")
1477
+ self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "RETAINED")])
1478
+ self.assertIn("base/default branch changed", result["reconciled"][0]["reason"])
1479
+ self.assertTrue(task.exists())
1480
+ self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
1481
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
1482
+
1483
+ def test_reconcile_skips_open_and_unaccepted_and_retains_in_use_tree(self):
1484
+ self.allocate(); self.accept()
1485
+ self.complete("--mode", "pr", "--writers-stopped")
1486
+ receipt, task = self.receipt, self.task
1487
+ before = receipt.read_bytes()
1488
+ result = self.allocate("unaccepted")
1489
+ self.assertEqual(result["reconciled"], [])
1490
+ self.assertEqual(receipt.read_bytes(), before)
1491
+ unaccepted, unaccepted_tree = self.receipt, self.task
1492
+ saved = json.loads(unaccepted.read_text())
1493
+ saved.update(pr_number=1, writers_released=True)
1494
+ unaccepted.write_text(json.dumps(saved))
1495
+ unaccepted_before = unaccepted.read_bytes()
1496
+ pr = json.loads(self.data.read_text())["pr"]
1497
+ self.configure(pr=dict(pr, state="CLOSED"))
1498
+ result = self.allocate("closed")
1499
+ self.assertEqual(result["reconciled"], [])
1500
+ self.assertEqual(receipt.read_bytes(), before)
1501
+ self.assertTrue(task.exists())
1502
+ self.configure(pr=pr)
1503
+ self.merge_pr()
1504
+ self.g("worktree", "lock", str(task))
1505
+ result = self.allocate("locked")
1506
+ self.assertEqual(len(result["reconciled"]), 1)
1507
+ self.assertEqual(result["reconciled"][0]["status"], "RETAINED")
1508
+ self.assertIn("locked", result["reconciled"][0]["reason"])
1509
+ self.assertTrue(task.exists())
1510
+ self.g("worktree", "unlock", str(task))
1511
+ child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], cwd=task)
1512
+ try:
1513
+ result = self.allocate("active")
1514
+ self.assertEqual(len(result["reconciled"]), 1)
1515
+ self.assertEqual(result["reconciled"][0]["status"], "RETAINED")
1516
+ self.assertIn("active process", result["reconciled"][0]["reason"])
1517
+ self.assertTrue(task.exists())
1518
+ self.assertIsNone(child.poll())
1519
+ finally:
1520
+ child.terminate(); child.wait(timeout=5)
1521
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1522
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
1523
+ self.assertEqual(unaccepted.read_bytes(), unaccepted_before)
1524
+ self.assertTrue(unaccepted_tree.exists())
1525
+
1526
+ def test_reconcile_already_retired_tree_and_branch(self):
1527
+ self.allocate(); self.accept()
1528
+ self.complete("--mode", "pr", "--writers-stopped")
1529
+ self.merge_pr()
1530
+ receipt, task = self.receipt, self.task
1531
+ self.g("worktree", "remove", str(task))
1532
+ self.g("update-ref", "-d", "refs/heads/task/fixture", self.sha)
1533
+ before = self.anchor_state()
1534
+ result = self.allocate("next")
1535
+ self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
1536
+ self.assertFalse(task.exists())
1537
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
1538
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
1539
+ self.assertEqual(self.anchor_state(), before)
1540
+
1541
+ def test_reconcile_never_removes_allocation_anchor(self):
1542
+ self.allocate(); self.accept()
1543
+ self.complete("--mode", "pr", "--writers-stopped")
1544
+ self.merge_pr()
1545
+ before = self.anchor_state(self.task)
1546
+ result, _ = self.cli("allocate", "--repo", self.task, "--task", "next", "--branch", "task/next",
1547
+ "--repository", "test/project", "--base", "main", "--worktree", self.root / "next")
1548
+ self.assertEqual(result["status"], "ALLOCATED")
1549
+ self.assertEqual(len(result["reconciled"]), 1)
1550
+ self.assertEqual(result["reconciled"][0]["status"], "RETAINED")
1551
+ self.assertIn("anchor", result["reconciled"][0]["reason"])
1552
+ self.assertEqual(self.anchor_state(self.task), before)
1553
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1554
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
1555
+
1556
+ def test_reconcile_reports_scratch_cleanup_pending(self):
1557
+ scratch = Path(self.allocate()["scratch"])
1558
+ self.accept()
1559
+ self.complete("--mode", "pr", "--writers-stopped")
1560
+ self.merge_pr()
1561
+ receipt, task = self.receipt, self.task
1562
+ (scratch / "object").write_bytes(b"rebuildable")
1563
+ child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], cwd=scratch)
1564
+ try:
1565
+ result = self.allocate("next")
1566
+ self.assertEqual(len(result["reconciled"]), 1)
1567
+ row = result["reconciled"][0]
1568
+ self.assertEqual((row["status"], row["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
1569
+ self.assertEqual(row["scratch_cleanup"]["status"], "RETAINED")
1570
+ self.assertIn("active process", row["scratch_cleanup"]["reason"])
1571
+ self.assertEqual((scratch / "object").read_bytes(), b"rebuildable")
1572
+ self.assertIsNone(child.poll())
1573
+ finally:
1574
+ child.terminate(); child.wait(timeout=5)
1575
+ self.assertFalse(task.exists())
1576
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
1577
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
1578
+ self.configure(default_branch="changed")
1579
+ server = json.loads(self.data.read_text())
1580
+ result = self.allocate("retry")
1581
+ self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
1582
+ self.assertFalse(any(scratch.iterdir()))
1583
+ self.assertEqual(json.loads(self.data.read_text()), server)
1584
+ self.assertEqual(self.allocate("later")["reconciled"], [])
1585
+
1586
+ def test_reconcile_retries_unattempted_scratch_cleanup(self):
1587
+ scratch = Path(self.allocate()["scratch"])
1588
+ self.accept()
1589
+ self.complete("--mode", "auto", "--writers-stopped")
1590
+ receipt = self.receipt
1591
+ saved = json.loads(receipt.read_text())
1592
+ del saved["scratch_cleanup"]
1593
+ receipt.write_text(json.dumps(saved))
1594
+ (scratch / "object").write_bytes(b"rebuildable")
1595
+ result = self.allocate("next")
1596
+ self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
1597
+ self.assertFalse(any(scratch.iterdir()))
1598
+
1599
+ def test_reconcile_retains_busy_receipt_without_waiting(self):
1600
+ self.allocate(); self.accept()
1601
+ self.complete("--mode", "pr", "--writers-stopped")
1602
+ self.merge_pr()
1603
+ code = "import pathlib, runpy, sys, time\nwith runpy.run_path(sys.argv[1])['file_lock'](pathlib.Path(sys.argv[2]), blocking=True):\n print('locked', flush=True)\n time.sleep(30)\n"
1604
+ child = subprocess.Popen([sys.executable, "-c", code, str(Path(__file__).resolve().with_name("platform-support.py")),
1605
+ str(self.receipt.parent / "lock")], stdout=subprocess.PIPE, text=True)
1606
+ try:
1607
+ self.assertEqual(child.stdout.readline().strip(), "locked")
1608
+ process = subprocess.run([sys.executable, str(Path(__file__).resolve()), "allocate", "--repo", str(self.work),
1609
+ "--task", "next", "--branch", "task/next", "--repository", "test/project", "--base", "main",
1610
+ "--worktree", str(self.root / "next")], cwd=self.root, env=self.env, capture_output=True, text=True, timeout=10)
1611
+ self.assertEqual(process.returncode, 0, process.stdout+process.stderr)
1612
+ result = json.loads(process.stdout)
1613
+ self.assertEqual(result["status"], "ALLOCATED")
1614
+ self.assertEqual(len(result["reconciled"]), 1)
1615
+ self.assertEqual(result["reconciled"][0]["status"], "RETAINED")
1616
+ self.assertIn("lock", result["reconciled"][0]["reason"])
1617
+ self.assertTrue(self.task.exists())
1618
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1619
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
1620
+ self.assertIsNone(child.poll())
1621
+ finally:
1622
+ child.terminate(); child.wait(timeout=5)
1623
+ child.stdout.close()
1624
+
1625
+ def test_reconcile_retains_malformed_receipt_without_failing_allocation(self):
1626
+ self.allocate()
1627
+ receipt, task = self.receipt, self.task
1628
+ receipt.write_text("[]", encoding="utf-8")
1629
+ result = self.allocate("next")
1630
+ self.assertEqual(result["status"], "ALLOCATED")
1631
+ self.assertTrue(self.task.exists())
1632
+ self.assertEqual(len(result["reconciled"]), 1)
1633
+ self.assertEqual(result["reconciled"][0]["status"], "RETAINED")
1634
+ self.assertEqual(result["reconciled"][0]["receipt"], str(receipt))
1635
+ self.assertTrue(result["reconciled"][0]["reason"])
1636
+ self.assertEqual(receipt.read_text(), "[]")
1637
+ self.assertTrue(task.exists())
1638
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1639
+
1640
+ def test_records_refuse_symlink_nonregular_and_tampered_resume(self):
1641
+ self.allocate(); self.accept()
1642
+ self.complete("--mode", "pr", "--writers-stopped")
1643
+ self.merge_pr()
1644
+ other = self.root / "owner-file"
1645
+ other.write_bytes(b"owner data")
1646
+ link = self.task / ".devlyn/link"
1647
+ link.symlink_to(other)
1648
+ result, _ = self.complete(success=False)
1649
+ self.assertIn("path escapes custody", result["reason"])
1650
+ self.assertTrue(self.task.exists())
1651
+ self.assertTrue(link.is_symlink())
1652
+ self.assertEqual(other.read_bytes(), b"owner data")
1653
+ link.unlink()
1654
+ if hasattr(os, "mkfifo"):
1655
+ os.mkfifo(link)
1656
+ result, _ = self.complete(success=False)
1657
+ self.assertIn("regular file", result["reason"])
1658
+ self.assertTrue(self.task.exists())
1659
+ link.unlink()
1660
+ saved = self.receipt.parent / "records/.devlyn"
1661
+ shutil.copytree(self.task / ".devlyn", saved)
1662
+ (saved / "checks.txt").write_bytes(b"tampered copy")
1663
+ result, _ = self.complete(success=False)
1664
+ self.assertIn("bytes/mode changed", result["reason"])
1665
+ self.assertEqual((saved / "checks.txt").read_bytes(), b"tampered copy")
1666
+ self.assertTrue(self.task.exists())
1667
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1668
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
1669
+ shutil.copy2(self.task / ".devlyn/checks.txt", saved / "checks.txt")
1670
+ result, _ = self.complete()
1671
+ self.assertEqual(result["status"], "COMPLETE")
1672
+ self.assertFalse(self.task.exists())
1173
1673
 
1174
1674
  def test_linked_custody_and_interrupted_removal(self):
1175
- self.allocate(linked=True); self.accept(pipeline=True)
1176
- # Direct evidence unrelated to the pipeline is unknown ignored content.
1177
- (self.task / ".devlyn/checks.txt").unlink()
1675
+ self.allocate(); self.accept(pipeline=True)
1178
1676
  self.configure(interrupt_remove=True)
1179
- result, r = self.complete("--writers-stopped", success=False)
1677
+ result, r = self.complete("--writers-stopped", "--mode", "auto", success=False)
1180
1678
  self.assertNotEqual(r.returncode, 0)
1181
1679
  self.assertFalse(self.task.exists())
1182
1680
  saved = json.loads(self.receipt.read_text(encoding="utf-8"))
1681
+ self.assertNotIn("cleanup_started", saved)
1183
1682
  recovered = self.receipt.parent / "custody" / ".devlyn/runs/fixture-run/final-report.md"
1184
1683
  self.assertEqual(hashlib.sha256(recovered.read_bytes()).hexdigest(), saved["files"][".devlyn/runs/fixture-run/final-report.md"]["sha256"])
1185
1684
  result, _ = self.complete("--writers-stopped", acceptance=False)
@@ -1224,33 +1723,35 @@ class CompletionTests(unittest.TestCase):
1224
1723
 
1225
1724
  def test_interrupted_external_effects(self):
1226
1725
  self.allocate(); self.accept()
1227
- for effect in ("push", "create", "merge"):
1726
+ for effect in ("push", "create"):
1228
1727
  self.configure(**{"interrupt_"+effect: True})
1229
- _, r = self.complete("--writers-stopped", success=False)
1728
+ _, r = self.complete("--writers-stopped", "--mode", "auto", success=False)
1230
1729
  self.assertNotEqual(r.returncode, 0)
1730
+ # A merge command that fails after merging is judged by the PR state, not its exit code.
1731
+ self.configure(interrupt_merge=True)
1231
1732
  result, _ = self.complete("--writers-stopped")
1232
1733
  self.assertEqual(result["status"], "COMPLETE")
1233
1734
  d = json.loads(self.data.read_text(encoding="utf-8"))
1234
1735
  self.assertEqual((d["pushs"], d["creates"], d["merges"]), (1, 1, 1))
1235
1736
 
1236
- def test_retains_unknown_dirty_locked_and_active_tree(self):
1237
- self.allocate(linked=True); self.accept()
1238
- for case in ("ignored", "dirty", "untracked", "locked", "cwd", "no-yield"):
1239
- path = self.task / ("ignored/unknown" if case == "ignored" else "product" if case == "dirty" else "unknown")
1240
- if case in {"ignored", "dirty", "untracked"}:
1737
+ def test_retains_dirty_locked_and_active_tree(self):
1738
+ self.allocate(); self.accept()
1739
+ for case in ("no-yield", "dirty", "untracked", "locked", "cwd"):
1740
+ path = self.task / ("product" if case == "dirty" else "unknown")
1741
+ if case in {"dirty", "untracked"}:
1241
1742
  path.parent.mkdir(exist_ok=True); path.write_text("do not delete", encoding="utf-8")
1242
1743
  if case == "locked": self.g("worktree", "lock", str(self.task))
1243
1744
  with self.subTest(case=case):
1244
- _, r = self.complete(*([] if case == "no-yield" else ["--writers-stopped"]), cwd=self.task if case == "cwd" else None, success=False)
1745
+ _, r = self.complete("--mode", "auto", *([] if case == "no-yield" else ["--writers-stopped"]), cwd=self.task if case == "cwd" else None, success=False)
1245
1746
  self.assertNotEqual(r.returncode, 0)
1246
1747
  self.assertTrue(self.task.exists())
1247
1748
  self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1248
- if case in {"ignored", "untracked"}: path.unlink()
1749
+ if case == "untracked": path.unlink()
1249
1750
  if case == "dirty": path.write_text("accepted\n", encoding="utf-8")
1250
1751
  if case == "locked": self.g("worktree", "unlock", str(self.task))
1251
1752
 
1252
1753
  def test_no_adoption_and_remote_head_race(self):
1253
- _, r = self.cli("allocate", "--repo", self.work, "--task", "foreign", "--branch", "main", "--repository", "test/project", "--base", "main", success=False)
1754
+ _, r = self.cli("allocate", "--repo", self.work, "--task", "foreign", "--branch", "main", "--repository", "test/project", "--base", "main", "--worktree", self.root / "foreign", success=False)
1254
1755
  self.assertNotEqual(r.returncode, 0)
1255
1756
  self.allocate(); self.accept()
1256
1757
  self.complete("--mode", "pr")
@@ -1261,7 +1762,7 @@ class CompletionTests(unittest.TestCase):
1261
1762
 
1262
1763
  def test_concurrent_completions(self):
1263
1764
  self.allocate(); self.accept(); self.configure(pending=True)
1264
- args = [sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(self.receipt), "--acceptance", str(self.acceptance)]
1765
+ args = [sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(self.receipt), "--acceptance", str(self.acceptance), "--mode", "auto"]
1265
1766
  procs = [subprocess.Popen(args, cwd=self.root, env=self.env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8") for _ in range(2)]
1266
1767
  for proc in procs:
1267
1768
  out, err = proc.communicate(timeout=30)
@@ -1270,7 +1771,7 @@ class CompletionTests(unittest.TestCase):
1270
1771
  self.assertEqual((d["pushs"], d["creates"], d["merges"]), (1,1,1))
1271
1772
 
1272
1773
  def test_custody_failure_and_evidence_tamper(self):
1273
- self.allocate(linked=True); self.accept()
1774
+ self.allocate(); self.accept()
1274
1775
  bad = self.receipt.parent / "custody/.devlyn/checks.txt"
1275
1776
  bad.parent.mkdir(parents=True)
1276
1777
  bad.write_text("partial interrupted copy", encoding="utf-8")
@@ -1287,10 +1788,10 @@ class CompletionTests(unittest.TestCase):
1287
1788
  self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("merges",0), 0)
1288
1789
 
1289
1790
  def test_actual_foreign_writer_and_registration(self):
1290
- self.allocate(linked=True); self.accept()
1791
+ self.allocate(); self.accept()
1291
1792
  child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], cwd=self.task)
1292
1793
  try:
1293
- _, r = self.complete("--writers-stopped", success=False)
1794
+ _, r = self.complete("--writers-stopped", "--mode", "auto", success=False)
1294
1795
  self.assertNotEqual(r.returncode, 0)
1295
1796
  self.assertTrue(self.task.exists())
1296
1797
  self.assertIn("active process", json.loads(r.stdout)["reason"])
@@ -1307,7 +1808,7 @@ class CompletionTests(unittest.TestCase):
1307
1808
  self.allocate(); self.accept()
1308
1809
  race = self.g("rev-parse", "main")
1309
1810
  self.configure(remote_delete_race=True, race_sha=race)
1310
- _, r = self.complete("--writers-stopped", success=False)
1811
+ _, r = self.complete("--writers-stopped", "--mode", "auto", success=False)
1311
1812
  self.assertNotEqual(r.returncode, 0)
1312
1813
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], race)
1313
1814
  self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
@@ -1315,16 +1816,19 @@ class CompletionTests(unittest.TestCase):
1315
1816
  def test_policy_and_local_only_persist(self):
1316
1817
  self.allocate(); self.accept()
1317
1818
  self.configure(merge_allowed=False)
1318
- _, r = self.complete(success=False)
1319
- self.assertNotEqual(r.returncode, 0)
1320
- self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
1819
+ result, _ = self.complete()
1820
+ self.assertEqual((result["status"], result["merge_refused"]), ("PR", "repository disallows merge commits"))
1821
+ d = json.loads(self.data.read_text(encoding="utf-8"))
1822
+ self.assertEqual((d.get("pushs", 0), d.get("merges", 0)), (1, 0))
1321
1823
  self.complete("--mode", "pr")
1322
1824
  result, _ = self.complete()
1323
1825
  self.assertEqual(result["status"], "PR")
1826
+ self.assertNotIn("merge_refused", result)
1324
1827
  self.configure(merge_allowed=True, auto_allowed=False, pending=True)
1325
- _, r = self.complete("--mode", "auto", success=False)
1326
- self.assertNotEqual(r.returncode, 0)
1327
- self.assertIn("disallows auto merge", json.loads(r.stdout)["reason"])
1828
+ result, _ = self.complete("--mode", "auto")
1829
+ self.assertEqual(result["status"], "PR")
1830
+ self.assertIn("disallows auto merge", result["merge_refused"])
1831
+ self.assertTrue(self.task.exists())
1328
1832
  result, _ = self.complete("--no-push")
1329
1833
  self.assertEqual(result["status"], "LOCAL_ONLY")
1330
1834
  result, _ = self.complete(acceptance=False)
@@ -1365,15 +1869,19 @@ class CompletionTests(unittest.TestCase):
1365
1869
  result, _ = self.complete("--mode", "pr")
1366
1870
  self.assertEqual(result["status"], "PR")
1367
1871
 
1368
- def test_in_place_resume_does_not_overwrite_ignored_collision(self):
1369
- self.allocate(); self.accept()
1872
+ def test_legacy_in_place_resume_does_not_overwrite_ignored_collision(self):
1873
+ self.allocate_legacy(); self.accept()
1874
+ self.complete("--mode", "pr", "--writers-stopped")
1875
+ self.merge_pr()
1876
+ self.g("switch", "main")
1370
1877
  user_file = self.task / "ignored/user.txt"
1371
1878
  user_file.parent.mkdir()
1372
1879
  user_file.write_text("retained user data", encoding="utf-8")
1880
+ before = self.anchor_state()
1373
1881
  self.configure(interrupt_delete=True)
1374
- _, r = self.complete("--writers-stopped", success=False)
1882
+ _, r = self.complete(acceptance=False, success=False)
1375
1883
  self.assertNotEqual(r.returncode, 0)
1376
- self.assertEqual(self.g("branch", "--show-current"), "main")
1884
+ self.assertEqual(self.anchor_state(), before)
1377
1885
  other = self.root / "advance"
1378
1886
  self.run_cmd(["git", "clone", str(self.bare), str(other)])
1379
1887
  (other / "ignored").mkdir()
@@ -1381,13 +1889,15 @@ class CompletionTests(unittest.TestCase):
1381
1889
  self.g("add", "-f", "ignored/user.txt", work=other)
1382
1890
  self.g("commit", "-m", "upstream collision", work=other)
1383
1891
  self.g("push", "origin", "main", work=other)
1384
- _, r = self.complete("--writers-stopped", acceptance=False, success=False)
1385
- self.assertNotEqual(r.returncode, 0)
1892
+ result, _ = self.complete(acceptance=False)
1893
+ self.assertEqual(result["status"], "COMPLETE")
1894
+ self.assertEqual(self.anchor_state(), before)
1386
1895
  self.assertEqual(user_file.read_text(encoding="utf-8"), "retained user data")
1387
- self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1896
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
1897
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
1388
1898
 
1389
1899
  def test_replaced_tree_and_unsafe_custody(self):
1390
- self.allocate(linked=True); self.accept()
1900
+ self.allocate(); self.accept()
1391
1901
  elsewhere = self.root / "elsewhere"
1392
1902
  elsewhere.mkdir()
1393
1903
  (self.receipt.parent / "custody").symlink_to(elsewhere, target_is_directory=True)