@orkastery/cli 0.5.0 → 0.5.3

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 (131) hide show
  1. package/adapters/claude-code/README.md +11 -2
  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/onboarding.md +3 -2
  5. package/adapters/claude-code/commands/ork.md +9 -0
  6. package/adapters/codex/skills/ork/SKILL.md +18 -3
  7. package/adapters/hermes/README.md +1 -0
  8. package/adapters/hermes/bin/ork-network-status.sh +10 -0
  9. package/adapters/hermes/hermes.plugin.json +1 -0
  10. package/adapters/hermes/hitl-ingress/__init__.py +2 -2
  11. package/adapters/hermes/skills/orkastery-devmaster/SKILL.md +6 -5
  12. package/adapters/openclaw/README.md +35 -4
  13. package/adapters/openclaw/dist/hitl-ingress.js +2 -2
  14. package/adapters/openclaw/dist/index.js +47 -12
  15. package/adapters/openclaw/openclaw.plugin.json +5 -1
  16. package/adapters/openclaw/package.json +2 -2
  17. package/adapters/openclaw/src/hitl-ingress.ts +2 -2
  18. package/adapters/openclaw/src/index.ts +50 -12
  19. package/assets/orkmind_bridge.py +117 -9
  20. package/dist/adapters/claude-bg.js +59 -11
  21. package/dist/adapters/codex.js +4 -3
  22. package/dist/agents-md.js +1 -0
  23. package/dist/auditrun.js +6 -2
  24. package/dist/board.js +16 -14
  25. package/dist/branch-de-estado.js +66 -10
  26. package/dist/busca-semantica.js +52 -12
  27. package/dist/canarios-rm057.js +149 -0
  28. package/dist/canarios.js +2 -0
  29. package/dist/ci.js +8 -1
  30. package/dist/claim-lint.js +3 -1
  31. package/dist/claims.js +39 -1
  32. package/dist/company-brain-context.js +158 -22
  33. package/dist/docs.js +110 -3
  34. package/dist/doctor-pulse-cron.js +194 -0
  35. package/dist/doctor-rede.js +149 -0
  36. package/dist/doctor.js +294 -37
  37. package/dist/entrega-pr.js +27 -8
  38. package/dist/esgotamentos.js +181 -0
  39. package/dist/experiencia.js +12 -2
  40. package/dist/fabrica-estado.js +35 -9
  41. package/dist/fabrica-publicar.js +6 -0
  42. package/dist/fechamento.js +20 -66
  43. package/dist/fix.js +3 -3
  44. package/dist/forja.js +31 -7
  45. package/dist/gates.js +5 -1
  46. package/dist/hitl-contract.js +59 -4
  47. package/dist/hitl-curto.js +11 -6
  48. package/dist/hitl-gates.js +5 -0
  49. package/dist/hitl-lote.js +5 -2
  50. package/dist/hitl-resumo.js +33 -1
  51. package/dist/hitl-tempo-parado.js +201 -0
  52. package/dist/hitl-texto-livre.js +5 -5
  53. package/dist/horario.js +27 -0
  54. package/dist/hosts.js +59 -13
  55. package/dist/impedimento.js +205 -0
  56. package/dist/index.js +433 -88
  57. package/dist/indice-vetorial.js +87 -10
  58. package/dist/indice.js +3 -1
  59. package/dist/init.js +28 -5
  60. package/dist/integracoes-locais.js +4 -0
  61. package/dist/intelligence-graph-cli.js +267 -30
  62. package/dist/intelligence-graph-contexto.js +215 -0
  63. package/dist/intelligence-graph-contract.js +71 -12
  64. package/dist/intelligence-graph-extract-md.js +41 -23
  65. package/dist/intelligence-graph-extract-ts.js +181 -11
  66. package/dist/intelligence-graph-extract.js +186 -9
  67. package/dist/intelligence-graph-index.js +205 -24
  68. package/dist/intelligence-graph-parsers.js +6 -4
  69. package/dist/intelligence-graph-repo.js +10 -0
  70. package/dist/leases.js +680 -87
  71. package/dist/ledger-stats.js +15 -1
  72. package/dist/ledger.js +12 -0
  73. package/dist/licoes.js +6 -4
  74. package/dist/liveness.js +2 -1
  75. package/dist/maestro-runtime.js +7 -1
  76. package/dist/manifest.js +44 -1
  77. package/dist/master-nota.js +14 -5
  78. package/dist/master.js +129 -12
  79. package/dist/mcp-artifacts.js +36 -3
  80. package/dist/mcp-git.js +80 -15
  81. package/dist/mcp-grafo-worker.js +93 -0
  82. package/dist/mcp-grafo.js +279 -0
  83. package/dist/mcp-install.js +2 -1
  84. package/dist/mcp-server.js +23 -1
  85. package/dist/mcp-ship.js +2 -0
  86. package/dist/mcp-verify.js +3 -1
  87. package/dist/memoria.js +48 -6
  88. package/dist/network-roadmap.js +195 -20
  89. package/dist/ocupacao.js +17 -5
  90. package/dist/onboarding.js +9 -4
  91. package/dist/orkmind.js +90 -5
  92. package/dist/orquestracao.js +14 -11
  93. package/dist/parado-no-condutor.js +1089 -0
  94. package/dist/phase.js +68 -15
  95. package/dist/policies.js +79 -3
  96. package/dist/portfolio.js +4 -1
  97. package/dist/projeto-alvo.js +5 -1
  98. package/dist/prompts.js +36 -1
  99. package/dist/prova-ativacao.js +414 -0
  100. package/dist/pulse-delivery.js +60 -7
  101. package/dist/pulse.js +99 -3
  102. package/dist/ratelimit.js +20 -4
  103. package/dist/recall.js +3 -1
  104. package/dist/rede-adesao.js +399 -0
  105. package/dist/rede-forja.js +289 -0
  106. package/dist/rede-projetos.js +239 -0
  107. package/dist/rede-status.js +336 -0
  108. package/dist/rede.js +980 -0
  109. package/dist/retry.js +69 -2
  110. package/dist/roadmap-status.js +162 -7
  111. package/dist/runtime-context.js +5 -1
  112. package/dist/runtime-profiles.js +22 -1
  113. package/dist/saida-segura.js +58 -0
  114. package/dist/session-watcher-claude.js +113 -23
  115. package/dist/sessoes-contas.js +187 -0
  116. package/dist/sessoes-inventario.js +6 -12
  117. package/dist/sessoes.js +87 -3
  118. package/dist/ship.js +110 -20
  119. package/dist/thread.js +212 -14
  120. package/dist/util.js +71 -4
  121. package/dist/verify.js +2 -2
  122. package/dist/worktree.js +34 -8
  123. package/eval/casos/onboarding.json +1 -1
  124. package/eval/fixtures/fx-pedido-colado/caso.json +20 -0
  125. package/package.json +7 -3
  126. package/references/definition-of-done.md +1 -1
  127. package/skills/README.md +1 -1
  128. package/skills/core/onboarding/SKILL.md +3 -2
  129. package/skills/core/orkastery-bootstrap/SKILL.md +2 -2
  130. package/skills/phases/master-metrics/SKILL.md +8 -3
  131. package/skills/phases/ship-release/SKILL.md +6 -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;
