devlyn-cli 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (424) hide show
  1. package/AGENTS.md +2 -2
  2. package/CLAUDE.md +2 -2
  3. package/README.md +22 -113
  4. package/bin/devlyn.js +13 -225
  5. package/bin/instruction-templates.json +752 -1
  6. package/bin/instructions.js +101 -17
  7. package/config/skills/_shared/adapters/grok.md +1 -2
  8. package/config/skills/_shared/archive_run.py +15 -3
  9. package/config/skills/_shared/judge-role-evidence.py +21 -1
  10. package/config/skills/_shared/runtime-principles.md +1 -1
  11. package/config/skills/_shared/spec-verify-check.py +0 -653
  12. package/config/skills/_shared/state-phase-write.py +371 -30
  13. package/config/skills/_shared/task-complete.py +635 -143
  14. package/config/skills/_shared/terminal-claim-check.py +229 -4
  15. package/config/skills/_shared/verify-merge-findings.py +93 -2
  16. package/config/skills/devlyn:ideate/SKILL.md +3 -4
  17. package/config/skills/devlyn:ideate/references/elicitation.md +2 -35
  18. package/config/skills/devlyn:ideate/references/from-spec-mode.md +3 -18
  19. package/config/skills/devlyn:ideate/references/project-mode.md +1 -16
  20. package/config/skills/devlyn:ideate/references/spec-template.md +0 -2
  21. package/config/skills/devlyn:queue/SKILL.md +6 -4
  22. package/config/skills/devlyn:resolve/SKILL.md +16 -25
  23. package/config/skills/devlyn:resolve/references/free-form-mode.md +0 -14
  24. package/config/skills/devlyn:resolve/references/outer-loop.md +1 -1
  25. package/config/skills/devlyn:resolve/references/phases/build-gate.md +1 -1
  26. package/config/skills/devlyn:resolve/references/phases/cleanup.md +3 -3
  27. package/config/skills/devlyn:resolve/references/phases/implement.md +1 -1
  28. package/config/skills/devlyn:resolve/references/phases/probe-derive.md +1 -16
  29. package/config/skills/devlyn:resolve/references/phases/verify.md +4 -9
  30. package/config/skills/devlyn:resolve/references/state-schema.md +9 -7
  31. package/config/skills/devlyn:resolve/references/task-completion.md +46 -37
  32. package/{config/skills → optional-skills}/code-health-standards/SKILL.md +4 -13
  33. package/{config/skills → optional-skills}/code-review-standards/SKILL.md +4 -12
  34. package/{config/skills → optional-skills}/root-cause-analysis/SKILL.md +4 -12
  35. package/{config/skills → optional-skills}/ui-implementation-standards/SKILL.md +4 -14
  36. package/package.json +1 -57
  37. package/benchmark/auto-resolve/BENCHMARK-DESIGN.md +0 -289
  38. package/benchmark/auto-resolve/BENCHMARK-RESULTS.md +0 -341
  39. package/benchmark/auto-resolve/README.md +0 -696
  40. package/benchmark/auto-resolve/RUBRIC.md +0 -177
  41. package/benchmark/auto-resolve/fixtures/F1-cli-trivial-flag/NOTES.md +0 -34
  42. package/benchmark/auto-resolve/fixtures/F1-cli-trivial-flag/expected.json +0 -68
  43. package/benchmark/auto-resolve/fixtures/F1-cli-trivial-flag/metadata.json +0 -10
  44. package/benchmark/auto-resolve/fixtures/F1-cli-trivial-flag/setup.sh +0 -4
  45. package/benchmark/auto-resolve/fixtures/F1-cli-trivial-flag/spec.md +0 -44
  46. package/benchmark/auto-resolve/fixtures/F1-cli-trivial-flag/task.ko.txt +0 -8
  47. package/benchmark/auto-resolve/fixtures/F1-cli-trivial-flag/task.txt +0 -8
  48. package/benchmark/auto-resolve/fixtures/F10-persist-write-collision/NOTES.md +0 -68
  49. package/benchmark/auto-resolve/fixtures/F10-persist-write-collision/expected.json +0 -60
  50. package/benchmark/auto-resolve/fixtures/F10-persist-write-collision/metadata.json +0 -10
  51. package/benchmark/auto-resolve/fixtures/F10-persist-write-collision/setup.sh +0 -17
  52. package/benchmark/auto-resolve/fixtures/F10-persist-write-collision/spec.md +0 -51
  53. package/benchmark/auto-resolve/fixtures/F10-persist-write-collision/task.txt +0 -9
  54. package/benchmark/auto-resolve/fixtures/F10-persist-write-collision/verifiers/invalid.js +0 -29
  55. package/benchmark/auto-resolve/fixtures/F10-persist-write-collision/verifiers/parallel.js +0 -50
  56. package/benchmark/auto-resolve/fixtures/F11-batch-import-all-or-nothing/NOTES.md +0 -75
  57. package/benchmark/auto-resolve/fixtures/F11-batch-import-all-or-nothing/expected.json +0 -52
  58. package/benchmark/auto-resolve/fixtures/F11-batch-import-all-or-nothing/metadata.json +0 -10
  59. package/benchmark/auto-resolve/fixtures/F11-batch-import-all-or-nothing/setup.sh +0 -171
  60. package/benchmark/auto-resolve/fixtures/F11-batch-import-all-or-nothing/spec.md +0 -50
  61. package/benchmark/auto-resolve/fixtures/F11-batch-import-all-or-nothing/task.txt +0 -9
  62. package/benchmark/auto-resolve/fixtures/F12-webhook-raw-body-signature/NOTES.md +0 -89
  63. package/benchmark/auto-resolve/fixtures/F12-webhook-raw-body-signature/expected.json +0 -74
  64. package/benchmark/auto-resolve/fixtures/F12-webhook-raw-body-signature/metadata.json +0 -10
  65. package/benchmark/auto-resolve/fixtures/F12-webhook-raw-body-signature/setup.sh +0 -251
  66. package/benchmark/auto-resolve/fixtures/F12-webhook-raw-body-signature/spec.md +0 -57
  67. package/benchmark/auto-resolve/fixtures/F12-webhook-raw-body-signature/task.txt +0 -13
  68. package/benchmark/auto-resolve/fixtures/F12-webhook-raw-body-signature/verifiers/replay-malformed-body.js +0 -64
  69. package/benchmark/auto-resolve/fixtures/F15-frozen-diff-race-review/NOTES.md +0 -104
  70. package/benchmark/auto-resolve/fixtures/F15-frozen-diff-race-review/expected.json +0 -46
  71. package/benchmark/auto-resolve/fixtures/F15-frozen-diff-race-review/metadata.json +0 -10
  72. package/benchmark/auto-resolve/fixtures/F15-frozen-diff-race-review/setup.sh +0 -336
  73. package/benchmark/auto-resolve/fixtures/F15-frozen-diff-race-review/spec.md +0 -51
  74. package/benchmark/auto-resolve/fixtures/F15-frozen-diff-race-review/task.txt +0 -9
  75. package/benchmark/auto-resolve/fixtures/F16-cli-quote-tax-rules/NOTES.md +0 -38
  76. package/benchmark/auto-resolve/fixtures/F16-cli-quote-tax-rules/expected.json +0 -64
  77. package/benchmark/auto-resolve/fixtures/F16-cli-quote-tax-rules/metadata.json +0 -10
  78. package/benchmark/auto-resolve/fixtures/F16-cli-quote-tax-rules/setup.sh +0 -32
  79. package/benchmark/auto-resolve/fixtures/F16-cli-quote-tax-rules/spec.md +0 -63
  80. package/benchmark/auto-resolve/fixtures/F16-cli-quote-tax-rules/task.txt +0 -7
  81. package/benchmark/auto-resolve/fixtures/F16-cli-quote-tax-rules/verifiers/exact-success.js +0 -54
  82. package/benchmark/auto-resolve/fixtures/F16-cli-quote-tax-rules/verifiers/no-hardcoded-pricing.js +0 -47
  83. package/benchmark/auto-resolve/fixtures/F16-cli-quote-tax-rules/verifiers/stock-error.js +0 -45
  84. package/benchmark/auto-resolve/fixtures/F2-cli-medium-subcommand/NOTES.md +0 -57
  85. package/benchmark/auto-resolve/fixtures/F2-cli-medium-subcommand/expected-pair-plan-registry.json +0 -170
  86. package/benchmark/auto-resolve/fixtures/F2-cli-medium-subcommand/expected.json +0 -84
  87. package/benchmark/auto-resolve/fixtures/F2-cli-medium-subcommand/metadata.json +0 -21
  88. package/benchmark/auto-resolve/fixtures/F2-cli-medium-subcommand/pair-plan.sample-fail.json +0 -214
  89. package/benchmark/auto-resolve/fixtures/F2-cli-medium-subcommand/pair-plan.sample-pass.json +0 -223
  90. package/benchmark/auto-resolve/fixtures/F2-cli-medium-subcommand/setup.sh +0 -5
  91. package/benchmark/auto-resolve/fixtures/F2-cli-medium-subcommand/spec.md +0 -55
  92. package/benchmark/auto-resolve/fixtures/F2-cli-medium-subcommand/task.txt +0 -14
  93. package/benchmark/auto-resolve/fixtures/F21-cli-scheduler-priority/NOTES.md +0 -39
  94. package/benchmark/auto-resolve/fixtures/F21-cli-scheduler-priority/expected.json +0 -62
  95. package/benchmark/auto-resolve/fixtures/F21-cli-scheduler-priority/metadata.json +0 -10
  96. package/benchmark/auto-resolve/fixtures/F21-cli-scheduler-priority/setup.sh +0 -2
  97. package/benchmark/auto-resolve/fixtures/F21-cli-scheduler-priority/spec.md +0 -67
  98. package/benchmark/auto-resolve/fixtures/F21-cli-scheduler-priority/task.txt +0 -7
  99. package/benchmark/auto-resolve/fixtures/F21-cli-scheduler-priority/verifiers/error-order.js +0 -55
  100. package/benchmark/auto-resolve/fixtures/F21-cli-scheduler-priority/verifiers/priority-blocked.js +0 -48
  101. package/benchmark/auto-resolve/fixtures/F22-cli-ledger-close/NOTES.md +0 -35
  102. package/benchmark/auto-resolve/fixtures/F22-cli-ledger-close/expected.json +0 -56
  103. package/benchmark/auto-resolve/fixtures/F22-cli-ledger-close/metadata.json +0 -10
  104. package/benchmark/auto-resolve/fixtures/F22-cli-ledger-close/setup.sh +0 -2
  105. package/benchmark/auto-resolve/fixtures/F22-cli-ledger-close/spec.md +0 -64
  106. package/benchmark/auto-resolve/fixtures/F22-cli-ledger-close/task.txt +0 -7
  107. package/benchmark/auto-resolve/fixtures/F22-cli-ledger-close/verifiers/conflicting-duplicate.js +0 -34
  108. package/benchmark/auto-resolve/fixtures/F22-cli-ledger-close/verifiers/idempotent-close.js +0 -41
  109. package/benchmark/auto-resolve/fixtures/F23-cli-fulfillment-wave/NOTES.md +0 -39
  110. package/benchmark/auto-resolve/fixtures/F23-cli-fulfillment-wave/expected.json +0 -56
  111. package/benchmark/auto-resolve/fixtures/F23-cli-fulfillment-wave/metadata.json +0 -10
  112. package/benchmark/auto-resolve/fixtures/F23-cli-fulfillment-wave/setup.sh +0 -2
  113. package/benchmark/auto-resolve/fixtures/F23-cli-fulfillment-wave/spec.md +0 -76
  114. package/benchmark/auto-resolve/fixtures/F23-cli-fulfillment-wave/task.txt +0 -7
  115. package/benchmark/auto-resolve/fixtures/F23-cli-fulfillment-wave/verifiers/priority-rollback.js +0 -64
  116. package/benchmark/auto-resolve/fixtures/F23-cli-fulfillment-wave/verifiers/single-warehouse-fefo.js +0 -66
  117. package/benchmark/auto-resolve/fixtures/F25-cli-cart-promotion-rules/NOTES.md +0 -40
  118. package/benchmark/auto-resolve/fixtures/F25-cli-cart-promotion-rules/expected.json +0 -66
  119. package/benchmark/auto-resolve/fixtures/F25-cli-cart-promotion-rules/metadata.json +0 -10
  120. package/benchmark/auto-resolve/fixtures/F25-cli-cart-promotion-rules/setup.sh +0 -36
  121. package/benchmark/auto-resolve/fixtures/F25-cli-cart-promotion-rules/spec.md +0 -71
  122. package/benchmark/auto-resolve/fixtures/F25-cli-cart-promotion-rules/task.txt +0 -7
  123. package/benchmark/auto-resolve/fixtures/F25-cli-cart-promotion-rules/verifiers/catalog-source.js +0 -57
  124. package/benchmark/auto-resolve/fixtures/F25-cli-cart-promotion-rules/verifiers/exact-success.js +0 -63
  125. package/benchmark/auto-resolve/fixtures/F25-cli-cart-promotion-rules/verifiers/stock-error.js +0 -34
  126. package/benchmark/auto-resolve/fixtures/F26-cli-payout-ledger-rules/NOTES.md +0 -31
  127. package/benchmark/auto-resolve/fixtures/F26-cli-payout-ledger-rules/expected.json +0 -68
  128. package/benchmark/auto-resolve/fixtures/F26-cli-payout-ledger-rules/metadata.json +0 -10
  129. package/benchmark/auto-resolve/fixtures/F26-cli-payout-ledger-rules/setup.sh +0 -17
  130. package/benchmark/auto-resolve/fixtures/F26-cli-payout-ledger-rules/spec.md +0 -68
  131. package/benchmark/auto-resolve/fixtures/F26-cli-payout-ledger-rules/task.txt +0 -7
  132. package/benchmark/auto-resolve/fixtures/F26-cli-payout-ledger-rules/verifiers/conflicting-duplicate.js +0 -29
  133. package/benchmark/auto-resolve/fixtures/F26-cli-payout-ledger-rules/verifiers/exact-payout.js +0 -58
  134. package/benchmark/auto-resolve/fixtures/F26-cli-payout-ledger-rules/verifiers/rules-source.js +0 -56
  135. package/benchmark/auto-resolve/fixtures/F3-backend-contract-risk/NOTES.md +0 -35
  136. package/benchmark/auto-resolve/fixtures/F3-backend-contract-risk/expected-pair-plan-registry.json +0 -162
  137. package/benchmark/auto-resolve/fixtures/F3-backend-contract-risk/expected.json +0 -67
  138. package/benchmark/auto-resolve/fixtures/F3-backend-contract-risk/metadata.json +0 -19
  139. package/benchmark/auto-resolve/fixtures/F3-backend-contract-risk/setup.sh +0 -4
  140. package/benchmark/auto-resolve/fixtures/F3-backend-contract-risk/spec.md +0 -55
  141. package/benchmark/auto-resolve/fixtures/F3-backend-contract-risk/task.txt +0 -9
  142. package/benchmark/auto-resolve/fixtures/F31-cli-seat-rebalance/NOTES.md +0 -34
  143. package/benchmark/auto-resolve/fixtures/F31-cli-seat-rebalance/expected.json +0 -57
  144. package/benchmark/auto-resolve/fixtures/F31-cli-seat-rebalance/metadata.json +0 -10
  145. package/benchmark/auto-resolve/fixtures/F31-cli-seat-rebalance/setup.sh +0 -2
  146. package/benchmark/auto-resolve/fixtures/F31-cli-seat-rebalance/spec.md +0 -67
  147. package/benchmark/auto-resolve/fixtures/F31-cli-seat-rebalance/task.txt +0 -7
  148. package/benchmark/auto-resolve/fixtures/F31-cli-seat-rebalance/verifiers/duplicate-event-error.js +0 -35
  149. package/benchmark/auto-resolve/fixtures/F31-cli-seat-rebalance/verifiers/priority-transfer-rollback.js +0 -53
  150. package/benchmark/auto-resolve/fixtures/F32-cli-subscription-renewal/NOTES.md +0 -38
  151. package/benchmark/auto-resolve/fixtures/F32-cli-subscription-renewal/expected.json +0 -57
  152. package/benchmark/auto-resolve/fixtures/F32-cli-subscription-renewal/metadata.json +0 -10
  153. package/benchmark/auto-resolve/fixtures/F32-cli-subscription-renewal/setup.sh +0 -2
  154. package/benchmark/auto-resolve/fixtures/F32-cli-subscription-renewal/spec.md +0 -70
  155. package/benchmark/auto-resolve/fixtures/F32-cli-subscription-renewal/task.txt +0 -3
  156. package/benchmark/auto-resolve/fixtures/F32-cli-subscription-renewal/verifiers/duplicate-renewal-error.js +0 -42
  157. package/benchmark/auto-resolve/fixtures/F32-cli-subscription-renewal/verifiers/priority-credit-rollback.js +0 -70
  158. package/benchmark/auto-resolve/fixtures/F4-web-browser-design/NOTES.md +0 -47
  159. package/benchmark/auto-resolve/fixtures/F4-web-browser-design/expected.json +0 -57
  160. package/benchmark/auto-resolve/fixtures/F4-web-browser-design/metadata.json +0 -10
  161. package/benchmark/auto-resolve/fixtures/F4-web-browser-design/setup.sh +0 -6
  162. package/benchmark/auto-resolve/fixtures/F4-web-browser-design/spec.md +0 -48
  163. package/benchmark/auto-resolve/fixtures/F4-web-browser-design/task.txt +0 -9
  164. package/benchmark/auto-resolve/fixtures/F5-fix-loop-red-green/NOTES.md +0 -45
  165. package/benchmark/auto-resolve/fixtures/F5-fix-loop-red-green/expected.json +0 -65
  166. package/benchmark/auto-resolve/fixtures/F5-fix-loop-red-green/metadata.json +0 -10
  167. package/benchmark/auto-resolve/fixtures/F5-fix-loop-red-green/setup.sh +0 -55
  168. package/benchmark/auto-resolve/fixtures/F5-fix-loop-red-green/spec.md +0 -48
  169. package/benchmark/auto-resolve/fixtures/F5-fix-loop-red-green/task.txt +0 -7
  170. package/benchmark/auto-resolve/fixtures/F6-dep-audit-native-module/NOTES.md +0 -43
  171. package/benchmark/auto-resolve/fixtures/F6-dep-audit-native-module/expected.json +0 -77
  172. package/benchmark/auto-resolve/fixtures/F6-dep-audit-native-module/metadata.json +0 -10
  173. package/benchmark/auto-resolve/fixtures/F6-dep-audit-native-module/setup.sh +0 -4
  174. package/benchmark/auto-resolve/fixtures/F6-dep-audit-native-module/spec.md +0 -48
  175. package/benchmark/auto-resolve/fixtures/F6-dep-audit-native-module/task.txt +0 -10
  176. package/benchmark/auto-resolve/fixtures/F7-out-of-scope-trap/NOTES.md +0 -57
  177. package/benchmark/auto-resolve/fixtures/F7-out-of-scope-trap/expected.json +0 -76
  178. package/benchmark/auto-resolve/fixtures/F7-out-of-scope-trap/metadata.json +0 -10
  179. package/benchmark/auto-resolve/fixtures/F7-out-of-scope-trap/setup.sh +0 -36
  180. package/benchmark/auto-resolve/fixtures/F7-out-of-scope-trap/spec.md +0 -45
  181. package/benchmark/auto-resolve/fixtures/F7-out-of-scope-trap/task.txt +0 -7
  182. package/benchmark/auto-resolve/fixtures/F8-known-limit-ambiguous/NOTES.md +0 -53
  183. package/benchmark/auto-resolve/fixtures/F8-known-limit-ambiguous/expected.json +0 -63
  184. package/benchmark/auto-resolve/fixtures/F8-known-limit-ambiguous/metadata.json +0 -10
  185. package/benchmark/auto-resolve/fixtures/F8-known-limit-ambiguous/setup.sh +0 -4
  186. package/benchmark/auto-resolve/fixtures/F8-known-limit-ambiguous/spec.md +0 -48
  187. package/benchmark/auto-resolve/fixtures/F8-known-limit-ambiguous/task.txt +0 -1
  188. package/benchmark/auto-resolve/fixtures/F9-e2e-ideate-to-resolve/NOTES.md +0 -105
  189. package/benchmark/auto-resolve/fixtures/F9-e2e-ideate-to-resolve/expected.json +0 -74
  190. package/benchmark/auto-resolve/fixtures/F9-e2e-ideate-to-resolve/metadata.json +0 -10
  191. package/benchmark/auto-resolve/fixtures/F9-e2e-ideate-to-resolve/setup.sh +0 -28
  192. package/benchmark/auto-resolve/fixtures/F9-e2e-ideate-to-resolve/spec.md +0 -59
  193. package/benchmark/auto-resolve/fixtures/F9-e2e-ideate-to-resolve/task.txt +0 -5
  194. package/benchmark/auto-resolve/fixtures/SCHEMA.md +0 -188
  195. package/benchmark/auto-resolve/fixtures/retired/F27-cli-subscription-proration/NOTES.md +0 -37
  196. package/benchmark/auto-resolve/fixtures/retired/F27-cli-subscription-proration/RETIRED.md +0 -13
  197. package/benchmark/auto-resolve/fixtures/retired/F27-cli-subscription-proration/expected.json +0 -56
  198. package/benchmark/auto-resolve/fixtures/retired/F27-cli-subscription-proration/metadata.json +0 -10
  199. package/benchmark/auto-resolve/fixtures/retired/F27-cli-subscription-proration/setup.sh +0 -18
  200. package/benchmark/auto-resolve/fixtures/retired/F27-cli-subscription-proration/spec.md +0 -69
  201. package/benchmark/auto-resolve/fixtures/retired/F27-cli-subscription-proration/task.txt +0 -7
  202. package/benchmark/auto-resolve/fixtures/retired/F27-cli-subscription-proration/verifiers/exact-proration.js +0 -48
  203. package/benchmark/auto-resolve/fixtures/retired/F27-cli-subscription-proration/verifiers/rules-source-and-conflict.js +0 -79
  204. package/benchmark/auto-resolve/fixtures/retired/F28-cli-return-authorization/NOTES.md +0 -54
  205. package/benchmark/auto-resolve/fixtures/retired/F28-cli-return-authorization/RETIRED.md +0 -7
  206. package/benchmark/auto-resolve/fixtures/retired/F28-cli-return-authorization/expected.json +0 -67
  207. package/benchmark/auto-resolve/fixtures/retired/F28-cli-return-authorization/metadata.json +0 -10
  208. package/benchmark/auto-resolve/fixtures/retired/F28-cli-return-authorization/setup.sh +0 -2
  209. package/benchmark/auto-resolve/fixtures/retired/F28-cli-return-authorization/spec.md +0 -67
  210. package/benchmark/auto-resolve/fixtures/retired/F28-cli-return-authorization/task.txt +0 -5
  211. package/benchmark/auto-resolve/fixtures/retired/F28-cli-return-authorization/verifiers/policy-precedence.js +0 -72
  212. package/benchmark/auto-resolve/fixtures/retired/F28-cli-return-authorization/verifiers/validation-and-immutability.js +0 -43
  213. package/benchmark/auto-resolve/fixtures/retired/F28-cli-return-authorization/verifiers/validation-boundary.js +0 -116
  214. package/benchmark/auto-resolve/fixtures/retired/F30-cli-credit-hold-settlement/NOTES.md +0 -35
  215. package/benchmark/auto-resolve/fixtures/retired/F30-cli-credit-hold-settlement/RETIRED.md +0 -12
  216. package/benchmark/auto-resolve/fixtures/retired/F30-cli-credit-hold-settlement/expected.json +0 -58
  217. package/benchmark/auto-resolve/fixtures/retired/F30-cli-credit-hold-settlement/metadata.json +0 -10
  218. package/benchmark/auto-resolve/fixtures/retired/F30-cli-credit-hold-settlement/setup.sh +0 -2
  219. package/benchmark/auto-resolve/fixtures/retired/F30-cli-credit-hold-settlement/spec.md +0 -73
  220. package/benchmark/auto-resolve/fixtures/retired/F30-cli-credit-hold-settlement/task.txt +0 -17
  221. package/benchmark/auto-resolve/fixtures/retired/F30-cli-credit-hold-settlement/verifiers/mixed-idempotent-settlement.js +0 -53
  222. package/benchmark/auto-resolve/fixtures/retired/F30-cli-credit-hold-settlement/verifiers/rejection-boundaries.js +0 -74
  223. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/NOTES.md +0 -28
  224. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/RETIRED.md +0 -15
  225. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/expected.json +0 -57
  226. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/metadata.json +0 -10
  227. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/setup.sh +0 -50
  228. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/spec.md +0 -59
  229. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/task.txt +0 -9
  230. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/verifiers/ordered-settlement.js +0 -88
  231. package/benchmark/auto-resolve/fixtures/retired/F34-cli-settle-inflight/verifiers/reject-invalid.js +0 -45
  232. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/NOTES.md +0 -30
  233. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/RETIRED.md +0 -16
  234. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/expected.json +0 -64
  235. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/metadata.json +0 -10
  236. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/setup.sh +0 -21
  237. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/spec.md +0 -58
  238. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/task.txt +0 -9
  239. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/verifiers/dup-op.js +0 -54
  240. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/verifiers/priority-apply.js +0 -82
  241. package/benchmark/auto-resolve/fixtures/retired/F35-cli-apply-journal/verifiers/rollback.js +0 -77
  242. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/NOTES.md +0 -99
  243. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/RETIRED.md +0 -18
  244. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/expected.json +0 -64
  245. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/metadata.json +0 -10
  246. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/setup.sh +0 -4
  247. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/spec.md +0 -61
  248. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/task.txt +0 -9
  249. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/verifiers/correctness-small.js +0 -135
  250. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/verifiers/scale.js +0 -100
  251. package/benchmark/auto-resolve/fixtures/retired/F36-cli-session-admission/verifiers/validation.js +0 -75
  252. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/NOTES.md +0 -93
  253. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/RETIRED.md +0 -16
  254. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/expected.json +0 -65
  255. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/metadata.json +0 -10
  256. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/setup.sh +0 -18
  257. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/spec.md +0 -66
  258. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/task.txt +0 -9
  259. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/verifiers/correctness-small.js +0 -84
  260. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/verifiers/scale.js +0 -184
  261. package/benchmark/auto-resolve/fixtures/retired/F37-cli-rule-lookup/verifiers/validation.js +0 -74
  262. package/benchmark/auto-resolve/fixtures/retired/F9-e2e-ideate-to-preflight/NOTES.md +0 -60
  263. package/benchmark/auto-resolve/fixtures/retired/F9-e2e-ideate-to-preflight/RETIRED.md +0 -29
  264. package/benchmark/auto-resolve/fixtures/retired/F9-e2e-ideate-to-preflight/expected.json +0 -73
  265. package/benchmark/auto-resolve/fixtures/retired/F9-e2e-ideate-to-preflight/metadata.json +0 -10
  266. package/benchmark/auto-resolve/fixtures/retired/F9-e2e-ideate-to-preflight/setup.sh +0 -28
  267. package/benchmark/auto-resolve/fixtures/retired/F9-e2e-ideate-to-preflight/spec.md +0 -58
  268. package/benchmark/auto-resolve/fixtures/retired/F9-e2e-ideate-to-preflight/task.txt +0 -5
  269. package/benchmark/auto-resolve/fixtures/test-repo/README.md +0 -12
  270. package/benchmark/auto-resolve/fixtures/test-repo/bin/cli.js +0 -63
  271. package/benchmark/auto-resolve/fixtures/test-repo/package-lock.json +0 -823
  272. package/benchmark/auto-resolve/fixtures/test-repo/package.json +0 -22
  273. package/benchmark/auto-resolve/fixtures/test-repo/playwright.config.js +0 -17
  274. package/benchmark/auto-resolve/fixtures/test-repo/server/index.js +0 -37
  275. package/benchmark/auto-resolve/fixtures/test-repo/tests/cli.test.js +0 -25
  276. package/benchmark/auto-resolve/fixtures/test-repo/tests/server.test.js +0 -58
  277. package/benchmark/auto-resolve/fixtures/test-repo/web/index.html +0 -37
  278. package/benchmark/auto-resolve/results/20260507-f10-f11-tier1-full-pipeline/headroom-gate.json +0 -25
  279. package/benchmark/auto-resolve/results/20260507-f10-f11-tier1-full-pipeline/headroom-gate.md +0 -10
  280. package/benchmark/auto-resolve/results/20260508-f22-exact-error-headroom/headroom-gate.json +0 -18
  281. package/benchmark/auto-resolve/results/20260508-f22-exact-error-headroom/headroom-gate.md +0 -9
  282. package/benchmark/auto-resolve/results/20260508-f26-headroom/headroom-gate.json +0 -18
  283. package/benchmark/auto-resolve/results/20260508-f26-headroom/headroom-gate.md +0 -9
  284. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F16-cli-quote-tax-rules/l2_risk_probes/result.json +0 -28
  285. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F23-cli-fulfillment-wave/l2_risk_probes/result.json +0 -28
  286. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/F25-cli-cart-promotion-rules/l2_risk_probes/result.json +0 -30
  287. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/full-pipeline-pair-gate.json +0 -82
  288. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/full-pipeline-pair-gate.md +0 -18
  289. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/headroom-gate.json +0 -46
  290. package/benchmark/auto-resolve/results/20260510-f16-f23-f25-combined-proof/headroom-gate.md +0 -17
  291. package/benchmark/auto-resolve/results/20260511-f12-webhook-headroom/headroom-gate.json +0 -28
  292. package/benchmark/auto-resolve/results/20260511-f12-webhook-headroom/headroom-gate.md +0 -15
  293. package/benchmark/auto-resolve/results/20260511-f15-concurrency-headroom/headroom-gate.json +0 -28
  294. package/benchmark/auto-resolve/results/20260511-f15-concurrency-headroom/headroom-gate.md +0 -15
  295. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/F21-cli-scheduler-priority/l2_risk_probes/result.json +0 -32
  296. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/full-pipeline-pair-gate.json +0 -44
  297. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/full-pipeline-pair-gate.md +0 -16
  298. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/headroom-gate.json +0 -18
  299. package/benchmark/auto-resolve/results/20260511-f21-current-riskprobes-v1/headroom-gate.md +0 -9
  300. package/benchmark/auto-resolve/results/20260511-f3-http-error-headroom/headroom-gate.json +0 -28
  301. package/benchmark/auto-resolve/results/20260511-f3-http-error-headroom/headroom-gate.md +0 -15
  302. package/benchmark/auto-resolve/results/20260512-f2-medium-headroom/headroom-gate.json +0 -28
  303. package/benchmark/auto-resolve/results/20260512-f2-medium-headroom/headroom-gate.md +0 -15
  304. package/benchmark/auto-resolve/results/20260512-f31-seat-rebalance-headroom/headroom-gate.json +0 -28
  305. package/benchmark/auto-resolve/results/20260512-f31-seat-rebalance-headroom/headroom-gate.md +0 -15
  306. package/benchmark/auto-resolve/results/20260512-f32-subscription-renewal-headroom/headroom-gate.json +0 -28
  307. package/benchmark/auto-resolve/results/20260512-f32-subscription-renewal-headroom/headroom-gate.md +0 -15
  308. package/benchmark/auto-resolve/results/20260512-f4-web-headroom/headroom-gate.json +0 -28
  309. package/benchmark/auto-resolve/results/20260512-f4-web-headroom/headroom-gate.md +0 -15
  310. package/benchmark/auto-resolve/results/20260512-f5-fixloop-headroom/headroom-gate.json +0 -28
  311. package/benchmark/auto-resolve/results/20260512-f5-fixloop-headroom/headroom-gate.md +0 -15
  312. package/benchmark/auto-resolve/results/20260512-f6-checksum-headroom/headroom-gate.json +0 -28
  313. package/benchmark/auto-resolve/results/20260512-f6-checksum-headroom/headroom-gate.md +0 -15
  314. package/benchmark/auto-resolve/results/20260512-f7-scope-headroom/headroom-gate.json +0 -28
  315. package/benchmark/auto-resolve/results/20260512-f7-scope-headroom/headroom-gate.md +0 -15
  316. package/benchmark/auto-resolve/results/20260512-f9-e2e-headroom/headroom-gate.json +0 -28
  317. package/benchmark/auto-resolve/results/20260512-f9-e2e-headroom/headroom-gate.md +0 -15
  318. package/benchmark/auto-resolve/run-real-benchmark.md +0 -303
  319. package/benchmark/auto-resolve/scripts/audit-headroom-rejections.py +0 -441
  320. package/benchmark/auto-resolve/scripts/audit-pair-evidence.py +0 -1256
  321. package/benchmark/auto-resolve/scripts/build-pair-eligible-manifest.py +0 -306
  322. package/benchmark/auto-resolve/scripts/check-f9-artifacts.py +0 -268
  323. package/benchmark/auto-resolve/scripts/collect-swebench-predictions.py +0 -108
  324. package/benchmark/auto-resolve/scripts/compile-report.py +0 -493
  325. package/benchmark/auto-resolve/scripts/fetch-swebench-instances.py +0 -129
  326. package/benchmark/auto-resolve/scripts/frozen-verify-gate.py +0 -434
  327. package/benchmark/auto-resolve/scripts/full-pipeline-pair-gate.py +0 -612
  328. package/benchmark/auto-resolve/scripts/headroom-gate.py +0 -378
  329. package/benchmark/auto-resolve/scripts/iter-0033c-compare.py +0 -683
  330. package/benchmark/auto-resolve/scripts/iter-0033c-l1-summary.py +0 -97
  331. package/benchmark/auto-resolve/scripts/judge-opus-pass.sh +0 -542
  332. package/benchmark/auto-resolve/scripts/judge.sh +0 -565
  333. package/benchmark/auto-resolve/scripts/oracle-scope-tier-a.py +0 -256
  334. package/benchmark/auto-resolve/scripts/oracle-scope-tier-b.py +0 -287
  335. package/benchmark/auto-resolve/scripts/oracle-test-fidelity.py +0 -328
  336. package/benchmark/auto-resolve/scripts/pair-candidate-frontier.py +0 -469
  337. package/benchmark/auto-resolve/scripts/pair-plan-idgen.py +0 -401
  338. package/benchmark/auto-resolve/scripts/pair-plan-lint.py +0 -475
  339. package/benchmark/auto-resolve/scripts/pair-rejected-fixtures.sh +0 -103
  340. package/benchmark/auto-resolve/scripts/pair_evidence_contract.py +0 -270
  341. package/benchmark/auto-resolve/scripts/prepare-swebench-frozen-case.py +0 -273
  342. package/benchmark/auto-resolve/scripts/prepare-swebench-frozen-corpus.py +0 -148
  343. package/benchmark/auto-resolve/scripts/prepare-swebench-solver-worktree.py +0 -210
  344. package/benchmark/auto-resolve/scripts/recent-benchmark-summary.py +0 -232
  345. package/benchmark/auto-resolve/scripts/run-fixture.sh +0 -1024
  346. package/benchmark/auto-resolve/scripts/run-frozen-verify-pair.sh +0 -683
  347. package/benchmark/auto-resolve/scripts/run-full-pipeline-pair-candidate.sh +0 -458
  348. package/benchmark/auto-resolve/scripts/run-headroom-candidate.sh +0 -336
  349. package/benchmark/auto-resolve/scripts/run-iter-0033c.sh +0 -208
  350. package/benchmark/auto-resolve/scripts/run-suite.sh +0 -251
  351. package/benchmark/auto-resolve/scripts/run-swebench-frozen-corpus.sh +0 -310
  352. package/benchmark/auto-resolve/scripts/run-swebench-solver-batch.sh +0 -257
  353. package/benchmark/auto-resolve/scripts/ship-gate.py +0 -391
  354. package/benchmark/auto-resolve/scripts/solo-ceiling-avoidance.py +0 -53
  355. package/benchmark/auto-resolve/scripts/solo-headroom-hypothesis.py +0 -77
  356. package/benchmark/auto-resolve/scripts/swebench-frozen-matrix.py +0 -478
  357. package/benchmark/auto-resolve/scripts/test-audit-headroom-rejections.sh +0 -288
  358. package/benchmark/auto-resolve/scripts/test-audit-pair-evidence.sh +0 -1672
  359. package/benchmark/auto-resolve/scripts/test-benchmark-arg-parsing.sh +0 -1015
  360. package/benchmark/auto-resolve/scripts/test-build-pair-eligible-manifest.sh +0 -491
  361. package/benchmark/auto-resolve/scripts/test-check-f9-artifacts.sh +0 -91
  362. package/benchmark/auto-resolve/scripts/test-frozen-verify-gate.sh +0 -517
  363. package/benchmark/auto-resolve/scripts/test-full-pipeline-pair-gate.sh +0 -610
  364. package/benchmark/auto-resolve/scripts/test-headroom-gate.sh +0 -401
  365. package/benchmark/auto-resolve/scripts/test-iter-0033c-compare.sh +0 -525
  366. package/benchmark/auto-resolve/scripts/test-iter-0033c-l1-summary.sh +0 -254
  367. package/benchmark/auto-resolve/scripts/test-lint-fixtures.sh +0 -580
  368. package/benchmark/auto-resolve/scripts/test-pair-candidate-frontier.sh +0 -591
  369. package/benchmark/auto-resolve/scripts/test-run-full-pipeline-pair-candidate.sh +0 -497
  370. package/benchmark/auto-resolve/scripts/test-run-headroom-candidate.sh +0 -401
  371. package/benchmark/auto-resolve/scripts/test-run-swebench-solver-batch.sh +0 -111
  372. package/benchmark/auto-resolve/scripts/test-ship-gate.sh +0 -1189
  373. package/benchmark/auto-resolve/scripts/test-swebench-frozen-case.sh +0 -1221
  374. package/benchmark/auto-resolve/shadow-fixtures/S1-cli-lang-flag/NOTES.md +0 -28
  375. package/benchmark/auto-resolve/shadow-fixtures/S1-cli-lang-flag/expected.json +0 -63
  376. package/benchmark/auto-resolve/shadow-fixtures/S1-cli-lang-flag/metadata.json +0 -10
  377. package/benchmark/auto-resolve/shadow-fixtures/S1-cli-lang-flag/setup.sh +0 -3
  378. package/benchmark/auto-resolve/shadow-fixtures/S1-cli-lang-flag/spec.md +0 -47
  379. package/benchmark/auto-resolve/shadow-fixtures/S1-cli-lang-flag/task.txt +0 -1
  380. package/benchmark/auto-resolve/shadow-fixtures/S2-cli-inventory-reservation/NOTES.md +0 -34
  381. package/benchmark/auto-resolve/shadow-fixtures/S2-cli-inventory-reservation/expected.json +0 -53
  382. package/benchmark/auto-resolve/shadow-fixtures/S2-cli-inventory-reservation/metadata.json +0 -10
  383. package/benchmark/auto-resolve/shadow-fixtures/S2-cli-inventory-reservation/setup.sh +0 -3
  384. package/benchmark/auto-resolve/shadow-fixtures/S2-cli-inventory-reservation/spec.md +0 -50
  385. package/benchmark/auto-resolve/shadow-fixtures/S2-cli-inventory-reservation/task.txt +0 -1
  386. package/benchmark/auto-resolve/shadow-fixtures/S2-cli-inventory-reservation/verifiers/duplicate-order-error.js +0 -27
  387. package/benchmark/auto-resolve/shadow-fixtures/S2-cli-inventory-reservation/verifiers/priority-stock-reservation.js +0 -44
  388. package/benchmark/auto-resolve/shadow-fixtures/S3-cli-ticket-assignment/NOTES.md +0 -34
  389. package/benchmark/auto-resolve/shadow-fixtures/S3-cli-ticket-assignment/expected.json +0 -55
  390. package/benchmark/auto-resolve/shadow-fixtures/S3-cli-ticket-assignment/metadata.json +0 -10
  391. package/benchmark/auto-resolve/shadow-fixtures/S3-cli-ticket-assignment/setup.sh +0 -3
  392. package/benchmark/auto-resolve/shadow-fixtures/S3-cli-ticket-assignment/spec.md +0 -52
  393. package/benchmark/auto-resolve/shadow-fixtures/S3-cli-ticket-assignment/task.txt +0 -1
  394. package/benchmark/auto-resolve/shadow-fixtures/S3-cli-ticket-assignment/verifiers/duplicate-ticket-error.js +0 -29
  395. package/benchmark/auto-resolve/shadow-fixtures/S3-cli-ticket-assignment/verifiers/priority-agent-assignment.js +0 -48
  396. package/benchmark/auto-resolve/shadow-fixtures/S4-cli-return-routing/NOTES.md +0 -34
  397. package/benchmark/auto-resolve/shadow-fixtures/S4-cli-return-routing/expected.json +0 -55
  398. package/benchmark/auto-resolve/shadow-fixtures/S4-cli-return-routing/metadata.json +0 -10
  399. package/benchmark/auto-resolve/shadow-fixtures/S4-cli-return-routing/setup.sh +0 -3
  400. package/benchmark/auto-resolve/shadow-fixtures/S4-cli-return-routing/spec.md +0 -55
  401. package/benchmark/auto-resolve/shadow-fixtures/S4-cli-return-routing/task.txt +0 -1
  402. package/benchmark/auto-resolve/shadow-fixtures/S4-cli-return-routing/verifiers/duplicate-return-error.js +0 -43
  403. package/benchmark/auto-resolve/shadow-fixtures/S4-cli-return-routing/verifiers/priority-return-routing.js +0 -70
  404. package/benchmark/auto-resolve/shadow-fixtures/S5-cli-credit-grant-ledger/NOTES.md +0 -37
  405. package/benchmark/auto-resolve/shadow-fixtures/S5-cli-credit-grant-ledger/expected.json +0 -54
  406. package/benchmark/auto-resolve/shadow-fixtures/S5-cli-credit-grant-ledger/metadata.json +0 -10
  407. package/benchmark/auto-resolve/shadow-fixtures/S5-cli-credit-grant-ledger/setup.sh +0 -3
  408. package/benchmark/auto-resolve/shadow-fixtures/S5-cli-credit-grant-ledger/spec.md +0 -59
  409. package/benchmark/auto-resolve/shadow-fixtures/S5-cli-credit-grant-ledger/task.txt +0 -1
  410. package/benchmark/auto-resolve/shadow-fixtures/S5-cli-credit-grant-ledger/verifiers/credit-ledger-priority.js +0 -98
  411. package/benchmark/auto-resolve/shadow-fixtures/S5-cli-credit-grant-ledger/verifiers/duplicate-charge-error.js +0 -38
  412. package/benchmark/auto-resolve/shadow-fixtures/S6-cli-refund-window-ledger/NOTES.md +0 -36
  413. package/benchmark/auto-resolve/shadow-fixtures/S6-cli-refund-window-ledger/expected.json +0 -56
  414. package/benchmark/auto-resolve/shadow-fixtures/S6-cli-refund-window-ledger/metadata.json +0 -10
  415. package/benchmark/auto-resolve/shadow-fixtures/S6-cli-refund-window-ledger/setup.sh +0 -3
  416. package/benchmark/auto-resolve/shadow-fixtures/S6-cli-refund-window-ledger/spec.md +0 -59
  417. package/benchmark/auto-resolve/shadow-fixtures/S6-cli-refund-window-ledger/task.txt +0 -1
  418. package/benchmark/auto-resolve/shadow-fixtures/S6-cli-refund-window-ledger/verifiers/duplicate-refund-error.js +0 -41
  419. package/benchmark/auto-resolve/shadow-fixtures/S6-cli-refund-window-ledger/verifiers/priority-refund-ledger.js +0 -65
  420. package/config/skills/_shared/pair-plan-schema.md +0 -298
  421. package/scripts/lint-fixtures.sh +0 -352
  422. package/scripts/lint-shadow-fixtures.sh +0 -58
  423. package/scripts/lint-skills.sh +0 -4265
  424. package/scripts/test-owner-phases.py +0 -193
