@orkastery/cli 0.5.1 → 0.5.6

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 (260) hide show
  1. package/adapters/claude-code/README.md +158 -0
  2. package/adapters/claude-code/agents/ork-master.md +2 -0
  3. package/adapters/claude-code/commands/master.md +8 -0
  4. package/adapters/claude-code/commands/ork.md +9 -0
  5. package/adapters/claude-code/mods/autoridade.json +56 -0
  6. package/adapters/claude-code/mods/ork-clima/.claude-plugin/plugin.json +14 -0
  7. package/adapters/claude-code/mods/ork-clima/hooks/contas.ts +283 -0
  8. package/adapters/claude-code/mods/ork-clima/hooks/hooks.json +3 -0
  9. package/adapters/claude-code/mods/ork-clima/hooks/previsao.ts +393 -0
  10. package/adapters/claude-code/mods/ork-clima/hooks/register.ts +205 -0
  11. package/adapters/claude-code/mods/ork-clima/orkastery-mod.json +5 -0
  12. package/adapters/claude-code/mods/ork-clima/types/index.d.ts +80 -0
  13. package/adapters/claude-code/mods/ork-freio/.claude-plugin/plugin.json +14 -0
  14. package/adapters/claude-code/mods/ork-freio/hooks/carga.ts +160 -0
  15. package/adapters/claude-code/mods/ork-freio/hooks/conferencia.ts +309 -0
  16. package/adapters/claude-code/mods/ork-freio/hooks/hooks.json +3 -0
  17. package/adapters/claude-code/mods/ork-freio/hooks/lote.ts +270 -0
  18. package/adapters/claude-code/mods/ork-freio/hooks/register.ts +285 -0
  19. package/adapters/claude-code/mods/ork-freio/orkastery-mod.json +5 -0
  20. package/adapters/claude-code/mods/ork-freio/types/index.d.ts +127 -0
  21. package/adapters/claude-code/mods/ork-mesa/.claude-plugin/plugin.json +14 -0
  22. package/adapters/claude-code/mods/ork-mesa/hooks/cartoes.tsx +246 -0
  23. package/adapters/claude-code/mods/ork-mesa/hooks/decidir.ts +110 -0
  24. package/adapters/claude-code/mods/ork-mesa/hooks/fila.ts +253 -0
  25. package/adapters/claude-code/mods/ork-mesa/hooks/hooks.json +3 -0
  26. package/adapters/claude-code/mods/ork-mesa/hooks/obvia.ts +98 -0
  27. package/adapters/claude-code/mods/ork-mesa/hooks/register.tsx +493 -0
  28. package/adapters/claude-code/mods/ork-mesa/orkastery-mod.json +5 -0
  29. package/adapters/claude-code/mods/ork-mesa/types/index.d.ts +131 -0
  30. package/adapters/claude-code/mods/ork-pista/.claude-plugin/plugin.json +14 -0
  31. package/adapters/claude-code/mods/ork-pista/hooks/acoes.ts +71 -0
  32. package/adapters/claude-code/mods/ork-pista/hooks/ci.ts +117 -0
  33. package/adapters/claude-code/mods/ork-pista/hooks/hooks.json +3 -0
  34. package/adapters/claude-code/mods/ork-pista/hooks/pista.tsx +356 -0
  35. package/adapters/claude-code/mods/ork-pista/hooks/register.tsx +439 -0
  36. package/adapters/claude-code/mods/ork-pista/orkastery-mod.json +5 -0
  37. package/adapters/claude-code/mods/ork-pista/types/index.d.ts +85 -0
  38. package/adapters/claude-code/mods/ork-ponte/.claude-plugin/plugin.json +13 -0
  39. package/adapters/claude-code/mods/ork-ponte/hooks/faixa.tsx +57 -0
  40. package/adapters/claude-code/mods/ork-ponte/hooks/guarda.ts +410 -0
  41. package/adapters/claude-code/mods/ork-ponte/hooks/hooks.json +3 -0
  42. package/adapters/claude-code/mods/ork-ponte/hooks/mostrado.ts +55 -0
  43. package/adapters/claude-code/mods/ork-ponte/hooks/nucleo.ts +157 -0
  44. package/adapters/claude-code/mods/ork-ponte/hooks/papel.ts +42 -0
  45. package/adapters/claude-code/mods/ork-ponte/hooks/register.ts +331 -0
  46. package/adapters/claude-code/mods/ork-ponte/hooks/vigia.ts +76 -0
  47. package/adapters/claude-code/mods/ork-ponte/orkastery-mod.json +5 -0
  48. package/adapters/claude-code/mods/ork-ponte/types/index.d.ts +178 -0
  49. package/adapters/claude-code/mods/ork-portao/.claude-plugin/plugin.json +14 -0
  50. package/adapters/claude-code/mods/ork-portao/hooks/hooks.json +3 -0
  51. package/adapters/claude-code/mods/ork-portao/hooks/lease.ts +201 -0
  52. package/adapters/claude-code/mods/ork-portao/hooks/plano.ts +304 -0
  53. package/adapters/claude-code/mods/ork-portao/hooks/register.ts +339 -0
  54. package/adapters/claude-code/mods/ork-portao/hooks/verify.ts +152 -0
  55. package/adapters/claude-code/mods/ork-portao/orkastery-mod.json +5 -0
  56. package/adapters/claude-code/mods/ork-portao/types/index.d.ts +60 -0
  57. package/adapters/claude-code/mods/ork-radar/.claude-plugin/plugin.json +14 -0
  58. package/adapters/claude-code/mods/ork-radar/hooks/colisao.ts +420 -0
  59. package/adapters/claude-code/mods/ork-radar/hooks/hooks.json +3 -0
  60. package/adapters/claude-code/mods/ork-radar/hooks/mapa.tsx +322 -0
  61. package/adapters/claude-code/mods/ork-radar/hooks/register.tsx +470 -0
  62. package/adapters/claude-code/mods/ork-radar/orkastery-mod.json +5 -0
  63. package/adapters/claude-code/mods/ork-radar/types/index.d.ts +72 -0
  64. package/adapters/claude-code/mods/ork-sentinela/.claude-plugin/plugin.json +14 -0
  65. package/adapters/claude-code/mods/ork-sentinela/hooks/alertas.ts +85 -0
  66. package/adapters/claude-code/mods/ork-sentinela/hooks/despacho.ts +156 -0
  67. package/adapters/claude-code/mods/ork-sentinela/hooks/hooks.json +3 -0
  68. package/adapters/claude-code/mods/ork-sentinela/hooks/register.ts +511 -0
  69. package/adapters/claude-code/mods/ork-sentinela/hooks/vigia.ts +186 -0
  70. package/adapters/claude-code/mods/ork-sentinela/orkastery-mod.json +5 -0
  71. package/adapters/claude-code/mods/ork-sentinela/types/index.d.ts +65 -0
  72. package/adapters/claude-code/mods/ork-torre/.claude-plugin/plugin.json +14 -0
  73. package/adapters/claude-code/mods/ork-torre/hooks/agenda.ts +60 -0
  74. package/adapters/claude-code/mods/ork-torre/hooks/detalhe.tsx +236 -0
  75. package/adapters/claude-code/mods/ork-torre/hooks/frases.ts +42 -0
  76. package/adapters/claude-code/mods/ork-torre/hooks/hooks.json +3 -0
  77. package/adapters/claude-code/mods/ork-torre/hooks/painel.tsx +344 -0
  78. package/adapters/claude-code/mods/ork-torre/hooks/rede.ts +97 -0
  79. package/adapters/claude-code/mods/ork-torre/hooks/register.tsx +437 -0
  80. package/adapters/claude-code/mods/ork-torre/orkastery-mod.json +5 -0
  81. package/adapters/claude-code/mods/ork-torre/types/index.d.ts +108 -0
  82. package/adapters/codex/skills/ork/SKILL.md +18 -3
  83. package/adapters/hermes/README.md +1 -0
  84. package/adapters/hermes/bin/ork-network-status.sh +10 -0
  85. package/adapters/hermes/hermes.plugin.json +1 -0
  86. package/adapters/hermes/hitl-ingress/__init__.py +2 -2
  87. package/adapters/hermes/skills/orkastery-devmaster/SKILL.md +7 -6
  88. package/adapters/openclaw/README.md +26 -5
  89. package/adapters/openclaw/dist/hitl-ingress.js +2 -2
  90. package/adapters/openclaw/dist/index.js +46 -11
  91. package/adapters/openclaw/openclaw.plugin.json +5 -1
  92. package/adapters/openclaw/package.json +2 -2
  93. package/adapters/openclaw/src/hitl-ingress.ts +2 -2
  94. package/adapters/openclaw/src/index.ts +49 -11
  95. package/assets/docs-en/markdownlint-cli2.jsonc +22 -0
  96. package/assets/docs-en/padroes/documentacao-de-produto.md +135 -0
  97. package/assets/docs-en/padroes/roadmap-de-produto.md +100 -0
  98. package/assets/docs-en/produto/README.md +14 -0
  99. package/assets/docs-en/produto/_modelo-feature.md +62 -0
  100. package/assets/docs-en/roadmap/README.md +14 -0
  101. package/assets/docs-en/roadmap/_modelo-item.md +75 -0
  102. package/assets/orkmind_bridge.py +117 -9
  103. package/dist/adapters/claude-bg.js +116 -18
  104. package/dist/adapters/codex.js +14 -4
  105. package/dist/agents-md.js +24 -14
  106. package/dist/auditrun.js +22 -3
  107. package/dist/board.js +20 -17
  108. package/dist/branch-de-estado.js +88 -10
  109. package/dist/busca-semantica.js +52 -12
  110. package/dist/canarios-rm057.js +149 -0
  111. package/dist/canarios.js +2 -0
  112. package/dist/catalogo.js +33 -0
  113. package/dist/ci.js +21 -8
  114. package/dist/claims-conferir.js +342 -0
  115. package/dist/claims.js +41 -1
  116. package/dist/company-brain-client.js +36 -1
  117. package/dist/company-brain-context.js +158 -22
  118. package/dist/company-brain-journal.js +2 -1
  119. package/dist/company-brain-migration.js +36 -1
  120. package/dist/conducao-sentinela.js +388 -0
  121. package/dist/conducao-texto.js +12 -18
  122. package/dist/conducao.js +122 -30
  123. package/dist/conhecimento.js +490 -0
  124. package/dist/docs.js +70 -17
  125. package/dist/doctor-pulse-cron.js +349 -0
  126. package/dist/doctor-rede.js +150 -0
  127. package/dist/doctor.js +195 -111
  128. package/dist/entrega-pr.js +42 -29
  129. package/dist/escopo-escrita.js +10 -1
  130. package/dist/esgotamentos.js +181 -0
  131. package/dist/estado-thread.js +23 -1
  132. package/dist/experiencia-instalacao.js +45 -25
  133. package/dist/experiencia.js +12 -2
  134. package/dist/fabrica-batida.js +310 -0
  135. package/dist/fabrica-estado.js +52 -14
  136. package/dist/fabrica-publicar.js +9 -1
  137. package/dist/fechamento.js +20 -66
  138. package/dist/fila-pesada.js +444 -0
  139. package/dist/fix.js +3 -2
  140. package/dist/forja.js +31 -7
  141. package/dist/gates.js +3 -24
  142. package/dist/handoff.js +10 -6
  143. package/dist/hitl-contract.js +59 -4
  144. package/dist/hitl-curto.js +11 -6
  145. package/dist/hitl-gates.js +5 -0
  146. package/dist/hitl-local-receipt.js +2 -0
  147. package/dist/hitl-lote.js +5 -2
  148. package/dist/hitl-resumo.js +38 -3
  149. package/dist/hitl-tempo-parado.js +201 -0
  150. package/dist/hitl-texto-livre.js +5 -5
  151. package/dist/hitl.js +29 -33
  152. package/dist/horario.js +41 -7
  153. package/dist/hosts.js +104 -11
  154. package/dist/impedimento.js +299 -0
  155. package/dist/index.js +961 -542
  156. package/dist/indice-vetorial.js +87 -10
  157. package/dist/indice.js +9 -1
  158. package/dist/init.js +56 -43
  159. package/dist/instabilidade.js +144 -0
  160. package/dist/integracoes-locais.js +4 -0
  161. package/dist/intelligence-graph-cli.js +170 -16
  162. package/dist/intelligence-graph-contexto.js +254 -0
  163. package/dist/intelligence-graph-contract.js +2 -1
  164. package/dist/intelligence-graph-extract-md.js +56 -4
  165. package/dist/intelligence-graph-extract-ts.js +71 -3
  166. package/dist/intelligence-graph-parsers.js +6 -4
  167. package/dist/intelligence-graph-query.js +66 -19
  168. package/dist/leases.js +664 -87
  169. package/dist/ledger-stats.js +15 -1
  170. package/dist/ledger.js +18 -1
  171. package/dist/licoes.js +1 -0
  172. package/dist/liveness.js +6 -3
  173. package/dist/locale-ajuda.js +761 -0
  174. package/dist/locale-gerados.js +160 -0
  175. package/dist/locale-master.js +99 -0
  176. package/dist/locale-onboarding.js +92 -0
  177. package/dist/locale-phase.js +151 -0
  178. package/dist/locale-pulse.js +211 -0
  179. package/dist/locale-retry.js +385 -0
  180. package/dist/locale-roadmap.js +102 -0
  181. package/dist/locale-ship.js +494 -0
  182. package/dist/locale-thread.js +277 -0
  183. package/dist/locale-verify.js +125 -0
  184. package/dist/locale.js +714 -0
  185. package/dist/maestro-runtime.js +4 -1
  186. package/dist/manifest.js +149 -4
  187. package/dist/maquina.js +23 -4
  188. package/dist/master-digest.js +6 -1
  189. package/dist/master-nota.js +18 -11
  190. package/dist/master.js +145 -31
  191. package/dist/mcp-artifacts.js +43 -3
  192. package/dist/mcp-git.js +42 -8
  193. package/dist/mcp-grafo-worker.js +5 -3
  194. package/dist/mcp-grafo.js +55 -6
  195. package/dist/mcp-server.js +22 -3
  196. package/dist/mcp-ship.js +3 -1
  197. package/dist/mcp-verify.js +6 -1
  198. package/dist/memoria.js +48 -6
  199. package/dist/modos.js +3 -6
  200. package/dist/network-roadmap.js +196 -18
  201. package/dist/noctum-arco.js +911 -0
  202. package/dist/noctum-capacidade.js +141 -0
  203. package/dist/noctum-envelope.js +810 -0
  204. package/dist/noctum-execucao.js +304 -0
  205. package/dist/noctum-fila.js +280 -0
  206. package/dist/ocupacao.js +40 -5
  207. package/dist/onboarding.js +40 -21
  208. package/dist/orkmind.js +99 -7
  209. package/dist/orquestracao.js +24 -21
  210. package/dist/parado-no-condutor.js +219 -38
  211. package/dist/phase.js +160 -52
  212. package/dist/policies.js +56 -5
  213. package/dist/portfolio.js +8 -3
  214. package/dist/postura-local.js +234 -0
  215. package/dist/preflight.js +11 -10
  216. package/dist/procedencia.js +225 -0
  217. package/dist/projeto-alvo.js +132 -5
  218. package/dist/prompts.js +40 -1
  219. package/dist/prova-ativacao.js +75 -6
  220. package/dist/pulse-delivery.js +47 -4
  221. package/dist/pulse.js +98 -19
  222. package/dist/ratelimit.js +24 -6
  223. package/dist/recall.js +3 -1
  224. package/dist/redacao-saida.js +63 -0
  225. package/dist/rede-adesao.js +399 -0
  226. package/dist/rede-forja.js +289 -0
  227. package/dist/rede-projetos.js +239 -0
  228. package/dist/rede-status.js +337 -0
  229. package/dist/rede.js +980 -0
  230. package/dist/retry.js +291 -140
  231. package/dist/roadmap-reservas.js +20 -3
  232. package/dist/roadmap-status.js +150 -34
  233. package/dist/runtime-context.js +181 -3
  234. package/dist/runtime-profiles.js +22 -1
  235. package/dist/runtimes.js +2 -1
  236. package/dist/saida-segura.js +58 -0
  237. package/dist/session-events.js +7 -3
  238. package/dist/session-watcher-claude.js +120 -24
  239. package/dist/session-watcher.js +185 -33
  240. package/dist/sessoes-contas.js +187 -0
  241. package/dist/sessoes-inventario.js +6 -18
  242. package/dist/sessoes.js +83 -2
  243. package/dist/ship-publicar.js +800 -0
  244. package/dist/ship.js +157 -88
  245. package/dist/steal.js +77 -0
  246. package/dist/thread-close.js +3 -2
  247. package/dist/thread.js +299 -49
  248. package/dist/util.js +8 -1
  249. package/dist/verify.js +141 -52
  250. package/dist/vida-da-thread.js +254 -0
  251. package/dist/worktree-local.js +165 -0
  252. package/dist/worktree.js +25 -2
  253. package/dist/yaml.js +27 -8
  254. package/eval/fixtures/fx-pedido-colado/caso.json +20 -0
  255. package/package.json +7 -3
  256. package/references/definition-of-done.md +1 -1
  257. package/schemas/code-artifact-graph.v1.schema.json +2 -1
  258. package/skills/README.md +1 -1
  259. package/skills/core/orkastery-bootstrap/SKILL.md +2 -2
  260. package/skills/phases/master-metrics/SKILL.md +8 -3