@@ -78,8 +85,11 @@ exports.adquirirRegiao = adquirirRegiao;
78
85
  exports.tabelaDeLeases = tabelaDeLeases;
79
86
  exports.soltarDaThread = soltarDaThread;
80
87
  exports.soltarThreadFechada = soltarThreadFechada;
88
+ exports.podarRegioesDeThreadsFechadas = podarRegioesDeThreadsFechadas;
81
89
  const fs = __importStar(require("node:fs"));
82
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");
83
93
  const manifest_1 = require("./manifest");
84
94
  const estado_thread_1 = require("./estado-thread");
85
95
  const util_1 = require("./util");
@@ -102,17 +112,21 @@ exports.FAMILIAS_DE_LEASE = {
102
112
  };
103
113
  /** I-36 (D1): prefixo da familia de execucao, `exec:<thread>`. */
104
114
  exports.PREFIXO_EXEC = 'exec';
105
- /** 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
+ */
106
121
  function dirLeases(raiz) {
107
- return path.join((0, manifest_1.dirEstado)(raiz), 'leases');
122
+ return path.join((0, manifest_1.dirEstado)((0, estado_thread_1.raizDoEstado)(raiz)), 'leases');
108
123
  }
109
124
  /**
110
- * I-36: o lease de execucao mora no estado CANONICO do projeto (o mesmo das threads), nunca no
111
- * checkout de quem chamou. Um canal que roda da raiz e outro que roda de uma worktree disputam o
112
- * 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.
113
127
  */