@@ -172,7 +172,7 @@ def policy(receipt, override):
172
172
  require(config.returncode == 1 or config.stdout in {"auto\n", "pr\n"}, "invalid local devlyn.completionMode; use auto|pr")
173
173
  value = config.stdout.strip() if config.returncode == 0 else None
174
174
  require(override is None or override in {"auto", "pr"}, "invalid task mode; use auto|pr")
175
- return override or value or "auto"
175
+ return override or value or "pr"
176
176
 
177
177
 
178
178
  def allocate(args):
@@ -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,14 +678,12 @@ 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
688
  if mode == "auto":
641
689
  require(info["mergeCommitAllowed"], "repository disallows merge commits; use pr mode or resolve policy with repository owner")
@@ -649,11 +697,11 @@ def complete(args):
649
697
  validate_pr(receipt, pr)
650
698
  actual = remote_head(receipt, receipt["branch"])
651
699
  if not pr or pr["state"] != "MERGED":
700
+ inspect_workspace(receipt)
701
+ verify_files(Path(receipt["worktree"]), receipt["files"])
652
702
  require(actual in {None, receipt["publish_sha"]}, "remote task head changed; refusing to overwrite or merge")
653
703
  require(actual is not None or (not pr and not receipt.get("pushed")), "published task ref disappeared; retain and inspect")