package/dist/leases.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  /**
3
- * Leases de exclusao mutua entre threads (`.orkastery/leases/<nome>.json`).
3
+ * Leases de exclusao mutua entre threads (`.orkastery/leases/<nome>.json` da raiz do projeto).
4
4
  *
5
5
  * O lease `main-tree` serializa o SHIP: com N threads paralelas, so uma mergeia por vez.
6
6
  * A aquisicao e atomica de verdade (`open` com flag `wx`, que falha se o arquivo existe),
@@ -13,6 +13,11 @@
13
13
  * `path:<glob>`, `board:<card>` e `service:<porta>`, e uma FILA por colisao. Duas threads
14
14
  * que pedem a mesma regiao de path nao brigam: a segunda entra na fila em FIFO e recebe
15
15
  * o motivo tipado `lease.busy` com a correcao acionavel.
16
+ *
17
+ * RM-036 (fatia dos leases): todas as familias, e a fila, moram no estado CANONICO do projeto
18
+ * (`raizDoEstado`), como o `exec:` da I-36. O `ork` acha a raiz subindo do cwd e a worktree tem
19
+ * o proprio manifesto: com a pasta do checkout de quem chamava, um `ork` da raiz e outro da
20
+ * worktree pegavam o mesmo lease ao mesmo tempo.
16
21
  */