114
128
  function dirLeasesDeExecucao(raiz) {
115
- return path.join((0, manifest_1.dirEstado)((0, estado_thread_1.raizDoEstado)(raiz)), 'leases');
129
+ return dirLeases(raiz);
116
130
  }
117
131
  /**
118
132
  * Nome canonico de um lease de familia tipada: `path:core/src/**`, `service:5173`.
@@ -148,8 +162,191 @@ function alvoDoLease(nome) {
148
162
  * inalterado, o que preserva os leases gravados pelo bloco B1.
149
163
  */
150
164
  function caminhoLease(raiz, nome) {
151
- const dir = tipoDoLease(nome) === 'exec' ? dirLeasesDeExecucao(raiz) : dirLeases(raiz);
152
- 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;
153
350
  }
154
351
  /**
155
352
  * I-36 (D3): regrava um lease que a propria operacao segura (renovacao do prazo e conversao do
@@ -162,11 +359,15 @@ function regravarLease(raiz, lease) {
162
359
  fs.writeFileSync(temporario, JSON.stringify(lease, null, 2) + '\n', { encoding: 'utf8', mode: 0o644 });
163
360
  fs.renameSync(temporario, caminho);
164
361
  }
165
- /** 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
+ */
166
366
  function lerLease(raiz, nome) {
167
- const caminho = caminhoLease(raiz, nome);
168
- if (!fs.existsSync(caminho))
169
- 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) {
170
371
  try {
171
372
  return JSON.parse(fs.readFileSync(caminho, 'utf8'));
172
373
  }
@@ -174,6 +375,196 @@ function lerLease(raiz, nome) {
174
375
  return null;
175
376
  }
176
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
+ }
177
568
  /** O lease venceu? */
178
569
  function expirado(lease, referencia = new Date()) {
179
570
  const fim = Date.parse(lease.expiraEm);
@@ -276,27 +667,37 @@ function adquirir(raiz, nome, opcoes) {
276
667
  expiraEm: new Date(Date.now() + ttl).toISOString(),
277
668
  };
278
669
  const corpo = JSON.stringify(lease, null, 2) + '\n';
279
- for (const tentativa of [1, 2]) {
280
- try {
281
- // `wx` falha se o arquivo existe: e a atomicidade do lease, sem corrida.
282
- fs.writeFileSync(caminho, corpo, { encoding: 'utf8', flag: 'wx' });
283
- 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 };
284
675
  }
285
- catch (e) {
286
- if (e.code !== 'EEXIST')
287
- throw e;
288
- const atual = lerLease(raiz, nome);
289
- if (opcoes.retomarVencido === false || (atual && !expirado(atual))) {
290
- return { ok: false, lease: null, ocupadoPor: atual, tomadoDeVencido: false };
291
- }
292
- // Lease vencido ou corrompido: remove e tenta uma unica vez mais.
293
- try {
294
- fs.unlinkSync(caminho);
295
- }
296
- catch {
297
- /* outra thread removeu antes; a segunda tentativa decide */
298
- }
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 };
299
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' };
300
701
  }
301
702
  const atual = lerLease(raiz, nome);
302
703
  return { ok: false, lease: null, ocupadoPor: atual, tomadoDeVencido: false };