654
704
  if actual is None:
655
- inspect_workspace(receipt)
656
- verify_files(Path(receipt["worktree"]), receipt["files"])
657
705
  gref(receipt, "push", receipt["remote"], receipt["publish_sha"]+":refs/heads/"+receipt["branch"])
658
706
  receipt["pushed"] = True
659
707
  atomic_json(path, receipt)
@@ -665,8 +713,14 @@ def complete(args):
665
713
  validate_pr(receipt, pr)
666
714
  receipt.update(pr_number=pr["number"], pr_url=pr["url"])
667
715
  atomic_json(path, receipt)
668
- if mode == "pr":
669
- return result("PR")
716
+ if mode == "pr" and pr["state"] == "OPEN":
717
+ if pr.get("autoMergeRequest"):
718
+ gh(receipt, "pr", "merge", str(pr["number"]), "--disable-auto")
719
+ pr = json.loads(gh(receipt, "pr", "view", str(pr["number"]), "--json", PR_FIELDS))
720
+ validate_pr(receipt, pr)
721
+ require(not pr.get("autoMergeRequest"), "auto-merge request remains on owned PR; retain and inspect")
722
+ if pr["state"] == "OPEN":
723
+ return result("PR")
670
724
  if pr["state"] != "MERGED":
671
725
  inspect_workspace(receipt)