17
22
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
18
23
  if (k2 === undefined) k2 = k;
@@ -55,6 +60,8 @@ exports.nomeDeLease = nomeDeLease;
55
60
  exports.tipoDoLease = tipoDoLease;
56
61
  exports.alvoDoLease = alvoDoLease;
57
62
  exports.caminhoLease = caminhoLease;
63
+ exports.argumentoDeLease = argumentoDeLease;
64
+ exports.dirsLegadosDeLeases = dirsLegadosDeLeases;
58
65
  exports.regravarLease = regravarLease;
59
66
  exports.lerLease = lerLease;
60
67
  exports.expirado = expirado;
@@ -81,6 +88,8 @@ exports.soltarThreadFechada = soltarThreadFechada;
81
88
  exports.podarRegioesDeThreadsFechadas = podarRegioesDeThreadsFechadas;
82
89
  const fs = __importStar(require("node:fs"));
83
90
  const path = __importStar(require("node:path"));
91
+ const node_crypto_1 = require("node:crypto");
92
+ const node_child_process_1 = require("node:child_process");
84
93
  const manifest_1 = require("./manifest");
85
94
  const estado_thread_1 = require("./estado-thread");
86
95
  const util_1 = require("./util");
@@ -103,17 +112,21 @@ exports.FAMILIAS_DE_LEASE = {
103
112
  };
104
113
  /** I-36 (D1): prefixo da familia de execucao, `exec:<thread>`. */
105
114
  exports.PREFIXO_EXEC = 'exec';
106
- /** Diretorio dos leases do projeto. */
115
+ /**
116
+ * Diretorio dos leases do projeto: o `.orkastery/leases` da raiz do estado, o mesmo das threads,
117
+ * chamado da raiz ou de qualquer worktree (RM-036, D1 do PLAN). Com o diretorio do checkout, cada
118
+ * um teria o seu e os dois segurariam o mesmo lease. Fora de repositorio git, `raizDoEstado`
119
+ * devolve a propria raiz.
120
+ */
107
121
  function dirLeases(raiz) {
108
- return path.join((0, manifest_1.dirEstado)(raiz), 'leases');
122
+ return path.join((0, manifest_1.dirEstado)((0, estado_thread_1.raizDoEstado)(raiz)), 'leases');
109
123
  }
110
124
  /**
111
- * I-36: o lease de execucao mora no estado CANONICO do projeto (o mesmo das threads), nunca no
112
- * checkout de quem chamou. Um canal que roda da raiz e outro que roda de uma worktree disputam o
113
- * mesmo arquivo; com o diretorio do checkout, cada um teria o seu e os dois executariam juntos.
125
+ * I-36: o lease de execucao mora no estado CANONICO do projeto, nunca no checkout de quem chamou.
126
+ * Desde a RM-036 todas as familias moram la; o nome segue para quem ja o importava.
114
127
  */
115
128
  function dirLeasesDeExecucao(raiz) {
116
- return path.join((0, manifest_1.dirEstado)((0, estado_thread_1.raizDoEstado)(raiz)), 'leases');
129
+ return dirLeases(raiz);
117
130
  }
118
131
  /**
119
132
  * Nome canonico de um lease de familia tipada: `path:core/src/**`, `service:5173`.
@@ -149,8 +162,191 @@ function alvoDoLease(nome) {
149
162
  * inalterado, o que preserva os leases gravados pelo bloco B1.
150
163
  */