@@ -304,62 +705,125 @@ function adquirir(raiz, nome, opcoes) {
304
705
  /**
305
706
  * Libera o lease. Sem `forcar`, so a thread que o segura pode liberar: liberar o lease
306
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.
307
711
  */
308
712
  function liberar(raiz, nome, thread, forcar = false) {
309
- const atual = lerLease(raiz, nome);
310
- 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) {
311
723
  sairDaFila(raiz, nome, thread);
312
724
  return { ok: true, detalhe: `lease ${nome} ja estava livre` };
313
725
  }
314
- if (atual.thread !== thread && !forcar) {
726
+ const soltas = copias.filter((c) => forcar || c.lease?.thread === thread);
727
+ if (soltas.length === 0) {
315
728
  return {
316
729
  ok: false,
317
- 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`,
318
731
  };
319
732
  }
320
- fs.unlinkSync(caminhoLease(raiz, nome));
321
- 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));
322
753
  const proximo = proximoDaFila(raiz, nome);
323
754
  const seguinte = proximo ? `; proximo da fila: thread ${proximo.thread}` : '';
324
- 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}` };
325
758
  }
326
- /** Todos os leases existentes no projeto, pelo nome canonico gravado no arquivo. */
327
- function listarLeases(raiz) {
328
- const leases = [];
329
- const ler = (dir, soExecucao) => {
330
- 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 {
331
768
  return;
332
- for (const arquivo of fs.readdirSync(dir)) {
769
+ }
770
+ for (const arquivo of arquivos) {
333
771
  if (!arquivo.endsWith('.json') || arquivo === exports.NOME_ARQUIVO_FILA)
334
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
+ }
335
788
  try {
336
- const lease = JSON.parse(fs.readFileSync(path.join(dir, arquivo), 'utf8'));
789
+ const lease = JSON.parse(fs.readFileSync(caminho, 'utf8'));
337
790
  if (!lease || typeof lease.nome !== 'string')
338
791
  continue;
339
- if (soExecucao && tipoDoLease(lease.nome) !== 'exec')
340
- continue;
341
- leases.push(lease);
792
+ saida.push({ lease, caminho, legado: null });
342
793
  }
343
794
  catch {
344
795
  /* arquivo corrompido conta como lease ausente */
345
796
  }
346
797
  }
347
798
  };
348
- // I-36: os do checkout, mais os de execucao do estado canonico quando ele e outro diretorio.
349
- const local = dirLeases(raiz), canonico = dirLeasesDeExecucao(raiz);
350
- const real = (dir) => (fs.existsSync(dir) ? fs.realpathSync(dir) : path.resolve(dir));
351
- ler(local, false);
352
- if (real(local) !== real(canonico))
353
- ler(canonico, true);
354
- 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);
355
810
  }
356
811
  /** Leases ativos (nao vencidos) que colidem com o nome pedido. */
357
812
  function leasesColidentes(raiz, nome, thread) {
358
813
  return listarLeases(raiz).filter((l) => !expirado(l) && l.thread !== thread && leasesColidem(nome, l.nome));
359
814
  }
360
- /** 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
+ */
361
819
  function leasesDaThread(raiz, thread) {
362
- 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
+ });
363
827
  }
364
828
  // ---------------------------------------------------------------------------
365
829
  // Fila por colisao.
@@ -370,23 +834,50 @@ exports.NOME_ARQUIVO_FILA = 'fila.json';
370
834
  function caminhoFila(raiz) {
371
835
  return path.join(dirLeases(raiz), exports.NOME_ARQUIVO_FILA);
372
836
  }