672
726
  verify_files(Path(receipt["worktree"]), receipt["files"])
@@ -679,7 +733,7 @@ def complete(args):
679
733
  return result("PENDING")
680
734
  receipt["merge"] = pr
681
735
  atomic_json(path, receipt)
682
- cleanup(receipt, path, pr, args.writers_stopped)
736
+ cleanup(receipt, path, pr)
683
737
  return result("COMPLETE")
684
738
 
685
739
 
@@ -692,7 +746,7 @@ def main():
692
746
  for name in ("task", "branch", "repository", "base"):
693
747
  allocation.add_argument("--"+name, required=True)
694
748
  allocation.add_argument("--remote", default="origin")
695
- allocation.add_argument("--worktree")
749
+ allocation.add_argument("--worktree", required=True)
696
750
  completion = actions.add_parser("complete")
697
751
  completion.add_argument("--receipt", required=True)
698
752
  completion.add_argument("--acceptance")
@@ -742,7 +796,7 @@ def remote(ref):
742
796
  return r.stdout.strip() if r.returncode == 0 else None
743
797
  if a[:2] == ['repo', 'view']:
744
798
  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)}))
799
+ 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
800
  elif a[:2] == ['pr', 'list']:
747
801
  assert '--repo' in a and a[a.index('--repo')+1] == 'github.com/test/project'