151
164
  function caminhoLease(raiz, nome) {
152
- const dir = tipoDoLease(nome) === 'exec' ? dirLeasesDeExecucao(raiz) : dirLeases(raiz);
153
- return path.join(dir, `${encodeURIComponent(nome)}.json`);
165
+ return path.join(dirLeases(raiz), `${encodeURIComponent(nome)}.json`);
166
+ }
167
+ // ---------------------------------------------------------------------------
168
+ // RM-036: o legado, o que a versao anterior gravou no `.orkastery/leases` de uma worktree.
169
+ // ---------------------------------------------------------------------------
170
+ /** Diretorio de verdade: `lstat`, entao link simbolico nao conta. */
171
+ function diretorioDeVerdade(caminho) {
172
+ try {
173
+ return fs.lstatSync(caminho).isDirectory();
174
+ }
175
+ catch {
176
+ return false;
177
+ }
178
+ }
179
+ /** Arquivo regular de verdade, nunca pelo link simbolico. */
180
+ function arquivoDeVerdade(caminho) {
181
+ try {
182
+ return fs.lstatSync(caminho).isFile();
183
+ }
184
+ catch {
185
+ return false;
186
+ }
187
+ }
188
+ /** Texto que pode sair no terminal, no monitor ou numa correcao de comando. */
189
+ function textoSeguro(texto) {
190
+ return String(texto ?? '').replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, '');
191
+ }
192
+ /** Um argumento literal de shell, inclusive quando vem da fila canonica. */
193
+ function argumentoDeLease(texto) {
194
+ return `'${textoSeguro(texto).replace(/'/g, "'\\''")}'`;
195
+ }
196
+ /** O legado nao admite sintaxe de shell nem nomes arbitrariamente longos. */
197
+ function nomeLegadoSeguro(nome) {
198
+ return typeof nome === 'string' && /^[A-Za-z0-9][A-Za-z0-9._/*?:@+-]{0,199}$/.test(nome);
199
+ }
200
+ /** A primeira consulta abre uma unica janela, mesmo sem legado; nunca a renova. */
201
+ function janelaDoLegado(principal) {
202
+ const marcador = path.join(dirLeases(principal), '.legado');
203
+ try {
204
+ fs.mkdirSync(path.dirname(marcador), { recursive: true });
205
+ fs.closeSync(fs.openSync(marcador, 'wx'));
206
+ }
207
+ catch { /* EEXIST: outra chamada abriu a mesma janela; falha de leitura fecha. */ }
208
+ try {
209
+ const stat = fs.lstatSync(marcador);
210
+ return stat.isFile() && Date.now() - stat.mtimeMs < exports.TTL_PADRAO_MS;
211
+ }
212
+ catch {
213
+ return false;
214
+ }
215
+ }
216
+ /**
217
+ * As pastas `.orkastery/leases` das worktrees registradas no git do projeto, onde a versao anterior
218
+ * gravava os leases pedidos de dentro delas. A lista vem do git (`.git/worktrees/<n>/gitdir`),
219
+ * nunca de argumento, e so entra diretorio de verdade, sem link simbolico em `.orkastery` nem em `leases`:
220
+ * a leitura do legado nunca e levada para fora; limpeza grava apenas marcas no canonico. O `.git` da worktree precisa apontar
221
+ * de volta ao registro. A janela de 30 min dispensa a varredura depois da troca de versao.
222
+ */
223
+ function dirsLegadosDeLeases(raiz) {
224
+ const principal = (0, estado_thread_1.raizDoEstado)(raiz);
225
+ if (janelaDoLegado(principal) === false)
226
+ return [];
227
+ const registro = path.join(principal, '.git', 'worktrees');
228
+ let nomes;
229
+ try {
230
+ nomes = fs.readdirSync(registro);
231
+ }
232
+ catch {
233
+ return [];
234
+ }
235
+ const real = (dir) => { try {
236
+ return fs.realpathSync(dir);
237
+ }
238
+ catch {
239
+ return path.resolve(dir);
240
+ } };
241
+ const vistos = new Set([real(dirLeases(principal))]);
242
+ const dirs = [];
243
+ for (const nome of nomes.sort()) {
244
+ const entrada = path.join(registro, nome);
245
+ if (!diretorioDeVerdade(entrada) || !arquivoDeVerdade(path.join(entrada, 'gitdir')))
246
+ continue;
247
+ let gitdir;
248
+ try {
249
+ gitdir = fs.readFileSync(path.join(entrada, 'gitdir'), 'utf8').trim();
250
+ }
251
+ catch {
252
+ continue;
253
+ }
254
+ if (!gitdir)
255
+ continue;
256
+ const gitDaWorktree = path.resolve(entrada, gitdir);
257
+ if (path.basename(gitDaWorktree) !== '.git' || !arquivoDeVerdade(gitDaWorktree))
258
+ continue;
259
+ try {
260
+ const volta = /^gitdir: (.+)\s*$/.exec(fs.readFileSync(gitDaWorktree, 'utf8').trim());
261
+ if (!volta || real(path.resolve(path.dirname(gitDaWorktree), volta[1].trim())) !== real(entrada))
262
+ continue;
263
+ }
264
+ catch {
265
+ continue;
266
+ }
267
+ const estado = (0, manifest_1.dirEstado)(path.dirname(gitDaWorktree));
268
+ const dir = path.join(estado, 'leases');
269
+ if (!diretorioDeVerdade(estado) || !diretorioDeVerdade(dir) || vistos.has(real(dir)))
270
+ continue;
271
+ vistos.add(real(dir));
272
+ dirs.push(dir);
273
+ }
274
+ return dirs;
275
+ }
276
+ /** Legado e entrada nao confiavel: formato, nome do arquivo e prazo precisam concordar. */
277
+ function lerLeaseLegado(caminho) {
278
+ let fd;
279
+ try {
280
+ fd = fs.openSync(caminho, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
281
+ const stat = fs.fstatSync(fd);
282
+ if (!stat.isFile() || stat.size > 64 * 1024)
283
+ return null;
284
+ const lease = JSON.parse(fs.readFileSync(fd, 'utf8'));
285
+ if (!lease || !nomeLegadoSeguro(lease.nome) ||
286
+ path.basename(caminho) !== `${encodeURIComponent(lease.nome)}.json` || tipoDoLease(lease.nome) === 'exec' ||
287
+ typeof lease.thread !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/.test(lease.thread) ||
288
+ typeof lease.motivo !== 'string' || !Number.isSafeInteger(lease.pid) || lease.pid <= 0)
289
+ return null;
290
+ const inicio = Date.parse(lease.adquiridoEm), fim = Date.parse(lease.expiraEm);
291
+ if (!Number.isFinite(inicio) || !Number.isFinite(fim) ||
292
+ new Date(inicio).toISOString() !== lease.adquiridoEm || new Date(fim).toISOString() !== lease.expiraEm ||
293
+ inicio > Date.now() || fim <= inicio || fim - inicio > exports.TTL_PADRAO_MS + 1_000)
294
+ return null;
295
+ // Nao transporta campos extras (como conducao) de um arquivo gravavel pela worktree.
296
+ return { nome: lease.nome, thread: lease.thread, motivo: '(legado)', pid: lease.pid,
297
+ adquiridoEm: lease.adquiridoEm, expiraEm: lease.expiraEm };
298
+ }
299
+ catch {
300
+ return null;
301
+ }
302
+ finally {
303
+ if (fd !== undefined)
304
+ fs.closeSync(fd);
305
+ }
306
+ }
307
+ /** Descarte da geracao dev/ino/ctime; inode reutilizado nao herda o descarte anterior. */
308
+ function marcaDeLegado(raiz, identidade) {
309
+ return path.join(dirLeases(raiz), `.legado-ignorado-${identidade.dev}-${identidade.ino}-${identidade.ctimeMs}`);
310
+ }
311
+ function legadoIgnorado(raiz, identidade) {
312
+ return arquivoDeVerdade(marcaDeLegado(raiz, identidade));
313
+ }
314
+ function ignorarLegado(raiz, caminho, identidade) {
315
+ try {
316
+ if (!diretorioDeVerdade(path.dirname(caminho)) || !diretorioDeVerdade(path.dirname(path.dirname(caminho))))
317
+ return false;
318
+ const atual = fs.lstatSync(caminho);
319
+ if (!atual.isFile() || atual.dev !== identidade.dev || atual.ino !== identidade.ino || atual.ctimeMs !== identidade.ctimeMs)
320
+ return false;
321
+ fs.writeFileSync(marcaDeLegado(raiz, identidade), '', { flag: 'wx' });
322
+ return true;
323
+ }
324
+ catch {
325
+ return false;
326
+ }
327
+ }
328
+ /** As copias legadas de um nome. O `exec:` nunca morou no legado (I-36). */
329
+ function copiasLegadas(raiz, nome) {
330
+ if (tipoDoLease(nome) === 'exec')
331
+ return [];
332
+ const copias = [];
333
+ for (const dir of dirsLegadosDeLeases(raiz)) {
334
+ const caminho = path.join(dir, `${encodeURIComponent(nome)}.json`);
335
+ if (!arquivoDeVerdade(caminho))
336
+ continue;
337
+ let identidadeLegada;
338
+ try {
339
+ identidadeLegada = fs.lstatSync(caminho);
340
+ }
341
+ catch {
342
+ continue;
343
+ }
344
+ if (!identidadeLegada.isFile() || legadoIgnorado(raiz, identidadeLegada))
345
+ continue;
346
+ const lease = lerLeaseLegado(caminho);
347
+ copias.push({ caminho, lease: lease && lease.nome === nome ? lease : null, identidadeLegada });
348
+ }
349
+ return copias;
154
350
  }
155
351
  /**
156
352
  * I-36 (D3): regrava um lease que a propria operacao segura (renovacao do prazo e conversao do
@@ -163,11 +359,15 @@ function regravarLease(raiz, lease) {
163
359
  fs.writeFileSync(temporario, JSON.stringify(lease, null, 2) + '\n', { encoding: 'utf8', mode: 0o644 });
164
360
  fs.renameSync(temporario, caminho);
165
361
  }
166
- /** Le o lease em disco. Arquivo corrompido conta como lease ausente. */
362
+ /**
363
+ * Le somente o arquivo canonico. Legado pode barrar uma aquisicao, nunca provar posse
364
+ * para ativacao de escrita nem substituir um arquivo canonico que sumiu durante uma liberacao.
365
+ */
167
366
  function lerLease(raiz, nome) {
168
- const caminho = caminhoLease(raiz, nome);
169
- if (!fs.existsSync(caminho))
170
- return null;
367
+ return lerArquivoDeLease(caminhoLease(raiz, nome));
368
+ }
369
+ /** O arquivo de lease do estado canonico; ausente ou corrompido conta como lease ausente. */
370
+ function lerArquivoDeLease(caminho) {
171
371
  try {
172
372
  return JSON.parse(fs.readFileSync(caminho, 'utf8'));
173
373
  }
@@ -175,6 +375,196 @@ function lerLease(raiz, nome) {
175
375
  return null;
176
376
  }
177
377
  }
378
+ /** O criador exclusivo pode estar entre o open(wx) e a escrita do JSON. */
379
+ function escritaRecente(caminho) {
380
+ try {
381
+ return Date.now() - fs.lstatSync(caminho).mtimeMs < 5_000;
382
+ }
383
+ catch {
384
+ return false;
385
+ }
386
+ }
387
+ /** Candidatos e temporarios pertencem ao mesmo namespace de PID do processo local. */
388
+ function lerCandidatoRetomada(dir, nome) {
389
+ const match = /^(\d+)-[a-f0-9-]+\.json(\.tmp)?$/.exec(nome);
390
+ const pid = Number(match?.[1]);
391
+ if (!match || !Number.isSafeInteger(pid) || pid <= 0)
392
+ throw Object.assign(Error('candidato de retomada invalido'), { code: 'EPERM' });
393
+ let fd;
394
+ try {
395
+ fd = fs.openSync(path.join(dir, nome), fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
396
+ const stat = fs.fstatSync(fd);
397
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > 1024)
398
+ throw Object.assign(Error('candidato inseguro'), { code: 'EPERM' });
399
+ // SIGKILL pode deixar JSON parcial: o temporario nao participa da ordem de tickets.
400
+ const ticket = match[2] ? null : JSON.parse(fs.readFileSync(fd, 'utf8')).ticket;
401
+ if (ticket !== null && (!Number.isSafeInteger(ticket) || ticket < 0))
402
+ throw Object.assign(Error('ticket invalido'), { code: 'EPERM' });
403
+ return { nome, pid, ticket, stat };
404
+ }
405
+ catch (e) {
406
+ if (e.code === 'ENOENT')
407
+ return null;
408
+ throw e;
409
+ }
410
+ finally {
411
+ if (fd !== undefined)
412
+ fs.closeSync(fd);
413
+ }
414
+ }
415
+ /** Nomes nao se repetem. Ainda assim, nunca recolhe um inode substituido. */
416
+ function recolherCandidato(dir, c) {
417
+ const arquivo = path.join(dir, c.nome), atual = fs.lstatSync(arquivo, { throwIfNoEntry: false });
418
+ if (atual?.isFile() && atual.nlink === 1 && atual.dev === c.stat.dev && atual.ino === c.stat.ino) {
419
+ try {
420
+ fs.unlinkSync(arquivo);
421
+ }
422
+ catch (e) {
423
+ if (e.code !== 'ENOENT')
424
+ throw e;
425
+ }
426
+ }
427
+ }
428
+ /**
429
+ * Bakery local e portatil, com prazo limitado a 30 min. Publica ticket zero e depois
430
+ * o ticket por rename atomico. Todo retomador participa, inclusive com flock.
431
+ * Candidato recolhido nao autoriza seu processo a continuar: a posse e revalidada
432
+ * na entrada da secao critica e imediatamente antes da conferencia final do lease.
433
+ */
434
+ function comFilaDeRetomada(caminho, retomar) {
435
+ const dir = `${caminho}.retomadas`;
436
+ fs.mkdirSync(dir, { recursive: true });
437
+ if (!fs.lstatSync(dir).isDirectory())
438
+ throw Object.assign(Error('fila insegura'), { code: 'EPERM' });
439
+ const nome = `${process.pid}-${(0, node_crypto_1.randomUUID)()}.json`, arquivo = path.join(dir, nome);
440
+ const prazo = Date.now() + exports.TTL_PADRAO_MS;
441
+ const publicar = (ticket) => {
442
+ const tmp = `${arquivo}.tmp`;
443
+ try {
444
+ fs.writeFileSync(tmp, JSON.stringify({ ticket }), { flag: 'wx' });
445
+ fs.renameSync(tmp, arquivo);
446
+ }
447
+ finally {
448
+ try {
449
+ fs.unlinkSync(tmp);
450
+ }
451
+ catch (e) {
452
+ if (e.code !== 'ENOENT')
453
+ throw e;
454
+ }
455
+ }
456
+ };
457
+ let recolheuExpirado = false;
458
+ const candidatos = () => {
459
+ const resultado = [];
460
+ for (const entrada of fs.readdirSync(dir)) {
461
+ const c = lerCandidatoRetomada(dir, entrada);
462
+ if (!c)
463
+ continue;
464
+ if (Date.now() - c.stat.mtimeMs >= exports.TTL_PADRAO_MS) {
465
+ recolherCandidato(dir, c);
466
+ recolheuExpirado = true;
467
+ continue;
468
+ }
469
+ try {
470
+ process.kill(c.pid, 0);
471
+ }
472
+ catch (e) {
473
+ if (e.code === 'ESRCH') {
474
+ recolherCandidato(dir, c);
475
+ continue;
476
+ }
477
+ // EPERM nao prova morte; o prazo limita a espera por PID inacessivel/reutilizado.
478
+ }
479
+ if (c.ticket !== null)
480
+ resultado.push({ nome: entrada, ticket: c.ticket });
481
+ }
482
+ return resultado;
483
+ };
484
+ try {
485
+ publicar(0);
486
+ const ticket = Math.max(0, ...candidatos().map((c) => c.ticket)) + 1;
487
+ if (recolheuExpirado)
488
+ return 'fila-expirada';
489
+ publicar(ticket);
490
+ const identidade = fs.lstatSync(arquivo);
491
+ const aindaCandidato = () => {
492
+ const atual = fs.lstatSync(arquivo, { throwIfNoEntry: false });
493
+ return Date.now() < prazo && !!atual?.isFile() && atual.nlink === 1 &&
494
+ atual.dev === identidade.dev && atual.ino === identidade.ino;
495
+ };
496
+ const outros = candidatos();
497
+ if (recolheuExpirado || !aindaCandidato())
498
+ return 'fila-expirada';
499
+ if (outros.some((c) => c.nome !== nome && (c.ticket === 0 || c.ticket < ticket ||
500
+ (c.ticket === ticket && c.nome < nome))))
501
+ return 'ocupado';
502
+ return retomar(aindaCandidato);
503
+ }
504
+ finally {
505
+ // Se ficar, outro retomador o recolhe por PID morto ou prazo; preservar resultado e erro original.
506
+ try {
507
+ fs.unlinkSync(arquivo);
508
+ }
509
+ catch { /* Melhor esforco: candidato remanescente sera recolhido. */ }
510
+ // rmdir e atomico e so remove uma pasta vazia; nunca remove candidato concorrente.
511
+ try {
512
+ fs.rmdirSync(dir);
513
+ }
514
+ catch { /* Melhor esforco: nao altera o resultado da retomada. */ }
515
+ }
516
+ }
517
+ /** O fd e a fila exclusiva ficam presos ao inode antigo ate concluir o novo wx. */
518
+ function retomarArquivo(caminho, corpo) {
519
+ let fd;
520
+ try {
521
+ fd = fs.openSync(caminho, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
522
+ const stat = fs.fstatSync(fd);
523
+ if (stat.nlink === 0)
524
+ return 'ocupado';
525
+ if (!stat.isFile() || stat.nlink !== 1)
526
+ return 'indisponivel';
527
+ try {
528
+ const trava = (0, node_child_process_1.spawnSync)('/usr/bin/flock', ['--exclusive', '--nonblock', '3'], { stdio: ['ignore', 'pipe', 'pipe', fd], timeout: 2_000 });
529
+ if (!trava.error && trava.status === 1)
530
+ return 'ocupado';
531
+ }
532
+ catch { /* Transporte ausente ou bloqueado: a fila portatil ainda serializa. */ }
533
+ const descritor = fd;
534
+ return comFilaDeRetomada(caminho, (aindaCandidato) => {
535
+ let atual = null;
536
+ try {
537
+ atual = JSON.parse(fs.readFileSync(descritor, 'utf8'));
538
+ }
539
+ catch { /* corrompido */ }
540
+ if ((atual && !expirado(atual)) || (!atual && Date.now() - fs.fstatSync(descritor).mtimeMs < 5_000))
541
+ return 'ocupado';
542
+ const agoraNoPath = fs.lstatSync(caminho);
543
+ if (!agoraNoPath.isFile() || agoraNoPath.dev !== stat.dev || agoraNoPath.ino !== stat.ino)
544
+ return 'ocupado';
545
+ if (!aindaCandidato())
546
+ return 'fila-expirada';
547
+ // Reconfere imediatamente antes do unlink; a primeira leitura pode ter envelhecido.
548
+ const antesDeApagar = fs.lstatSync(caminho);
549
+ if (!antesDeApagar.isFile() || antesDeApagar.dev !== stat.dev || antesDeApagar.ino !== stat.ino)
550
+ return 'ocupado';
551
+ fs.unlinkSync(caminho);
552
+ fs.writeFileSync(caminho, corpo, { encoding: 'utf8', flag: 'wx' });
553
+ return 'retomado';
554
+ });
555
+ }
556
+ catch (e) {
557
+ if (['ENOENT', 'EEXIST'].includes(e.code ?? ''))
558
+ return 'ocupado';
559
+ if (['ELOOP', 'EACCES', 'EPERM', 'ENOSYS'].includes(e.code ?? ''))
560
+ return 'indisponivel';
561
+ throw e;
562
+ }
563
+ finally {
564
+ if (fd !== undefined)
565
+ fs.closeSync(fd);
566
+ }
567
+ }
178
568
  /** O lease venceu? */
179
569
  function expirado(lease, referencia = new Date()) {
180
570
  const fim = Date.parse(lease.expiraEm);
@@ -277,27 +667,37 @@ function adquirir(raiz, nome, opcoes) {
277
667
  expiraEm: new Date(Date.now() + ttl).toISOString(),
278
668
  };
279
669
  const corpo = JSON.stringify(lease, null, 2) + '\n';
280
- for (const tentativa of [1, 2]) {
281
- try {
282
- // `wx` falha se o arquivo existe: e a atomicidade do lease, sem corrida.
283
- fs.writeFileSync(caminho, corpo, { encoding: 'utf8', flag: 'wx' });
284
- return { ok: true, lease, ocupadoPor: null, tomadoDeVencido: tentativa === 2 };
670
+ // So o legado valido e vivo barra, inclusive quando o MCP desliga a retomada do canonico.
671
+ let tomouLegado = false;
672
+ for (const copia of copiasLegadas(raiz, nome)) {
673
+ if (copia.lease && !expirado(copia.lease)) {
674
+ return { ok: false, lease: null, ocupadoPor: copia.lease, tomadoDeVencido: false };
285
675
  }
286
- catch (e) {
287
- if (e.code !== 'EEXIST')
288
- throw e;
289
- const atual = lerLease(raiz, nome);
290
- if (opcoes.retomarVencido === false || (atual && !expirado(atual))) {
291
- return { ok: false, lease: null, ocupadoPor: atual, tomadoDeVencido: false };
292
- }
293
- // Lease vencido ou corrompido: remove e tenta uma unica vez mais.
294
- try {
295
- fs.unlinkSync(caminho);
296
- }
297
- catch {
298
- /* outra thread removeu antes; a segunda tentativa decide */
299
- }
676
+ // Vencido nao barra: adquirir nunca apaga arquivos da worktree legada.
677
+ if (copia.lease)
678
+ tomouLegado = true;
679
+ }
680
+ try {
681
+ // `wx` falha se o arquivo existe: e a atomicidade do lease, sem corrida.
682
+ fs.writeFileSync(caminho, corpo, { encoding: 'utf8', flag: 'wx' });
683
+ return { ok: true, lease, ocupadoPor: null, tomadoDeVencido: tomouLegado };
684
+ }
685
+ catch (e) {
686
+ if (e.code !== 'EEXIST')
687
+ throw e;
688
+ const atual = lerLease(raiz, nome);
689
+ if (opcoes.retomarVencido === false || (atual && !expirado(atual)) || (!atual && escritaRecente(caminho))) {
690
+ return { ok: false, lease: null, ocupadoPor: atual, tomadoDeVencido: false };
300
691
  }
692
+ const retomada = retomarArquivo(caminho, corpo);
693
+ if (retomada === 'retomado')
694
+ return { ok: true, lease, ocupadoPor: null, tomadoDeVencido: true };
695
+ if (retomada === 'fila-expirada')
696
+ return { ok: false, lease: null, ocupadoPor: lerLease(raiz, nome),
697
+ tomadoDeVencido: false, falhaRetomada: 'lease.resume-unavailable', filaRetomadaExpirada: true };
698
+ if (retomada === 'indisponivel')
699
+ return { ok: false, lease: null, ocupadoPor: lerLease(raiz, nome),
700
+ tomadoDeVencido: false, falhaRetomada: 'lease.resume-unavailable' };
301
701
  }
302
702
  const atual = lerLease(raiz, nome);
303
703
  return { ok: false, lease: null, ocupadoPor: atual, tomadoDeVencido: false };
@@ -305,62 +705,125 @@ function adquirir(raiz, nome, opcoes) {
305
705
  /**
306
706
  * Libera o lease. Sem `forcar`, so a thread que o segura pode liberar: liberar o lease
307
707
  * dos outros e exatamente o bug que o lease existe para impedir.
708
+ *
709
+ * RM-036: release atua no canonico, quando presente. Sem canonico, apenas marca o inode legado
710
+ * como ignorado. Nunca apaga o legado nem limpa as duas origens no mesmo pedido.
308
711
  */
309
712
  function liberar(raiz, nome, thread, forcar = false) {
310
- const atual = lerLease(raiz, nome);
311
- if (!atual) {
713
+ const copias = [];
714
+ const canonico = caminhoLease(raiz, nome);
715
+ if (fs.lstatSync(canonico, { throwIfNoEntry: false })) {
716
+ copias.push({ caminho: canonico, lease: lerArquivoDeLease(canonico) });
717
+ }
718
+ if (copias.length === 0) {
719
+ for (const copia of copiasLegadas(raiz, nome))
720
+ copias.push(copia);
721
+ }
722
+ if (copias.length === 0) {
312
723
  sairDaFila(raiz, nome, thread);
313
724
  return { ok: true, detalhe: `lease ${nome} ja estava livre` };
314
725
  }
315
- if (atual.thread !== thread && !forcar) {
726
+ const soltas = copias.filter((c) => forcar || c.lease?.thread === thread);
727
+ if (soltas.length === 0) {
316
728
  return {
317
729
  ok: false,
318
- detalhe: `lease ${nome} pertence a thread ${atual.thread}; use --forcar para tomar`,
730
+ detalhe: `lease ${nome} pertence a thread ${textoSeguro(copias[0].lease?.thread ?? '(ilegivel)')}; use --forcar para tomar`,
319
731
  };
320
732
  }
321
- fs.unlinkSync(caminhoLease(raiz, nome));
322
- sairDaFila(raiz, nome, atual.thread);
733
+ const removidas = [];
734
+ for (const copia of soltas) {
735
+ try {
736
+ if (copia.identidadeLegada) {
737
+ if (!ignorarLegado(raiz, copia.caminho, copia.identidadeLegada))
738
+ continue;
739
+ }
740
+ else
741
+ fs.unlinkSync(copia.caminho);
742
+ removidas.push(copia);
743
+ }
744
+ catch {
745
+ continue; // Nada saiu: nao anunciar liberacao nem retirar o dono da fila.
746
+ }
747
+ if (copia.lease)
748
+ sairDaFila(raiz, nome, copia.lease.thread);
749
+ }
750
+ if (removidas.length === 0)
751
+ return { ok: false, detalhe: `lease ${nome}: nada removido ou ignorado; consulte ork lease list` };
752
+ const outra = copias.find((c) => !removidas.includes(c));
323
753
  const proximo = proximoDaFila(raiz, nome);
324
754
  const seguinte = proximo ? `; proximo da fila: thread ${proximo.thread}` : '';
325
- return { ok: true, detalhe: `lease ${nome} liberado${seguinte}` };
755
+ const resta = outra ? `; segue a copia da thread ${textoSeguro(outra.lease?.thread ?? '(ilegivel)')}` : '';
756
+ const acao = removidas.every((c) => c.identidadeLegada) ? 'legado ignorado (arquivo preservado)' : 'liberado';
757
+ return { ok: true, detalhe: `lease ${nome} ${acao}${seguinte}${resta}` };
326
758
  }
327
- /** Todos os leases existentes no projeto, pelo nome canonico gravado no arquivo. */
328
- function listarLeases(raiz) {
329
- const leases = [];
330
- const ler = (dir, soExecucao) => {
331
- if (!fs.existsSync(dir))
759
+ /** Os leases do estado canonico e os do legado das worktrees, canonico antes do legado no mesmo nome. */
760
+ function leasesEmDisco(raiz) {
761
+ const saida = [];
762
+ const ler = (dir, legado) => {
763
+ let arquivos;
764
+ try {
765
+ arquivos = fs.readdirSync(dir);
766
+ }
767
+ catch {
332
768
  return;
333
- for (const arquivo of fs.readdirSync(dir)) {
769
+ }
770
+ for (const arquivo of arquivos) {
334
771
  if (!arquivo.endsWith('.json') || arquivo === exports.NOME_ARQUIVO_FILA)
335
772
  continue;
773
+ const caminho = path.join(dir, arquivo);
774
+ if (legado) {
775
+ let identidadeLegada;
776
+ try {
777
+ identidadeLegada = fs.lstatSync(caminho);
778
+ }
779
+ catch {
780
+ continue;
781
+ }
782
+ const lease = arquivoDeVerdade(caminho) ? lerLeaseLegado(caminho) : null;
783
+ // O `exec:` nunca morou no legado (I-36): arquivo de execucao ali nao e lease de versao nenhuma.
784
+ if (lease && identidadeLegada.isFile() && !legadoIgnorado(raiz, identidadeLegada) && tipoDoLease(lease.nome) !== 'exec')
785
+ saida.push({ lease, caminho, legado: dir, identidadeLegada });
786
+ continue;
787
+ }
336
788
  try {
337
- const lease = JSON.parse(fs.readFileSync(path.join(dir, arquivo), 'utf8'));
789
+ const lease = JSON.parse(fs.readFileSync(caminho, 'utf8'));
338
790
  if (!lease || typeof lease.nome !== 'string')
339
791
  continue;
340
- if (soExecucao && tipoDoLease(lease.nome) !== 'exec')
341
- continue;
342
- leases.push(lease);
792
+ saida.push({ lease, caminho, legado: null });
343
793
  }
344
794
  catch {
345
795
  /* arquivo corrompido conta como lease ausente */
346
796
  }
347
797
  }
348
798
  };
349
- // I-36: os do checkout, mais os de execucao do estado canonico quando ele e outro diretorio.
350
- const local = dirLeases(raiz), canonico = dirLeasesDeExecucao(raiz);
351
- const real = (dir) => (fs.existsSync(dir) ? fs.realpathSync(dir) : path.resolve(dir));
352
- ler(local, false);
353
- if (real(local) !== real(canonico))
354
- ler(canonico, true);
355
- return leases.sort((a, b) => a.nome.localeCompare(b.nome));
799
+ ler(dirLeases(raiz), false);
800
+ for (const dir of dirsLegadosDeLeases(raiz))
801
+ ler(dir, true);
802
+ return saida.sort((a, b) => a.lease.nome.localeCompare(b.lease.nome) || Number(a.legado !== null) - Number(b.legado !== null));
803
+ }
804
+ /**
805
+ * Leases do estado canonico e legados validos consultados durante a janela da troca.
806
+ * Vencidos podem aparecer para diagnostico; so os vivos disputam regioes.
807
+ */
808
+ function listarLeases(raiz) {
809
+ return leasesEmDisco(raiz).map((e) => e.lease);
356
810
  }
357
811
  /** Leases ativos (nao vencidos) que colidem com o nome pedido. */
358
812
  function leasesColidentes(raiz, nome, thread) {
359
813
  return listarLeases(raiz).filter((l) => !expirado(l) && l.thread !== thread && leasesColidem(nome, l.nome));
360
814
  }
361
- /** Leases ativos segurados por uma thread. */
815
+ /**
816
+ * Leases ativos segurados por uma thread, um por nome: a copia canonica antes da legada que a
817
+ * versao anterior deixou com o mesmo nome (RM-036).
818
+ */
362
819
  function leasesDaThread(raiz, thread) {
363
- return listarLeases(raiz).filter((l) => l.thread === thread && !expirado(l));
820
+ const nomes = new Set();
821
+ return listarLeases(raiz).filter((l) => {
822
+ if (l.thread !== thread || expirado(l) || nomes.has(l.nome))
823
+ return false;
824
+ nomes.add(l.nome);
825
+ return true;
826
+ });
364
827
  }
365
828
  // ---------------------------------------------------------------------------
366
829
  // Fila por colisao.
@@ -371,23 +834,50 @@ exports.NOME_ARQUIVO_FILA = 'fila.json';
371
834
  function caminhoFila(raiz) {
372
835
  return path.join(dirLeases(raiz), exports.NOME_ARQUIVO_FILA);
373
836
  }
374
- /** Le a fila inteira, em ordem FIFO. Arquivo corrompido conta como fila vazia. */
375
- function lerFila(raiz) {
376
- const caminho = caminhoFila(raiz);
377
- if (!fs.existsSync(caminho))
378
- return [];
837
+ /** Le um arquivo de fila: so os pedidos bem formados. Ausente ou corrompido conta como fila vazia. */
838
+ function lerArquivoDeFila(caminho) {
379
839
  try {
380
840
  const dados = JSON.parse(fs.readFileSync(caminho, 'utf8'));
381
- return Array.isArray(dados) ? dados.sort((a, b) => a.desdeEm.localeCompare(b.desdeEm)) : [];
841
+ if (!Array.isArray(dados))
842
+ return null;
843
+ return dados.filter((p) => !!p && typeof p === 'object' &&
844
+ typeof p.nome === 'string' && typeof p.thread === 'string' &&
845
+ typeof p.desdeEm === 'string');
382
846
  }
383
847
  catch {
384
- return [];
848
+ return null;
385
849
  }
386
850
  }
851
+ /** A espera legada era local ao checkout: nao migra, refaz-se no proximo pedido. */
852
+ function lerFila(raiz) {
853
+ const todos = lerArquivoDeFila(caminhoFila(raiz)) ?? [];
854
+ const vistos = new Set();
855
+ return todos.sort((a, b) => a.desdeEm.localeCompare(b.desdeEm)).filter((p) => {
856
+ const chave = JSON.stringify([p.thread, p.nome]);
857
+ if (vistos.has(chave))
858
+ return false;
859
+ vistos.add(chave);
860
+ return true;
861
+ });
862
+ }
863
+ /**
864
+ * Grava a fila canonica por arquivo temporario e rename (RM-036, D4): a raiz e as worktrees dividem
865
+ * esta fila, e a fila lida pela metade contava como vazia, sumindo com a espera de todos.
866
+ */
387
867
  function gravarFila(raiz, fila) {
388
868
  const caminho = caminhoFila(raiz);
389
869
  fs.mkdirSync(path.dirname(caminho), { recursive: true });
390
- fs.writeFileSync(caminho, JSON.stringify(fila, null, 2) + '\n', 'utf8');
870
+ const temporario = `${caminho}.${process.pid}.${(0, node_crypto_1.randomUUID)()}.tmp`;
871
+ try {
872
+ fs.writeFileSync(temporario, JSON.stringify(fila, null, 2) + '\n', 'utf8');
873
+ fs.renameSync(temporario, caminho);
874
+ }
875
+ finally {
876
+ try {
877
+ fs.unlinkSync(temporario);
878
+ }
879
+ catch { /* ja virou a fila */ }
880
+ }
391
881
  }
392
882
  /** Coloca o pedido na fila (idempotente por thread e nome). Devolve a posicao, 1 = proxima. */
393
883
  function enfileirar(raiz, nome, dados) {
@@ -444,9 +934,20 @@ function adquirirRegiao(raiz, nome, opcoes) {
444
934
  podarThreadsFechadas(raiz, nome, opcoes.thread);
445
935
  }
446
936
  catch { /* a regra de colisao abaixo decide */ }
937
+ // Recusar a segunda copia nao cria espera atras de si mesmo, nem atras de quem ja espera por ela.
938
+ const proprio = copiasLegadas(raiz, nome).find((c) => c.lease?.thread === opcoes.thread && !expirado(c.lease));
939
+ if (proprio?.lease) {
940
+ sairDaFila(raiz, nome, opcoes.thread);
941
+ return { ...base, ok: false, ocupadoPor: proprio.lease, esperando: false, posicaoNaFila: 0,
942
+ colidiuCom: proprio.lease, motivo: 'lease.busy',
943
+ detalhe: `o lease legado "${nome}" ja pertence a thread ${opcoes.thread}`,
944
+ correcao: 'aguarde o prazo do legado ou o fim da janela de 30 min; o arquivo legado e preservado' };
945
+ }
447
946
  const colidentes = leasesColidentes(raiz, nome, opcoes.thread);
448
947
  if (colidentes.length > 0) {
449
948
  const dono = colidentes[0];
949
+ const legado = copiasLegadas(raiz, dono.nome).some((c) => c.lease?.thread === dono.thread &&
950
+ c.lease.adquiridoEm === dono.adquiridoEm);
450
951
  const posicao = enfileirar(raiz, nome, {
451
952
  thread: opcoes.thread,
452
953
  motivo: opcoes.motivo,
@@ -463,8 +964,9 @@ function adquirirRegiao(raiz, nome, opcoes) {
463
964
  motivo: 'lease.busy',
464
965
  detalhe: `a regiao "${nome}" colide com "${dono.nome}", que esta com a thread ${dono.thread} ` +
465
966
  `desde ${dono.adquiridoEm}`,
466
- correcao: `espere a thread ${dono.thread} liberar (posicao ${posicao} na fila), ` +
467
- `ou reduza o glob da sua regiao, ou libere com: ork lease release "${dono.nome}" --forcar`,
967
+ correcao: legado ? 'aguarde o prazo do legado ou o fim da janela de 30 min; o arquivo legado e preservado' :
968
+ `espere a thread ${dono.thread} liberar (posicao ${posicao} na fila), ` +
969
+ `ou reduza o glob da sua regiao, ou libere com: ork lease release ${argumentoDeLease(dono.nome)} --forcar`,
468
970
  };
469
971
  }
470
972
  // Fila vazia para a regiao? Entao adquire. Se ha fila e a vez nao e desta thread,
@@ -506,9 +1008,13 @@ function adquirirRegiao(raiz, nome, opcoes) {
506
1008
  esperando: true,
507
1009
  posicaoNaFila: posicao,
508
1010
  colidiuCom: dono,
509
- motivo: 'lease.busy',
510
- detalhe: `o lease "${nome}" esta com a thread ${dono?.thread ?? '(desconhecida)'}`,
511
- correcao: `espere a vez (posicao ${posicao} na fila) ou: ork lease release "${nome}" --forcar`,
1011
+ falhaRetomada: r.falhaRetomada,
1012
+ filaRetomadaExpirada: r.filaRetomadaExpirada,
1013
+ motivo: r.falhaRetomada ?? 'lease.busy',
1014
+ detalhe: r.filaRetomadaExpirada ? `fila de retomada do lease \"${nome}\" excedeu 30 min; candidatos expirados recolhidos` : r.falhaRetomada ? `retomada indisponivel para o lease "${nome}"; use --forcar para liberar explicitamente` :
1015
+ `o lease "${nome}" esta com a thread ${dono?.thread ?? '(desconhecida)'}`,
1016
+ correcao: r.filaRetomadaExpirada ? 'consulte ork lease list; depois repita a aquisicao' : r.falhaRetomada ? `ork lease release ${argumentoDeLease(nome)} --forcar; depois repita a aquisicao` :
1017
+ `espere a vez (posicao ${posicao} na fila) ou: ork lease release ${argumentoDeLease(nome)} --forcar`,
512
1018
  };
513
1019
  }
514
1020
  sairDaFila(raiz, nome, opcoes.thread);
@@ -527,39 +1033,104 @@ function adquirirRegiao(raiz, nome, opcoes) {
527
1033
  }
528
1034
  /** Texto de `ork lease list`: leases ativos, familias e a fila por colisao. */
529
1035
  function tabelaDeLeases(raiz) {
530
- const leases = listarLeases(raiz);
1036
+ const emDisco = leasesEmDisco(raiz);
531
1037
  const fila = lerFila(raiz);
532
1038
  const linhas = ['Leases do projeto', ''];
533
- if (leases.length === 0) {
1039
+ const principal = (0, estado_thread_1.raizDoEstado)(raiz);
1040
+ if (emDisco.length === 0) {
534
1041
  linhas.push(' Nenhum lease tomado. O merge na base esta liberado.');
535
1042
  }
536
1043
  else {
537
- for (const l of leases) {
1044
+ for (const { lease: l, legado } of emDisco) {
538
1045
  const situacao = expirado(l) ? 'VENCIDO (tomavel)' : 'ativo';
539
- linhas.push(` ${l.nome.padEnd(28)} [${tipoDoLease(l.nome)}] thread ${l.thread.padEnd(18)} ${situacao}`);
1046
+ linhas.push(` ${textoSeguro(l.nome).padEnd(28)} [${tipoDoLease(l.nome)}] thread ${textoSeguro(l.thread).padEnd(18)} ${situacao}`);
540
1047
  linhas.push(` desde ${(0, horario_1.formatarDataHora)(l.adquiridoEm)} ate ${(0, horario_1.formatarDataHora)(l.expiraEm)} (pid ${l.pid})`);
541
- linhas.push(` motivo: ${l.motivo}`);
1048
+ linhas.push(` motivo: ${textoSeguro(l.motivo)}`);
1049
+ // RM-036: a copia antiga e somente lida, ate vencer ou encerrar a janela.
1050
+ if (legado) {
1051
+ const relativo = path.relative(principal, legado);
1052
+ const onde = relativo && !relativo.startsWith('..') && !path.isAbsolute(relativo) ? relativo : legado;
1053
+ linhas.push(` legado: ${textoSeguro(onde)} (vale enquanto vivo, dentro da janela de 30 min da troca)`);
1054
+ }
542
1055
  // I-36: o lease de execucao diz quem conduz, com a mesma linha das outras superficies.
543
1056
  const conducao = (0, conducao_texto_1.conducaoDoLease)(l);
544
1057
  if (conducao)
545
1058
  linhas.push(` ${(0, conducao_texto_1.linhaDeConducao)(conducao)}`);
546
1059
  }
547
1060
  }
1061
+ // Diagnosticos nunca entram em listarLeases: arquivo invalido nao disputa regiao nem prova posse.
1062
+ for (const dir of dirsLegadosDeLeases(raiz)) {
1063
+ let arquivos;
1064
+ try {
1065
+ arquivos = fs.readdirSync(dir);
1066
+ }
1067
+ catch {
1068
+ continue;
1069
+ }
1070
+ for (const arquivo of arquivos.sort()) {
1071
+ const caminho = path.join(dir, arquivo);
1072
+ if (!arquivo.endsWith('.json') || arquivo === exports.NOME_ARQUIVO_FILA ||
1073
+ !arquivoDeVerdade(caminho) || lerLeaseLegado(caminho))
1074
+ continue;
1075
+ try {
1076
+ if (legadoIgnorado(raiz, fs.lstatSync(caminho)))
1077
+ continue;
1078
+ }
1079
+ catch {
1080
+ continue;
1081
+ }
1082
+ let nome;
1083
+ try {
1084
+ nome = decodeURIComponent(arquivo.slice(0, -5));
1085
+ }
1086
+ catch {
1087
+ nome = arquivo.slice(0, -5);
1088
+ }
1089
+ if (tipoDoLease(nome) === 'exec')
1090
+ continue;
1091
+ linhas.push(` legado: ${textoSeguro(arquivo)} INVALIDO ou ILEGIVEL (ignorado)`);
1092
+ linhas.push(' arquivo legado preservado; ignorado sem alterar o lease canonico');
1093
+ }
1094
+ }
548
1095
  linhas.push('');
1096
+ // Consulta nao recolhe candidatos: mostra a fila, inclusive sobras de SIGKILL.
1097
+ const pasta = dirLeases(raiz);
1098
+ const retomadas = fs.existsSync(pasta) ? fs.readdirSync(pasta).filter((n) => n.endsWith('.json.retomadas')).sort() : [];
1099
+ linhas.push(`Fila de retomada: ${retomadas.length === 0 ? 'vazia' : `${retomadas.length} lease(s)`}`);
1100
+ for (const nome of retomadas) {
1101
+ const dir = path.join(pasta, nome);
1102
+ linhas.push(` ${textoSeguro(nome)}`);
1103
+ try {
1104
+ if (!fs.lstatSync(dir).isDirectory())
1105
+ throw Error('fila insegura');
1106
+ for (const entrada of fs.readdirSync(dir).sort()) {
1107
+ const c = lerCandidatoRetomada(dir, entrada);
1108
+ if (!c)
1109
+ continue;
1110
+ const idade = Math.max(0, Date.now() - c.stat.mtimeMs), vencido = idade >= exports.TTL_PADRAO_MS;
1111
+ linhas.push(` ${textoSeguro(c.nome)} pid ${c.pid} ${c.ticket === null ? 'temporario' : `ticket ${c.ticket}`} idade ${Math.floor(idade / 1000)}s`);
1112
+ if (vencido)
1113
+ linhas.push(' lease.resume-unavailable: prazo de 30 min excedido; repita a aquisicao para recolher e consulte ork lease list');
1114
+ }
1115
+ }
1116
+ catch {
1117
+ linhas.push(' lease.resume-unavailable: fila ilegivel ou insegura; inspecione candidatos antes de repetir a aquisicao');
1118
+ }
1119
+ }
549
1120
  const merge = fila.filter((p) => p.tipo === 'main-tree');
550
1121
  const regiao = fila.filter((p) => p.tipo !== 'main-tree');
551
1122
  linhas.push(`Fila de merge (lease ${exports.LEASE_MAIN_TREE}): ${merge.length === 0 ? 'vazia' : `${merge.length} na espera`}`);
552
1123
  merge.forEach((p, i) => {
553
- linhas.push(` ${i + 1}. thread ${p.thread} desde ${(0, horario_1.formatarDesde)(p.desdeEm)} (bloqueada por ${p.bloqueadaPor})`);
1124
+ linhas.push(` ${i + 1}. thread ${textoSeguro(p.thread)} desde ${(0, horario_1.formatarDesde)(p.desdeEm)} (bloqueada por ${textoSeguro(p.bloqueadaPor)})`);
554
1125
  });
555
1126
  linhas.push(`Fila por colisao de regiao: ${regiao.length === 0 ? 'vazia' : `${regiao.length} na espera`}`);
556
1127
  regiao.forEach((p, i) => {
557
- linhas.push(` ${i + 1}. thread ${p.thread} quer ${p.nome}, colide com ${p.colidiuCom} ` +
558
- `(thread ${p.bloqueadaPor}) desde ${(0, horario_1.formatarDesde)(p.desdeEm)}`);
1128
+ linhas.push(` ${i + 1}. thread ${textoSeguro(p.thread)} quer ${textoSeguro(p.nome)}, colide com ${textoSeguro(p.colidiuCom)} ` +
1129
+ `(thread ${textoSeguro(p.bloqueadaPor)}) desde ${(0, horario_1.formatarDesde)(p.desdeEm)}`);
559
1130
  });
560
- if (leases.length > 0 || fila.length > 0)
1131
+ if (emDisco.length > 0 || fila.length > 0)
561
1132
  linhas.push((0, horario_1.legendaDoFuso)());
562
- return linhas.join('\n');
1133
+ return linhas.map(textoSeguro).join('\n');
563
1134
  }
564
1135
  // ---------------------------------------------------------------------------
565
1136
  // RM-037 (rm037noite, defeito 2): thread fechada nao segura regiao nem fila.
@@ -581,19 +1152,25 @@ function threadFechada(raiz, thread) {
581
1152
  * conducao tem ciclo proprio e ja trata thread fechada como sem conducao.
582
1153
  */
583
1154
  function soltarDaThread(raiz, thread) {
584
- const leases = [];
585
- for (const lease of listarLeases(raiz)) {
1155
+ const leases = new Set();
1156
+ // RM-036: a copia canonica e as legadas das worktrees, cada uma pelo proprio arquivo.
1157
+ for (const { lease, caminho, legado, identidadeLegada } of leasesEmDisco(raiz)) {
586
1158
  if (lease.thread !== thread || tipoDoLease(lease.nome) === 'exec')
587
1159
  continue;
588
1160
  // Achado A2 do CHECK 1: rele logo antes de apagar. Outra poda pode ter soltado este lease e uma
589
1161
  // thread viva pode ter adquirido o mesmo nome no meio; apagar pelo caminho da listagem levaria o
590
1162
  // lease dela. So sai o arquivo que ainda e o da thread fechada, com o mesmo carimbo.
591
- const atual = lerLease(raiz, lease.nome);
1163
+ const atual = legado ? (arquivoDeVerdade(caminho) ? lerLeaseLegado(caminho) : null) : lerArquivoDeLease(caminho);
592
1164
  if (!atual || atual.thread !== thread || atual.adquiridoEm !== lease.adquiridoEm)
593
1165
  continue;
594
1166
  try {
595
- fs.unlinkSync(caminhoLease(raiz, lease.nome));
596
- leases.push(lease.nome);
1167
+ if (legado) {
1168
+ if (!identidadeLegada || !ignorarLegado(raiz, caminho, identidadeLegada))
1169
+ continue;
1170
+ }
1171
+ else
1172
+ fs.unlinkSync(caminho);
1173
+ leases.add(lease.nome);
597
1174
  }
598
1175
  catch { /* outra limpeza chegou antes */ }
599
1176
  }
@@ -601,7 +1178,7 @@ function soltarDaThread(raiz, thread) {
601
1178
  const dela = fila.filter((p) => p.thread === thread);
602
1179
  if (dela.length > 0)
603
1180
  gravarFila(raiz, fila.filter((p) => p.thread !== thread));
604
- return { leases, fila: dela.map((p) => p.nome) };
1181
+ return { leases: [...leases], fila: dela.map((p) => p.nome) };
605
1182
  }
606
1183
  /**
607
1184
  * Solta o que a thread fechada deixou e registra no ledger dela: `lease_released` por lease e