373
- /** Le a fila inteira, em ordem FIFO. Arquivo corrompido conta como fila vazia. */
374
- function lerFila(raiz) {
375
- const caminho = caminhoFila(raiz);
376
- if (!fs.existsSync(caminho))
377
- return [];
837
+ /** Le um arquivo de fila: so os pedidos bem formados. Ausente ou corrompido conta como fila vazia. */
838
+ function lerArquivoDeFila(caminho) {
378
839
  try {
379
840
  const dados = JSON.parse(fs.readFileSync(caminho, 'utf8'));
380
- 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');
381
846
  }
382
847
  catch {
383
- return [];
848
+ return null;
384
849
  }
385
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
+ */
386
867
  function gravarFila(raiz, fila) {
387
868
  const caminho = caminhoFila(raiz);
388
869
  fs.mkdirSync(path.dirname(caminho), { recursive: true });
389
- 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
+ }
390
881
  }
391
882
  /** Coloca o pedido na fila (idempotente por thread e nome). Devolve a posicao, 1 = proxima. */
392
883
  function enfileirar(raiz, nome, dados) {
@@ -443,9 +934,20 @@ function adquirirRegiao(raiz, nome, opcoes) {
443
934
  podarThreadsFechadas(raiz, nome, opcoes.thread);
444
935
  }
445
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
+ }
446
946
  const colidentes = leasesColidentes(raiz, nome, opcoes.thread);
447
947
  if (colidentes.length > 0) {
448
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);
449
951
  const posicao = enfileirar(raiz, nome, {
450
952
  thread: opcoes.thread,
451
953
  motivo: opcoes.motivo,
@@ -462,8 +964,9 @@ function adquirirRegiao(raiz, nome, opcoes) {
462
964
  motivo: 'lease.busy',
463
965
  detalhe: `a regiao "${nome}" colide com "${dono.nome}", que esta com a thread ${dono.thread} ` +
464
966
  `desde ${dono.adquiridoEm}`,
465
- correcao: `espere a thread ${dono.thread} liberar (posicao ${posicao} na fila), ` +
466
- `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`,
467
970
  };
468
971
  }
469
972
  // Fila vazia para a regiao? Entao adquire. Se ha fila e a vez nao e desta thread,
@@ -505,9 +1008,13 @@ function adquirirRegiao(raiz, nome, opcoes) {
505
1008
  esperando: true,
506
1009
  posicaoNaFila: posicao,
507
1010
  colidiuCom: dono,
508
- motivo: 'lease.busy',
509
- detalhe: `o lease "${nome}" esta com a thread ${dono?.thread ?? '(desconhecida)'}`,
510
- 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`,
511
1018
  };
512
1019
  }
513
1020
  sairDaFila(raiz, nome, opcoes.thread);
@@ -526,39 +1033,104 @@ function adquirirRegiao(raiz, nome, opcoes) {
526
1033
  }
527
1034
  /** Texto de `ork lease list`: leases ativos, familias e a fila por colisao. */
528
1035
  function tabelaDeLeases(raiz) {
529
- const leases = listarLeases(raiz);
1036
+ const emDisco = leasesEmDisco(raiz);
530
1037
  const fila = lerFila(raiz);
531
1038
  const linhas = ['Leases do projeto', ''];
532
- if (leases.length === 0) {
1039
+ const principal = (0, estado_thread_1.raizDoEstado)(raiz);
1040
+ if (emDisco.length === 0) {
533
1041
  linhas.push(' Nenhum lease tomado. O merge na base esta liberado.');
534
1042
  }
535
1043
  else {
536
- for (const l of leases) {
1044
+ for (const { lease: l, legado } of emDisco) {
537
1045
  const situacao = expirado(l) ? 'VENCIDO (tomavel)' : 'ativo';
538
- 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}`);
539
1047
  linhas.push(` desde ${(0, horario_1.formatarDataHora)(l.adquiridoEm)} ate ${(0, horario_1.formatarDataHora)(l.expiraEm)} (pid ${l.pid})`);
540
- 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
+ }
541
1055
  // I-36: o lease de execucao diz quem conduz, com a mesma linha das outras superficies.
542
1056
  const conducao = (0, conducao_texto_1.conducaoDoLease)(l);
543
1057
  if (conducao)
544
1058
  linhas.push(` ${(0, conducao_texto_1.linhaDeConducao)(conducao)}`);
545
1059
  }
546
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
+ }
547
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
+ }
548
1120
  const merge = fila.filter((p) => p.tipo === 'main-tree');
549
1121
  const regiao = fila.filter((p) => p.tipo !== 'main-tree');
550
1122
  linhas.push(`Fila de merge (lease ${exports.LEASE_MAIN_TREE}): ${merge.length === 0 ? 'vazia' : `${merge.length} na espera`}`);