748
802
  print(json.dumps([d['pr']] if d.get('pr') else []))
@@ -756,6 +810,10 @@ elif a[:2] == ['pr', 'create']:
756
810
  if d.pop('interrupt_create',False): save(); sys.exit(1)
757
811
  elif a[:2] == ['pr', 'view']:
758
812
  print(json.dumps(d['pr']))
813
+ elif a[:2] == ['pr', 'merge'] and '--disable-auto' in a:
814
+ d['disables'] = d.get('disables',0)+1
815
+ d['pr']['autoMergeRequest'] = None
816
+ save()
759
817
  elif a[:2] == ['pr', 'merge']:
760
818
  assert '--admin' not in a and '--delete-branch' not in a
761
819
  assert '--auto' in a and '--merge' in a and '--match-head-commit' in a
@@ -837,7 +895,7 @@ class CompletionTests(unittest.TestCase):
837
895
  self.env["PATH"] = str(self.bin) + os.pathsep + os.environ["PATH"]
838
896
  self.run_cmd(["git", "init", "--bare", "--initial-branch=main", str(self.bare)])
839
897
  self.run_cmd(["git", "init", "--initial-branch=main", str(self.work)])
840
- (self.work / ".gitignore").write_text(".devlyn/\nignored/\n", encoding="utf-8")
898
+ (self.work / ".gitignore").write_text(".devlyn/\nignored/\nnode_modules/\n.env\n", encoding="utf-8")
841
899
  (self.work / "product").write_text("baseline\n", encoding="utf-8")
842
900
  self.g("add", ".")
843
901
  self.g("commit", "-m", "baseline")
@@ -862,33 +920,61 @@ class CompletionTests(unittest.TestCase):
862
920
  r = self.run_cmd([sys.executable, str(Path(__file__).resolve()), *map(str, args)], success=success, cwd=cwd)
863
921
  return json.loads(r.stdout), r
864
922
 
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"]
923
+ def allocate(self, name="fixture", base="main"):
924
+ args = ["allocate", "--repo", self.work, "--task", name, "--branch", "task/"+name, "--repository", "test/project", "--base", base,
925
+ "--worktree", self.root / ("linked" if name == "fixture" else name)]
869
926
  result, _ = self.cli(*args)
870
927
  self.receipt = Path(result["receipt"])
871
928
  self.task = Path(result["worktree"])
872
929
  return result
873
930
 
931
+ def allocate_legacy(self):
932
+ common = Path(self.g("rev-parse", "--absolute-git-dir"))
933
+ key = hashlib.sha256(b"task/fixture").hexdigest()[:24]
934
+ receipt = {"task": "fixture", "repository": "test/project", "remote": "origin", "base": "main", "branch": "task/fixture",
935
+ "common_gitdir": str(common), "anchor": str(self.work), "linked": False, "allocation": "owned",
936
+ "baseline": self.g("rev-parse", "HEAD"), "worktree": str(self.work), "id": key,
937
+ "recovery_ref": "refs/devlyn/completed/"+key, "worktree_gitdir": str(common),
938
+ "remote_url": dict.fromkeys(("literal", "fetch", "push"), "https://github.com/test/project.git")}
939
+ self.receipt = common / "devlyn-completion" / key / "receipt.json"
940
+ self.receipt.parent.mkdir(parents=True)
941
+ scratch = self.receipt.parent / "scratch"
942
+ scratch.mkdir()
943
+ receipt["scratch_identity"] = workspace_identity(scratch, self.receipt.parent)
944
+ self.g("switch", "-c", "task/fixture")
945
+ receipt["worktree_identity"] = workspace_identity(self.work, common)
946
+ self.receipt.write_text(json.dumps(receipt), encoding="utf-8")
947
+ self.task = self.work
948
+
949
+ def merge_pr(self):
950
+ self.configure(pending=False)
951
+ self.run_cmd(["gh", "pr", "merge", "1", "--auto", "--merge", "--match-head-commit", self.sha])
952
+
953
+ def anchor_state(self, work=None):
954
+ work = work or self.work
955
+ return (self.g("symbolic-ref", "HEAD", work=work), self.g("rev-parse", "HEAD", work=work),
956
+ self.g("status", "--porcelain", "--untracked-files=all", work=work), (work / "product").read_bytes(),
957
+ (Path(self.g("rev-parse", "--absolute-git-dir", work=work)) / "index").read_bytes())
958
+
874
959
  def test_allocate_base_with_same_named_tag(self):
875
960
  self.g("tag", "main")
876
- result = self.allocate(linked=True)
961
+ result = self.allocate()
877
962
  self.assertEqual(result["status"], "ALLOCATED")
878
963
  self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["base"], "main")
879
964
 
880
965
  def test_allocate_base_preserves_unicode_whitespace(self):
881
966
  base = "main\u00a0"
882
967
  self.g("switch", "-c", base)
968
+ self.g("push", "origin", "HEAD:refs/heads/"+base)
883
969
  result = self.allocate(base=base)
884
970
  self.assertEqual(result["status"], "ALLOCATED")
885
971
  self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["base"], base)
886
972
 
887
973
  def assert_base_identity_rejected(self, actual, base):
888
974
  result, process = self.cli("allocate", "--repo", self.work, "--task", "fixture",
889
- "--branch", "task/fixture", "--repository", "test/project", "--base", base, success=False)
975
+ "--branch", "task/fixture", "--repository", "test/project", "--base", base, "--worktree", self.root / "linked", success=False)
890
976
  self.assertNotEqual(process.returncode, 0)
891
- self.assertIn("retained base checkout", result["reason"])
977
+ self.assertIn("refs/heads/"+base, result["reason"])
892
978
  head = self.run_cmd(["git", "-C", str(self.work), "symbolic-ref", "HEAD"]).stdout
893
979
  self.assertEqual(head, "refs/heads/" + actual + "\n")
894
980
  self.assertFalse((self.work / ".git/devlyn-completion").exists())
@@ -897,6 +983,8 @@ class CompletionTests(unittest.TestCase):
897
983
 
898
984
  def test_allocate_rejects_trimmed_base_alias(self):
899
985
  self.g("switch", "-c", "main\u00a0")
986
+ self.g("push", "origin", "HEAD:refs/heads/main\u00a0")
987
+ self.run_cmd(["git", "--git-dir", str(self.bare), "update-ref", "-d", "refs/heads/main"])
900
988
  self.assert_base_identity_rejected("main\u00a0", "main")
901
989
 
902
990
  def test_allocate_rejects_short_base_alias(self):
@@ -904,6 +992,29 @@ class CompletionTests(unittest.TestCase):
904
992
  self.g("tag", "main")
905
993
  self.assert_base_identity_rejected("main", "heads/main")
906
994
 
995
+ def test_allocate_uses_remote_base_with_dirty_foreign_anchor(self):
996
+ baseline = self.g("rev-parse", "main")
997
+ (self.work / "product").write_text("remote advance\n", encoding="utf-8")
998
+ self.g("commit", "-am", "remote advance")
999
+ self.g("push", "origin", "main")
1000
+ remote = self.g("rev-parse", "main")
1001
+ self.g("switch", "-c", "busy")
1002
+ (self.work / "product").write_text("local advance\n", encoding="utf-8")
1003
+ self.g("commit", "-am", "local advance")
1004
+ ahead = self.g("rev-parse", "HEAD")
1005
+ (self.work / "product").write_text("busy edits\n", encoding="utf-8")
1006
+ (self.work / "untracked").write_bytes(b"owner data")
1007
+ for name, local in (("behind", baseline), ("ahead", ahead)):
1008
+ self.g("update-ref", "refs/heads/main", local)
1009
+ before = self.anchor_state()
1010
+ result = self.allocate(name)
1011
+ self.assertEqual(result["status"], "ALLOCATED")
1012
+ self.assertEqual(json.loads(self.receipt.read_text())["baseline"], remote)
1013
+ self.assertEqual(self.g("rev-parse", "HEAD", work=self.task), remote)
1014
+ self.assertEqual(self.anchor_state(), before)
1015
+ self.assertEqual((self.work / "untracked").read_bytes(), b"owner data")
1016
+ self.assertFalse((self.work / ".git" / "FETCH_HEAD").exists())
1017
+
907
1018
  @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
908
1019
  def test_disposable_scratch_cleans_for_local_only_and_preserves_source(self):
909
1020
  result = self.allocate()
@@ -934,6 +1045,29 @@ class CompletionTests(unittest.TestCase):
934
1045
  child.wait(timeout=5)
935
1046
  self.cli("clean-scratch", "--receipt", self.receipt, "--writers-stopped")
936
1047
 
1048
+ def test_linux_writer_scan_continues_after_vanished_fd(self):
1049
+ from unittest.mock import patch
1050
+ process = Path("/proc") / str(os.getpid() + 1)
1051
+ vanished, active = process / "fd/0", process / "fd/1"
1052
+ iterdir, readlink = Path.iterdir, os.readlink
1053
+ def entries(path):
1054
+ if path == Path("/proc"):
1055
+ return iter([process])
1056
+ if path == process / "fd":
1057
+ return iter([vanished, active])
1058
+ return iterdir(path)
1059
+ def target(path, *args, **kwargs):
1060
+ if path == process / "cwd":
1061
+ return str(self.root)
1062
+ if path == vanished:
1063
+ raise FileNotFoundError(path)
1064
+ if path == active:
1065
+ return str(self.work / "product")
1066
+ return readlink(path, *args, **kwargs)
1067
+ with patch.object(sys, "platform", "linux"), patch.object(Path, "iterdir", entries), patch.object(os, "readlink", target):
1068
+ with self.assertRaisesRegex(CompletionError, "active process " + process.name):
1069
+ stopped_writers(self.work)
1070
+
937
1071
  @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
938
1072
  def test_scratch_rejects_redirect_and_git_data_but_does_not_follow_child_links(self):
939
1073
  scratch = Path(self.allocate()["scratch"])
@@ -988,7 +1122,7 @@ class CompletionTests(unittest.TestCase):
988
1122
  scratch = Path(self.allocate()["scratch"])
989
1123
  self.accept()
990
1124
  (scratch / ".git").mkdir()
991
- result, _ = self.cli("complete", "--receipt", self.receipt, "--acceptance", self.acceptance, "--writers-stopped")
1125
+ result, _ = self.cli("complete", "--receipt", self.receipt, "--acceptance", self.acceptance, "--writers-stopped", "--mode", "auto")
992
1126
  self.assertEqual(result["status"], "CLEANUP_PENDING")
993
1127
  self.assertEqual(result["delivery_status"], "COMPLETE")
994
1128
  self.assertEqual(result["scratch_cleanup"]["status"], "RETAINED")
@@ -1108,10 +1242,26 @@ class CompletionTests(unittest.TestCase):
1108
1242
  self.g("config", "url." + alias + ".insteadOf", intended)
1109
1243
  self.allocate(); self.accept()
1110
1244
  self.assertEqual(json.loads(self.receipt.read_text(encoding="utf-8"))["remote_url"], {"literal": intended, "fetch": alias, "push": alias})
1111
- result, _ = self.complete("--mode", "pr")
1245
+ result, _ = self.complete()
1112
1246
  self.assertEqual(result["status"], "PR")
1113
1247
  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)
1248
+ d = json.loads(self.data.read_text(encoding="utf-8"))
1249
+ self.assertEqual((d["pushs"], d.get("merges", 0), d["pr"]["autoMergeRequest"]), (1, 0, None))
1250
+
1251
+ def test_pr_mode_disables_only_owned_auto_merge(self):
1252
+ self.allocate(); self.accept()
1253
+ self.configure(pending=True)
1254
+ result, _ = self.complete("--mode", "auto")
1255
+ self.assertEqual(result["status"], "PENDING")
1256
+ result, _ = self.complete("--mode", "pr")
1257
+ self.assertEqual(result["status"], "PR")
1258
+ d = json.loads(self.data.read_text(encoding="utf-8"))
1259
+ self.assertEqual((d["disables"], d["pr"]["autoMergeRequest"]), (1, None))
1260
+ self.configure(pr=dict(d["pr"], isCrossRepository=True, autoMergeRequest={"enabledAt": "now"}))
1261
+ _, r = self.complete("--mode", "pr", success=False)
1262
+ self.assertIn("PR head repository differs", json.loads(r.stdout)["reason"])
1263
+ d = json.loads(self.data.read_text(encoding="utf-8"))
1264
+ self.assertEqual((d["disables"], d["pr"]["autoMergeRequest"]), (1, {"enabledAt": "now"}))
1115
1265
 
1116
1266
  def test_remote_rewrite_after_allocation_is_rejected(self):
1117
1267
  self.allocate(); self.accept()
@@ -1158,28 +1308,364 @@ class CompletionTests(unittest.TestCase):
1158
1308
  d = json.loads(self.data.read_text(encoding="utf-8"))
1159
1309
  self.assertEqual((d["creates"], d["merges"], d["pushs"]), (1, 1, 1))
1160
1310
 
1161
- def test_in_place_retains_ignored_data(self):
1162
- self.allocate(); self.accept()
1311
+ def test_legacy_in_place_retains_checked_out_branch_then_retires_refs(self):
1312
+ self.allocate_legacy(); self.accept()
1163
1313
  (self.task / "ignored").mkdir()
1164
1314
  (self.task / "ignored" / "user.txt").write_text("retain me", encoding="utf-8")
1165
- result, _ = self.complete("--writers-stopped")
1166
- 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")
1315
+ self.complete("--mode", "pr", "--writers-stopped")
1316
+ self.merge_pr()
1317
+ before = self.anchor_state()
1318
+ receipt, task = self.receipt, self.task
1319
+ result = self.allocate("next")
1320
+ self.assertEqual(result["reconciled"][0]["status"], "RETAINED")
1321
+ self.assertIn(str(task), result["reconciled"][0]["reason"])
1322
+ self.assertIn("switch", result["reconciled"][0]["reason"])
1323
+ self.assertEqual(self.anchor_state(), before)
1324
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1325
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
1326
+ self.g("switch", "main")
1327
+ (self.work / "product").write_text("owner edits\n", encoding="utf-8")
1328
+ (self.work / "untracked").write_bytes(b"owner data")
1329
+ before = self.anchor_state()
1330
+ result = self.allocate("retired")
1331
+ self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
1332
+ self.assertEqual(self.anchor_state(), before)
1333
+ self.assertEqual((self.work / "untracked").read_bytes(), b"owner data")
1334
+ self.assertEqual((task / "ignored/user.txt").read_text(encoding="utf-8"), "retain me")
1169
1335
  self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
1170
1336
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
1171
- again, _ = self.complete("--writers-stopped", acceptance=False)
1337
+ self.receipt, self.task = receipt, task
1338
+ again, _ = self.complete(acceptance=False)
1172
1339
  self.assertEqual(again["status"], "COMPLETE")
1173
1340
 