551
1123
  merge.forEach((p, i) => {
552
- 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)})`);
553
1125
  });
554
1126
  linhas.push(`Fila por colisao de regiao: ${regiao.length === 0 ? 'vazia' : `${regiao.length} na espera`}`);
555
1127
  regiao.forEach((p, i) => {
556
- linhas.push(` ${i + 1}. thread ${p.thread} quer ${p.nome}, colide com ${p.colidiuCom} ` +
557
- `(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)}`);
558
1130
  });
559
- if (leases.length > 0 || fila.length > 0)
1131
+ if (emDisco.length > 0 || fila.length > 0)
560
1132
  linhas.push((0, horario_1.legendaDoFuso)());
561
- return linhas.join('\n');
1133
+ return linhas.map(textoSeguro).join('\n');
562
1134
  }
563
1135
  // ---------------------------------------------------------------------------
564
1136
  // RM-037 (rm037noite, defeito 2): thread fechada nao segura regiao nem fila.
@@ -580,19 +1152,25 @@ function threadFechada(raiz, thread) {
580
1152
  * conducao tem ciclo proprio e ja trata thread fechada como sem conducao.
581
1153
  */
582
1154
  function soltarDaThread(raiz, thread) {
583
- const leases = [];
584
- 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)) {
585
1158
  if (lease.thread !== thread || tipoDoLease(lease.nome) === 'exec')
586
1159
  continue;
587
1160
  // Achado A2 do CHECK 1: rele logo antes de apagar. Outra poda pode ter soltado este lease e uma
588
1161
  // thread viva pode ter adquirido o mesmo nome no meio; apagar pelo caminho da listagem levaria o
589
1162
  // lease dela. So sai o arquivo que ainda e o da thread fechada, com o mesmo carimbo.
590
- const atual = lerLease(raiz, lease.nome);
1163
+ const atual = legado ? (arquivoDeVerdade(caminho) ? lerLeaseLegado(caminho) : null) : lerArquivoDeLease(caminho);
591
1164
  if (!atual || atual.thread !== thread || atual.adquiridoEm !== lease.adquiridoEm)
592
1165
  continue;
593
1166
  try {
594
- fs.unlinkSync(caminhoLease(raiz, lease.nome));
595
- 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);
596
1174
  }
597
1175
  catch { /* outra limpeza chegou antes */ }
598
1176
  }
@@ -600,7 +1178,7 @@ function soltarDaThread(raiz, thread) {
600
1178
  const dela = fila.filter((p) => p.thread === thread);
601
1179
  if (dela.length > 0)
602
1180
  gravarFila(raiz, fila.filter((p) => p.thread !== thread));
603
- return { leases, fila: dela.map((p) => p.nome) };
1181
+ return { leases: [...leases], fila: dela.map((p) => p.nome) };
604
1182
  }
605
1183
  /**
606
1184
  * Solta o que a thread fechada deixou e registra no ledger dela: `lease_released` por lease e
@@ -634,3 +1212,18 @@ function podarThreadsFechadas(raiz, nome, quem) {
634
1212
  soltarThreadFechada(raiz, thread, 'poda', quem);
635
1213
  }
636
1214
  }
1215
+ /**
1216
+ * RM-037 (fatia 3, defeito 4): a mesma poda, para as conferencias previas do MCP. O `ork_git_commit`
1217
+ * conferia `lerLease` antes do `adquirirRegiao` e recusava com `lease.busy` o lease que a ork-companybrai3
1218
+ * (fechada) deixou, e soltar o lease de outra thread exigia o dono. Lease e fila de thread fechada sao
1219
+ * orfaos: saem aqui, com registro no ledger dela. Lease de thread aberta, vencido ou nao, e o da propria
1220
+ * thread seguem como estao, e a conferencia de quem chama decide. De melhor esforco, como no `adquirirRegiao`.
1221
+ */
1222
+ function podarRegioesDeThreadsFechadas(raiz, nomes, quem) {
1223
+ for (const nome of nomes) {
1224
+ try {
1225
+ podarThreadsFechadas(raiz, nome, quem);
1226
+ }
1227
+ catch { /* a conferencia de quem chama decide */ }
1228
+ }
1229
+ }