1341
+ def test_pr_mode_cleans_after_human_merge(self):
1342
+ self.allocate(); self.accept()
1343
+ self.complete("--mode", "pr")
1344
+ self.merge_pr()
1345
+ before = self.data.read_bytes()
1346
+ result, _ = self.complete("--mode", "pr", "--writers-stopped")
1347
+ self.assertEqual(result["status"], "COMPLETE")
1348
+ self.assertFalse(self.task.exists())
1349
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
1350
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
1351
+ saved, current = json.loads(before), json.loads(self.data.read_bytes())
1352
+ self.assertEqual((current["merges"], current.get("disables", 0)), (saved["merges"], saved.get("disables", 0)))
1353
+
1354
+ @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
1355
+ def test_nested_repository_or_worktree_retains_tree(self):
1356
+ self.allocate(); self.accept()
1357
+ _, r = self.cli("allocate", "--repo", self.work, "--task", "inner", "--branch", "task/inner", "--repository", "test/project",
1358
+ "--base", "main", "--worktree", self.task / "ignored" / "inner", success=False)
1359
+ self.assertIn("disjoint from every registered worktree", json.loads(r.stdout)["reason"])
1360
+ nested = self.task / "ignored" / "nested"
1361
+ self.g("worktree", "add", "-b", "nested/work", str(nested), "main")
1362
+ (nested / "product").write_text("unsaved nested work\n", encoding="utf-8")
1363
+ clone = self.task / "ignored" / "clone"
1364
+ self.run_cmd(["git", "init", "--initial-branch=main", str(clone)])
1365
+ for remove in (lambda: self.g("worktree", "remove", "--force", str(nested)), lambda: shutil.rmtree(clone)):
1366
+ _, r = self.complete("--mode", "auto", "--writers-stopped", success=False)
1367
+ self.assertIn("nested repository or worktree", json.loads(r.stdout)["reason"])
1368
+ self.assertTrue(self.task.exists())
1369
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1370
+ if nested.exists():
1371
+ self.assertEqual((nested / "product").read_text(encoding="utf-8"), "unsaved nested work\n")
1372
+ remove()
1373
+ result, _ = self.complete("--mode", "auto", "--writers-stopped")
1374
+ self.assertEqual(result["status"], "COMPLETE")
1375
+ self.assertFalse(self.task.exists())
1376
+
1377
+ def test_release_is_refused_from_inside_the_task_tree(self):
1378
+ self.allocate(); self.accept()
1379
+ _, r = self.complete("--mode", "pr", "--writers-stopped", cwd=self.task, success=False)
1380
+ self.assertIn("caller cwd is inside", json.loads(r.stdout)["reason"])
1381
+ self.assertNotIn("writers_released", json.loads(self.receipt.read_text(encoding="utf-8")))
1382
+
1383
+ @unittest.skipUnless(sys.platform == "darwin" or sys.platform.startswith("linux"), "writer observation requires POSIX")
1384
+ def test_squash_merge_cleans_and_recovery_ref_keeps_commit(self):
1385
+ self.allocate(); self.accept()
1386
+ result, _ = self.complete("--mode", "pr", "--writers-stopped")
1387
+ self.assertEqual(result["status"], "PR")
1388
+ bare = ["git", "--git-dir", str(self.bare)]
1389
+ base = self.run_cmd(bare + ["rev-parse", "refs/heads/main"]).stdout.strip()
1390
+ tree = self.run_cmd(bare + ["rev-parse", self.sha + "^{tree}"]).stdout.strip()
1391
+ squash = self.run_cmd(bare + ["commit-tree", tree, "-p", base, "-m", "squash fixture"]).stdout.strip()
1392
+ self.run_cmd(bare + ["update-ref", "refs/heads/main", squash, base])
1393
+ pr = json.loads(self.data.read_text(encoding="utf-8"))["pr"]
1394
+ self.configure(pr=dict(pr, state="MERGED", mergedAt="now", mergeCommit={"oid": squash}))
1395
+ result, _ = self.complete()
1396
+ self.assertEqual(result["status"], "COMPLETE")
1397
+ self.assertFalse(self.task.exists())
1398
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
1399
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
1400
+ self.assertEqual(self.g("rev-parse", json.loads(self.receipt.read_text(encoding="utf-8"))["recovery_ref"]), self.sha)
1401
+ self.assertFalse((self.work / ".git" / "FETCH_HEAD").exists())
1402
+
1403
+ def test_linked_ignored_output_disposed_and_records_preserved(self):
1404
+ self.allocate(); self.accept()
1405
+ for relative, raw in (("node_modules/x", b"build"), (".env", b"SECRET=value\n"), (".devlyn/runs/extra/log", b"extra\x00bytes\n")):
1406
+ path = self.task / relative
1407
+ path.parent.mkdir(parents=True, exist_ok=True)
1408
+ path.write_bytes(raw)
1409
+ path.chmod(0o640)
1410
+ records = snapshot_files(self.task, [".devlyn"])
1411
+ (self.task / "untracked").write_bytes(b"owner data")
1412
+ _, process = self.complete("--mode", "auto", "--writers-stopped", success=False)
1413
+ self.assertNotEqual(process.returncode, 0)
1414
+ self.assertTrue(self.task.exists())
1415
+ self.assertEqual((self.task / "untracked").read_bytes(), b"owner data")
1416
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1417
+ (self.task / "untracked").unlink()
1418
+ result, _ = self.complete("--mode", "auto", "--writers-stopped")
1419
+ self.assertEqual(result["status"], "COMPLETE")
1420
+ self.assertFalse(self.task.exists())
1421
+ verify_files(self.receipt.parent / "records", records)
1422
+ self.assertEqual(snapshot_files(self.receipt.parent / "records", [".devlyn"]), records)
1423
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
1424
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
1425
+
1426
+ def test_reconcile_released_merge_and_retains_without_release(self):
1427
+ self.allocate(); self.accept()
1428
+ result, _ = self.complete("--mode", "pr")
1429
+ self.assertEqual(result["status"], "PR")
1430
+ receipt, task = self.receipt, self.task
1431
+ self.merge_pr()
1432
+ result = self.allocate("unreleased")
1433
+ self.assertEqual(len(result["reconciled"]), 1)
1434
+ self.assertEqual(result["reconciled"][0]["status"], "RETAINED")
1435
+ self.assertIn("writers", result["reconciled"][0]["reason"])
1436
+ self.assertTrue(task.exists())
1437
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1438
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
1439
+ # Reopen only the fixture PR to exercise the owner's pre-merge release.
1440
+ d = json.loads(self.data.read_text())
1441
+ self.configure(pr=dict(d["pr"], state="OPEN", mergedAt=None, mergeCommit=None))
1442
+ self.receipt, self.task = receipt, task
1443
+ result, _ = self.complete("--mode", "pr", "--writers-stopped")
1444
+ self.assertEqual(result["status"], "PR")
1445
+ self.assertTrue(json.loads(receipt.read_text())["writers_released"])
1446
+ self.merge_pr()
1447
+ server = json.loads(self.data.read_text())
1448
+ result = self.allocate("released")
1449
+ self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
1450
+ self.assertFalse(task.exists())
1451
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
1452
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
1453
+ current = json.loads(self.data.read_text())
1454
+ self.assertEqual((current["pushs"], current["creates"], current["merges"], current.get("disables", 0)),
1455
+ (server["pushs"], server["creates"], server["merges"], server.get("disables", 0)))
1456
+
1457
+ def test_reconcile_checks_repo_policy_before_cleanup(self):
1458
+ self.allocate(); self.accept()
1459
+ self.complete("--mode", "pr", "--writers-stopped")
1460
+ self.merge_pr()
1461
+ receipt, task = self.receipt, self.task
1462
+ self.configure(default_branch="changed")
1463
+ result = self.allocate("next")
1464
+ self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "RETAINED")])
1465
+ self.assertIn("base/default branch changed", result["reconciled"][0]["reason"])
1466
+ self.assertTrue(task.exists())
1467
+ self.assertEqual(self.g("rev-parse", "task/fixture"), self.sha)
1468
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
1469
+
1470
+ def test_reconcile_skips_open_and_unaccepted_and_retains_in_use_tree(self):
1471
+ self.allocate(); self.accept()
1472
+ self.complete("--mode", "pr", "--writers-stopped")
1473
+ receipt, task = self.receipt, self.task
1474
+ before = receipt.read_bytes()
1475
+ result = self.allocate("unaccepted")
1476
+ self.assertEqual(result["reconciled"], [])
1477
+ self.assertEqual(receipt.read_bytes(), before)
1478
+ unaccepted, unaccepted_tree = self.receipt, self.task
1479
+ saved = json.loads(unaccepted.read_text())
1480
+ saved.update(pr_number=1, writers_released=True)
1481
+ unaccepted.write_text(json.dumps(saved))
1482
+ unaccepted_before = unaccepted.read_bytes()
1483
+ pr = json.loads(self.data.read_text())["pr"]
1484
+ self.configure(pr=dict(pr, state="CLOSED"))
1485
+ result = self.allocate("closed")
1486
+ self.assertEqual(result["reconciled"], [])
1487
+ self.assertEqual(receipt.read_bytes(), before)
1488
+ self.assertTrue(task.exists())
1489
+ self.configure(pr=pr)
1490
+ self.merge_pr()
1491
+ self.g("worktree", "lock", str(task))
1492
+ result = self.allocate("locked")
1493
+ self.assertEqual(len(result["reconciled"]), 1)
1494
+ self.assertEqual(result["reconciled"][0]["status"], "RETAINED")
1495
+ self.assertIn("locked", result["reconciled"][0]["reason"])
1496
+ self.assertTrue(task.exists())
1497
+ self.g("worktree", "unlock", str(task))
1498
+ child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], cwd=task)
1499
+ try:
1500
+ result = self.allocate("active")
1501
+ self.assertEqual(len(result["reconciled"]), 1)
1502
+ self.assertEqual(result["reconciled"][0]["status"], "RETAINED")
1503
+ self.assertIn("active process", result["reconciled"][0]["reason"])
1504
+ self.assertTrue(task.exists())
1505
+ self.assertIsNone(child.poll())
1506
+ finally:
1507
+ child.terminate(); child.wait(timeout=5)
1508
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1509
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
1510
+ self.assertEqual(unaccepted.read_bytes(), unaccepted_before)
1511
+ self.assertTrue(unaccepted_tree.exists())
1512
+
1513
+ def test_reconcile_already_retired_tree_and_branch(self):
1514
+ self.allocate(); self.accept()
1515
+ self.complete("--mode", "pr", "--writers-stopped")
1516
+ self.merge_pr()
1517
+ receipt, task = self.receipt, self.task
1518
+ self.g("worktree", "remove", str(task))
1519
+ self.g("update-ref", "-d", "refs/heads/task/fixture", self.sha)
1520
+ before = self.anchor_state()
1521
+ result = self.allocate("next")
1522
+ self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
1523
+ self.assertFalse(task.exists())
1524
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
1525
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
1526
+ self.assertEqual(self.anchor_state(), before)
1527
+
1528
+ def test_reconcile_never_removes_allocation_anchor(self):
1529
+ self.allocate(); self.accept()
1530
+ self.complete("--mode", "pr", "--writers-stopped")
1531
+ self.merge_pr()
1532
+ before = self.anchor_state(self.task)
1533
+ result, _ = self.cli("allocate", "--repo", self.task, "--task", "next", "--branch", "task/next",
1534
+ "--repository", "test/project", "--base", "main", "--worktree", self.root / "next")
1535
+ self.assertEqual(result["status"], "ALLOCATED")
1536
+ self.assertEqual(len(result["reconciled"]), 1)
1537
+ self.assertEqual(result["reconciled"][0]["status"], "RETAINED")
1538
+ self.assertIn("anchor", result["reconciled"][0]["reason"])
1539
+ self.assertEqual(self.anchor_state(self.task), before)
1540
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1541
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
1542
+
1543
+ def test_reconcile_reports_scratch_cleanup_pending(self):
1544
+ scratch = Path(self.allocate()["scratch"])
1545
+ self.accept()
1546
+ self.complete("--mode", "pr", "--writers-stopped")
1547
+ self.merge_pr()
1548
+ receipt, task = self.receipt, self.task
1549
+ (scratch / "object").write_bytes(b"rebuildable")
1550
+ child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], cwd=scratch)
1551
+ try:
1552
+ result = self.allocate("next")
1553
+ self.assertEqual(len(result["reconciled"]), 1)
1554
+ row = result["reconciled"][0]
1555
+ self.assertEqual((row["status"], row["delivery_status"]), ("CLEANUP_PENDING", "COMPLETE"))
1556
+ self.assertEqual(row["scratch_cleanup"]["status"], "RETAINED")
1557
+ self.assertIn("active process", row["scratch_cleanup"]["reason"])
1558
+ self.assertEqual((scratch / "object").read_bytes(), b"rebuildable")
1559
+ self.assertIsNone(child.poll())
1560
+ finally:
1561
+ child.terminate(); child.wait(timeout=5)
1562
+ self.assertFalse(task.exists())
1563
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
1564
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
1565
+ self.configure(default_branch="changed")
1566
+ server = json.loads(self.data.read_text())
1567
+ result = self.allocate("retry")
1568
+ self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
1569
+ self.assertFalse(any(scratch.iterdir()))
1570
+ self.assertEqual(json.loads(self.data.read_text()), server)
1571
+ self.assertEqual(self.allocate("later")["reconciled"], [])
1572
+
1573
+ def test_reconcile_retries_unattempted_scratch_cleanup(self):
1574
+ scratch = Path(self.allocate()["scratch"])
1575
+ self.accept()
1576
+ self.complete("--mode", "auto", "--writers-stopped")
1577
+ receipt = self.receipt
1578
+ saved = json.loads(receipt.read_text())
1579
+ del saved["scratch_cleanup"]
1580
+ receipt.write_text(json.dumps(saved))
1581
+ (scratch / "object").write_bytes(b"rebuildable")
1582
+ result = self.allocate("next")
1583
+ self.assertEqual([(row["receipt"], row["status"]) for row in result["reconciled"]], [(str(receipt), "COMPLETE")])
1584
+ self.assertFalse(any(scratch.iterdir()))
1585
+
1586
+ def test_reconcile_retains_busy_receipt_without_waiting(self):
1587
+ self.allocate(); self.accept()
1588
+ self.complete("--mode", "pr", "--writers-stopped")
1589
+ self.merge_pr()
1590
+ 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"
1591
+ child = subprocess.Popen([sys.executable, "-c", code, str(Path(__file__).resolve().with_name("platform-support.py")),
1592
+ str(self.receipt.parent / "lock")], stdout=subprocess.PIPE, text=True)
1593
+ try:
1594
+ self.assertEqual(child.stdout.readline().strip(), "locked")
1595
+ process = subprocess.run([sys.executable, str(Path(__file__).resolve()), "allocate", "--repo", str(self.work),
1596
+ "--task", "next", "--branch", "task/next", "--repository", "test/project", "--base", "main",
1597
+ "--worktree", str(self.root / "next")], cwd=self.root, env=self.env, capture_output=True, text=True, timeout=10)
1598
+ self.assertEqual(process.returncode, 0, process.stdout+process.stderr)
1599
+ result = json.loads(process.stdout)
1600
+ self.assertEqual(result["status"], "ALLOCATED")
1601
+ self.assertEqual(len(result["reconciled"]), 1)
1602
+ self.assertEqual(result["reconciled"][0]["status"], "RETAINED")
1603
+ self.assertIn("lock", result["reconciled"][0]["reason"])
1604
+ self.assertTrue(self.task.exists())
1605
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1606
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
1607
+ self.assertIsNone(child.poll())
1608
+ finally:
1609
+ child.terminate(); child.wait(timeout=5)
1610
+ child.stdout.close()
1611
+
1612
+ def test_reconcile_retains_malformed_receipt_without_failing_allocation(self):
1613
+ self.allocate()
1614
+ receipt, task = self.receipt, self.task
1615
+ receipt.write_text("[]", encoding="utf-8")
1616
+ result = self.allocate("next")
1617
+ self.assertEqual(result["status"], "ALLOCATED")
1618
+ self.assertTrue(self.task.exists())
1619
+ self.assertEqual(len(result["reconciled"]), 1)
1620
+ self.assertEqual(result["reconciled"][0]["status"], "RETAINED")
1621
+ self.assertEqual(result["reconciled"][0]["receipt"], str(receipt))
1622
+ self.assertTrue(result["reconciled"][0]["reason"])
1623
+ self.assertEqual(receipt.read_text(), "[]")
1624
+ self.assertTrue(task.exists())
1625
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1626
+
1627
+ def test_records_refuse_symlink_nonregular_and_tampered_resume(self):
1628
+ self.allocate(); self.accept()
1629
+ self.complete("--mode", "pr", "--writers-stopped")
1630
+ self.merge_pr()
1631
+ other = self.root / "owner-file"
1632
+ other.write_bytes(b"owner data")
1633
+ link = self.task / ".devlyn/link"
1634
+ link.symlink_to(other)
1635
+ result, _ = self.complete(success=False)
1636
+ self.assertIn("path escapes custody", result["reason"])
1637
+ self.assertTrue(self.task.exists())
1638
+ self.assertTrue(link.is_symlink())
1639
+ self.assertEqual(other.read_bytes(), b"owner data")
1640
+ link.unlink()
1641
+ if hasattr(os, "mkfifo"):
1642
+ os.mkfifo(link)
1643
+ result, _ = self.complete(success=False)
1644
+ self.assertIn("regular file", result["reason"])
1645
+ self.assertTrue(self.task.exists())
1646
+ link.unlink()
1647
+ saved = self.receipt.parent / "records/.devlyn"
1648
+ shutil.copytree(self.task / ".devlyn", saved)
1649
+ (saved / "checks.txt").write_bytes(b"tampered copy")
1650
+ result, _ = self.complete(success=False)
1651
+ self.assertIn("bytes/mode changed", result["reason"])
1652
+ self.assertEqual((saved / "checks.txt").read_bytes(), b"tampered copy")
1653
+ self.assertTrue(self.task.exists())
1654
+ self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1655
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], self.sha)
1656
+ shutil.copy2(self.task / ".devlyn/checks.txt", saved / "checks.txt")
1657
+ result, _ = self.complete()
1658
+ self.assertEqual(result["status"], "COMPLETE")
1659
+ self.assertFalse(self.task.exists())
1660
+
1174
1661
  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()
1662
+ self.allocate(); self.accept(pipeline=True)
1178
1663
  self.configure(interrupt_remove=True)
1179
- result, r = self.complete("--writers-stopped", success=False)
1664
+ result, r = self.complete("--writers-stopped", "--mode", "auto", success=False)
1180
1665
  self.assertNotEqual(r.returncode, 0)
1181
1666
  self.assertFalse(self.task.exists())
1182
1667
  saved = json.loads(self.receipt.read_text(encoding="utf-8"))
1668
+ self.assertNotIn("cleanup_started", saved)
1183
1669
  recovered = self.receipt.parent / "custody" / ".devlyn/runs/fixture-run/final-report.md"
1184
1670
  self.assertEqual(hashlib.sha256(recovered.read_bytes()).hexdigest(), saved["files"][".devlyn/runs/fixture-run/final-report.md"]["sha256"])
1185
1671
  result, _ = self.complete("--writers-stopped", acceptance=False)
@@ -1226,31 +1712,31 @@ class CompletionTests(unittest.TestCase):
1226
1712
  self.allocate(); self.accept()
1227
1713
  for effect in ("push", "create", "merge"):
1228
1714
  self.configure(**{"interrupt_"+effect: True})
1229
- _, r = self.complete("--writers-stopped", success=False)
1715
+ _, r = self.complete("--writers-stopped", "--mode", "auto", success=False)
1230
1716
  self.assertNotEqual(r.returncode, 0)
1231
1717
  result, _ = self.complete("--writers-stopped")
1232
1718
  self.assertEqual(result["status"], "COMPLETE")
1233
1719
  d = json.loads(self.data.read_text(encoding="utf-8"))
1234
1720
  self.assertEqual((d["pushs"], d["creates"], d["merges"]), (1, 1, 1))
1235
1721
 
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"}:
1722
+ def test_retains_dirty_locked_and_active_tree(self):
1723
+ self.allocate(); self.accept()
1724
+ for case in ("no-yield", "dirty", "untracked", "locked", "cwd"):
1725
+ path = self.task / ("product" if case == "dirty" else "unknown")
1726
+ if case in {"dirty", "untracked"}:
1241
1727
  path.parent.mkdir(exist_ok=True); path.write_text("do not delete", encoding="utf-8")
1242
1728
  if case == "locked": self.g("worktree", "lock", str(self.task))
1243
1729
  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)
1730
+ _, r = self.complete("--mode", "auto", *([] if case == "no-yield" else ["--writers-stopped"]), cwd=self.task if case == "cwd" else None, success=False)
1245
1731
  self.assertNotEqual(r.returncode, 0)
1246
1732
  self.assertTrue(self.task.exists())
1247
1733
  self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1248
- if case in {"ignored", "untracked"}: path.unlink()
1734
+ if case == "untracked": path.unlink()
1249
1735
  if case == "dirty": path.write_text("accepted\n", encoding="utf-8")
1250
1736
  if case == "locked": self.g("worktree", "unlock", str(self.task))
1251
1737
 
1252
1738
  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)
1739
+ _, r = self.cli("allocate", "--repo", self.work, "--task", "foreign", "--branch", "main", "--repository", "test/project", "--base", "main", "--worktree", self.root / "foreign", success=False)
1254
1740
  self.assertNotEqual(r.returncode, 0)
1255
1741
  self.allocate(); self.accept()
1256
1742
  self.complete("--mode", "pr")
@@ -1261,7 +1747,7 @@ class CompletionTests(unittest.TestCase):
1261
1747
 
1262
1748
  def test_concurrent_completions(self):
1263
1749
  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)]
1750
+ args = [sys.executable, str(Path(__file__).resolve()), "complete", "--receipt", str(self.receipt), "--acceptance", str(self.acceptance), "--mode", "auto"]
1265
1751
  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
1752
  for proc in procs:
1267
1753
  out, err = proc.communicate(timeout=30)
@@ -1270,7 +1756,7 @@ class CompletionTests(unittest.TestCase):
1270
1756
  self.assertEqual((d["pushs"], d["creates"], d["merges"]), (1,1,1))
1271
1757
 
1272
1758
  def test_custody_failure_and_evidence_tamper(self):
1273
- self.allocate(linked=True); self.accept()
1759
+ self.allocate(); self.accept()
1274
1760
  bad = self.receipt.parent / "custody/.devlyn/checks.txt"
1275
1761
  bad.parent.mkdir(parents=True)
1276
1762
  bad.write_text("partial interrupted copy", encoding="utf-8")
@@ -1287,10 +1773,10 @@ class CompletionTests(unittest.TestCase):
1287
1773
  self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("merges",0), 0)
1288
1774
 
1289
1775
  def test_actual_foreign_writer_and_registration(self):
1290
- self.allocate(linked=True); self.accept()
1776
+ self.allocate(); self.accept()
1291
1777
  child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], cwd=self.task)
1292
1778
  try:
1293
- _, r = self.complete("--writers-stopped", success=False)
1779
+ _, r = self.complete("--writers-stopped", "--mode", "auto", success=False)
1294
1780
  self.assertNotEqual(r.returncode, 0)
1295
1781
  self.assertTrue(self.task.exists())
1296
1782
  self.assertIn("active process", json.loads(r.stdout)["reason"])
@@ -1307,7 +1793,7 @@ class CompletionTests(unittest.TestCase):
1307
1793
  self.allocate(); self.accept()
1308
1794
  race = self.g("rev-parse", "main")
1309
1795
  self.configure(remote_delete_race=True, race_sha=race)
1310
- _, r = self.complete("--writers-stopped", success=False)
1796
+ _, r = self.complete("--writers-stopped", "--mode", "auto", success=False)
1311
1797
  self.assertNotEqual(r.returncode, 0)
1312
1798
  self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture").split()[0], race)
1313
1799
  self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
@@ -1315,7 +1801,7 @@ class CompletionTests(unittest.TestCase):
1315
1801
  def test_policy_and_local_only_persist(self):
1316
1802
  self.allocate(); self.accept()
1317
1803
  self.configure(merge_allowed=False)
1318
- _, r = self.complete(success=False)
1804
+ _, r = self.complete("--mode", "auto", success=False)
1319
1805
  self.assertNotEqual(r.returncode, 0)
1320
1806
  self.assertEqual(json.loads(self.data.read_text(encoding="utf-8")).get("pushs",0), 0)
1321
1807
  self.complete("--mode", "pr")
@@ -1365,15 +1851,19 @@ class CompletionTests(unittest.TestCase):
1365
1851
  result, _ = self.complete("--mode", "pr")
1366
1852
  self.assertEqual(result["status"], "PR")
1367
1853
 
1368
- def test_in_place_resume_does_not_overwrite_ignored_collision(self):
1369
- self.allocate(); self.accept()
1854
+ def test_legacy_in_place_resume_does_not_overwrite_ignored_collision(self):
1855
+ self.allocate_legacy(); self.accept()
1856
+ self.complete("--mode", "pr", "--writers-stopped")
1857
+ self.merge_pr()
1858
+ self.g("switch", "main")
1370
1859
  user_file = self.task / "ignored/user.txt"
1371
1860
  user_file.parent.mkdir()
1372
1861
  user_file.write_text("retained user data", encoding="utf-8")
1862
+ before = self.anchor_state()
1373
1863
  self.configure(interrupt_delete=True)
1374
- _, r = self.complete("--writers-stopped", success=False)
1864
+ _, r = self.complete(acceptance=False, success=False)
1375
1865
  self.assertNotEqual(r.returncode, 0)
1376
- self.assertEqual(self.g("branch", "--show-current"), "main")
1866
+ self.assertEqual(self.anchor_state(), before)
1377
1867
  other = self.root / "advance"
1378
1868
  self.run_cmd(["git", "clone", str(self.bare), str(other)])
1379
1869
  (other / "ignored").mkdir()
@@ -1381,13 +1871,15 @@ class CompletionTests(unittest.TestCase):
1381
1871
  self.g("add", "-f", "ignored/user.txt", work=other)
1382
1872
  self.g("commit", "-m", "upstream collision", work=other)
1383
1873
  self.g("push", "origin", "main", work=other)
1384
- _, r = self.complete("--writers-stopped", acceptance=False, success=False)
1385
- self.assertNotEqual(r.returncode, 0)
1874
+ result, _ = self.complete(acceptance=False)
1875
+ self.assertEqual(result["status"], "COMPLETE")
1876
+ self.assertEqual(self.anchor_state(), before)
1386
1877
  self.assertEqual(user_file.read_text(encoding="utf-8"), "retained user data")
1387
- self.assertNotEqual(self.g("branch", "--list", "task/fixture"), "")
1878
+ self.assertEqual(self.g("branch", "--list", "task/fixture"), "")
1879
+ self.assertEqual(self.g("ls-remote", "origin", "refs/heads/task/fixture"), "")
1388
1880
 
1389
1881
  def test_replaced_tree_and_unsafe_custody(self):
1390
- self.allocate(linked=True); self.accept()
1882
+ self.allocate(); self.accept()
1391
1883
  elsewhere = self.root / "elsewhere"
1392
1884
  elsewhere.mkdir()
1393
1885
  (self.receipt.parent / "custody").symlink_to(elsewhere, target_is_directory=True)