@orkastery/cli 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/adapters/README.md +11 -0
  2. package/adapters/claude-code/.claude-plugin/plugin.json +3 -1
  3. package/adapters/claude-code/agents/ork-check.md +1 -1
  4. package/adapters/claude-code/agents/ork-go.md +2 -2
  5. package/adapters/claude-code/agents/ork-goal.md +1 -1
  6. package/adapters/claude-code/agents/ork-master.md +1 -1
  7. package/adapters/claude-code/agents/ork-plan.md +1 -1
  8. package/adapters/claude-code/agents/ork-ship.md +1 -1
  9. package/adapters/claude-code/commands/master.md +11 -8
  10. package/adapters/claude-code/commands/ork.md +8 -0
  11. package/adapters/claude-code/commands/ship.md +2 -2
  12. package/adapters/codex/skills/ork/SKILL.md +12 -1
  13. package/adapters/hermes/README.md +3 -0
  14. package/adapters/hermes/bin/ork-abrir-thread.sh +17 -2
  15. package/adapters/hermes/bin/ork-brain.sh +3 -0
  16. package/adapters/hermes/bin/ork-maestro.sh +4 -1
  17. package/adapters/hermes/bin/ork-network-roadmap.sh +11 -0
  18. package/adapters/hermes/bin/ork-roadmap-status.sh +9 -0
  19. package/adapters/hermes/hermes.plugin.json +2 -0
  20. package/adapters/hermes/hitl-ingress/__init__.py +36 -3
  21. package/adapters/hermes/skills/orkastery-devmaster/SKILL.md +19 -20
  22. package/adapters/openclaw/README.md +34 -3
  23. package/adapters/openclaw/dist/hitl-ingress.js +37 -3
  24. package/adapters/openclaw/dist/index.js +92 -39
  25. package/adapters/openclaw/openclaw.plugin.json +10 -3
  26. package/adapters/openclaw/package.json +2 -2
  27. package/adapters/openclaw/src/hitl-ingress.ts +32 -3
  28. package/adapters/openclaw/src/index.ts +108 -41
  29. package/assets/docs/padroes/documentacao-de-produto.md +2 -2
  30. package/assets/docs/padroes/roadmap-de-produto.md +1 -1
  31. package/assets/docs/produto/_modelo-feature.md +2 -0
  32. package/assets/orkmind_bridge.py +214 -1
  33. package/dist/adapters/claude-bg.js +88 -9
  34. package/dist/adapters/codex-events.js +3 -1
  35. package/dist/board.js +74 -0
  36. package/dist/branch-de-estado.js +5 -4
  37. package/dist/busca-semantica.js +161 -0
  38. package/dist/ci.js +141 -10
  39. package/dist/company-brain-cli.js +27 -2
  40. package/dist/company-brain-context.js +132 -0
  41. package/dist/company-brain-dossie.js +376 -0
  42. package/dist/company-brain-mcp.js +4 -1
  43. package/dist/conducao.js +117 -31
  44. package/dist/decisao-autonoma.js +42 -0
  45. package/dist/docs.js +64 -22
  46. package/dist/doctor.js +33 -0
  47. package/dist/entrega-pr.js +109 -5
  48. package/dist/experiencia-instalacao.js +256 -0
  49. package/dist/experiencia.js +72 -0
  50. package/dist/fabrica-estado.js +43 -10
  51. package/dist/fechamento.js +105 -0
  52. package/dist/fix.js +1 -1
  53. package/dist/forja.js +435 -0
  54. package/dist/gates.js +10 -1
  55. package/dist/hitl-classificacao.js +11 -1
  56. package/dist/hitl-contract.js +50 -7
  57. package/dist/hitl-curto.js +158 -0
  58. package/dist/hitl-gates.js +67 -1
  59. package/dist/hitl-lote.js +30 -15
  60. package/dist/hitl-presentation.js +10 -7
  61. package/dist/hitl-resumo.js +33 -12
  62. package/dist/hitl-texto-livre.js +150 -0
  63. package/dist/hosts.js +86 -1
  64. package/dist/index.js +605 -41
  65. package/dist/indice-vetorial.js +317 -0
  66. package/dist/init.js +13 -0
  67. package/dist/integracoes-locais.js +1 -1
  68. package/dist/intelligence-benchmark-contract.js +570 -0
  69. package/dist/intelligence-graph-cli.js +430 -0
  70. package/dist/intelligence-graph-contract.js +577 -0
  71. package/dist/intelligence-graph-extract-md.js +469 -0
  72. package/dist/intelligence-graph-extract-ts.js +810 -0
  73. package/dist/intelligence-graph-extract.js +369 -0
  74. package/dist/intelligence-graph-index.js +512 -0
  75. package/dist/intelligence-graph-parsers.js +327 -0
  76. package/dist/intelligence-graph-query.js +344 -0
  77. package/dist/intelligence-graph-repo.js +187 -0
  78. package/dist/leases.js +84 -0
  79. package/dist/ledger.js +14 -0
  80. package/dist/maestro-cli.js +10 -1
  81. package/dist/maestro-contract.js +5 -1
  82. package/dist/maestro-discovery.js +16 -2
  83. package/dist/maestro-snapshot.js +6 -2
  84. package/dist/manifest.js +142 -5
  85. package/dist/master-batch.js +3 -2
  86. package/dist/master-digest.js +2 -2
  87. package/dist/master-nota.js +203 -0
  88. package/dist/master.js +4 -0
  89. package/dist/mcp-experiencia.js +164 -0
  90. package/dist/mcp-git.js +36 -10
  91. package/dist/mcp-install.js +3 -1
  92. package/dist/mcp-maestro.js +3 -2
  93. package/dist/mcp-server.js +84 -5
  94. package/dist/memoria.js +147 -1
  95. package/dist/network-roadmap.js +907 -0
  96. package/dist/ocupacao.js +38 -2
  97. package/dist/onboarding.js +113 -7
  98. package/dist/orkmind.js +165 -9
  99. package/dist/phase.js +166 -13
  100. package/dist/policies.js +2 -0
  101. package/dist/projeto-alvo.js +551 -0
  102. package/dist/prompts.js +1 -1
  103. package/dist/pulse-consentimento.js +4 -2
  104. package/dist/pulse-delivery.js +5 -0
  105. package/dist/pulse-resposta.js +674 -49
  106. package/dist/pulse.js +21 -0
  107. package/dist/ratelimit.js +3 -2
  108. package/dist/retry.js +230 -9
  109. package/dist/roadmap-reservas.js +262 -20
  110. package/dist/roadmap-status.js +213 -0
  111. package/dist/runtime-ambiente.js +12 -1
  112. package/dist/session-watcher-claude.js +26 -0
  113. package/dist/sessoes.js +49 -15
  114. package/dist/thread-close.js +3 -0
  115. package/dist/verify.js +5 -4
  116. package/dist/worktree.js +77 -0
  117. package/eval/casos/orchestration-experience-pt-br.json +48 -0
  118. package/eval/casos/orchestration-experience.json +48 -0
  119. package/package.json +1 -1
  120. package/schemas/code-artifact-graph.v1.schema.json +581 -0
  121. package/schemas/graph-benchmark.v1.schema.json +2240 -0
  122. package/schemas/maestro-snapshot.schema.json +23 -0
  123. package/skills/README.md +7 -5
  124. package/skills/core/onboarding/SKILL.md +7 -0
  125. package/skills/core/orchestration-experience/SKILL.md +113 -0
  126. package/skills/core/orchestration-experience-pt-br/SKILL.md +112 -0
  127. package/skills/core/orkastery-bootstrap/SKILL.md +2 -2
  128. package/skills/phases/go-implementation/SKILL.md +2 -0
@@ -1,4 +1,4 @@
1
- """Transporte de fabrica: OrkMind instalado, stdin JSON, sem provider ou embedder."""
1
+ """Transporte de fabrica: OrkMind instalado, stdin JSON; embedder so na operacao embed (I-38)."""
2
2
  from __future__ import annotations
3
3
  import asyncio
4
4
  import contextlib
@@ -6,7 +6,9 @@ import hashlib
6
6
  import io
7
7
  import json
8
8
  import logging
9
+ import math
9
10
  import os
11
+ import re
10
12
  import sys
11
13
 
12
14
 
@@ -188,6 +190,205 @@ def query_contract(request):
188
190
  return {k: list(v) for k, v in tags.items()}, request['collection'], limit
189
191
 
190
192
 
193
+ # I-38 (D1, D4, D8): a chave de embedding chega so ao ambiente desta operacao, com este nome.
194
+ EMBED_KEY_ENV = 'ORKMIND_EMBEDDING_API_KEY'
195
+ EMBED_TIMEOUT_S = 10.0
196
+ EMBED_MAX_TEXTS = 32
197
+ EMBED_MAX_CHARS = 24000
198
+ LOCAL_MAX_TOKENS = 8192
199
+ MODEL_NAME = re.compile(r'[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*')
200
+ # Mesmos padroes da policy segredo_em_prompt, mais URL com credencial: recusados antes da rede.
201
+ SECRET_PATTERNS = [re.compile(p) for p in (
202
+ r'sk-ant-[A-Za-z0-9_-]{16,}', r'sk-or-v1-[A-Za-z0-9]{32,}', r'\bsk-[A-Za-z0-9]{32,}\b',
203
+ r'\bAKIA[0-9A-Z]{16}\b', r'\bgh[pousr]_[A-Za-z0-9]{20,}\b', r'-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----',
204
+ r'(?i)\b(?:api[_-]?key|secret|token|senha|password)\s*[:=]\s*[\'"][^\'"\s]{16,}[\'"]',
205
+ r'\b[a-z][a-z0-9+.-]*://[^\s/:@]+:[^\s/@]+@')]
206
+
207
+
208
+ def embed_contract(request):
209
+ valid = isinstance(request, dict) and set(request) == {'op', 'papel', 'alvo', 'modelo', 'dim', 'textos'}
210
+ textos = request.get('textos') if valid else None
211
+ dim = request.get('dim') if valid else None
212
+ if (not valid or request['papel'] not in ('consulta', 'documento')
213
+ or request['alvo'] not in ('primario', 'fallback')
214
+ or not isinstance(request['modelo'], str) or MODEL_NAME.fullmatch(request['modelo']) is None
215
+ or type(dim) is not int or not 32 <= dim <= 4096
216
+ or not isinstance(textos, list) or not 1 <= len(textos) <= EMBED_MAX_TEXTS
217
+ or any(not isinstance(t, str) or not t.strip() or len(t) > EMBED_MAX_CHARS for t in textos)):
218
+ raise QueryError('memory.embed.invalid')
219
+
220
+
221
+ def model_prefix(modelo, papel):
222
+ """Instrucao de uso do proprio modelo; o conteudo da entrada vai inteiro depois dela."""
223
+ nome = modelo.lower().split('/')[-1]
224
+ if nome.startswith('qwen3-embedding'):
225
+ return ('Instruct: Given a search query, retrieve relevant memory entries that answer the query\nQuery:'
226
+ if papel == 'consulta' else '')
227
+ if re.search(r'(^|[-_])e5([-_]|$)', nome):
228
+ return 'query: ' if papel == 'consulta' else 'passage: '
229
+ return ''
230
+
231
+
232
+ async def embed_primary(modelo, dim, textos):
233
+ if not os.environ.get(EMBED_KEY_ENV, '').strip():
234
+ raise QueryError('embeddings.chave-ausente')
235
+ import httpx
236
+ from orkmind.embeddings.provider import OpenRouterEmbeddingProvider
237
+ provider = OpenRouterEmbeddingProvider(api_key_env=EMBED_KEY_ENV, model=modelo, embedding_dim=dim,
238
+ timeout_s=EMBED_TIMEOUT_S, request_dimensions=True)
239
+ try:
240
+ return await provider.embed_batch(textos)
241
+ except Exception as error:
242
+ # Nunca repassar texto do provider: ele pode ecoar a entrada ou o cabecalho.
243
+ cause = error.__cause__ or error
244
+ raise QueryError('embeddings.timeout' if isinstance(cause, httpx.TimeoutException)
245
+ else 'embeddings.provider-indisponivel') from None
246
+
247
+
248
+ def local_model_dir(modelo):
249
+ """Pasta do modelo no cache local, sem rede. None quando nao foi baixado."""
250
+ os.environ['HF_HUB_OFFLINE'] = '1'
251
+ os.environ['TRANSFORMERS_OFFLINE'] = '1'
252
+ try:
253
+ from huggingface_hub import snapshot_download
254
+ except ImportError:
255
+ raise QueryError('embeddings.dependencia-ausente') from None
256
+ try:
257
+ return snapshot_download(modelo, local_files_only=True)
258
+ except Exception:
259
+ return None
260
+
261
+
262
+ def local_pooling(pasta):
263
+ from pathlib import Path
264
+ modules = Path(pasta, 'modules.json')
265
+ if modules.exists() and any('Dense' in m.get('type', '') for m in json.loads(modules.read_text())):
266
+ raise QueryError('embeddings.dimensao-divergente')
267
+ config = Path(pasta, '1_Pooling', 'config.json')
268
+ modos = json.loads(config.read_text()) if config.exists() else {}
269
+ return ('lasttoken' if modos.get('pooling_mode_lasttoken') else
270
+ 'cls' if modos.get('pooling_mode_cls_token') else 'mean')
271
+
272
+
273
+ class LocalEmbeddingProvider:
274
+ """Fallback local (D4): mesma interface do EmbeddingProvider da biblioteca, offline, em CPU."""
275
+
276
+ def __init__(self, modelo):
277
+ pasta = local_model_dir(modelo)
278
+ if pasta is None:
279
+ raise QueryError('embeddings.local-ausente')
280
+ try:
281
+ import torch
282
+ from transformers import AutoModel, AutoTokenizer
283
+ except ImportError:
284
+ raise QueryError('embeddings.dependencia-ausente') from None
285
+ torch.set_num_threads(min(4, os.cpu_count() or 1))
286
+ self._torch = torch
287
+ self._pooling = local_pooling(pasta)
288
+ self._tokenizer = AutoTokenizer.from_pretrained(
289
+ pasta, local_files_only=True, padding_side='left' if self._pooling == 'lasttoken' else 'right')
290
+ self._model = AutoModel.from_pretrained(pasta, local_files_only=True)
291
+ self._model.eval()
292
+ self._dim = int(self._model.config.hidden_size)
293
+ self._max = min(int(getattr(self._tokenizer, 'model_max_length', LOCAL_MAX_TOKENS) or LOCAL_MAX_TOKENS),
294
+ LOCAL_MAX_TOKENS)
295
+ # Texto acima do contexto do modelo local e embedado pelo comeco e DECLARADO (nunca em silencio).
296
+ self.truncados = []
297
+
298
+ @property
299
+ def dim(self):
300
+ return self._dim
301
+
302
+ async def embed(self, text):
303
+ return (await self.embed_batch([text]))[0]
304
+
305
+ async def embed_batch(self, texts):
306
+ torch = self._torch
307
+ self.truncados = [i for i, ids in enumerate(self._tokenizer(texts, truncation=False)['input_ids'])
308
+ if len(ids) > self._max]
309
+ lote = self._tokenizer(texts, padding=True, truncation=True, max_length=self._max, return_tensors='pt')
310
+ with torch.inference_mode():
311
+ saida = self._model(**lote).last_hidden_state
312
+ if self._pooling == 'lasttoken':
313
+ vetores = saida[:, -1]
314
+ elif self._pooling == 'cls':
315
+ vetores = saida[:, 0]
316
+ else:
317
+ mascara = lote['attention_mask'].unsqueeze(-1).to(saida.dtype)
318
+ vetores = (saida * mascara).sum(1) / mascara.sum(1).clamp(min=1e-9)
319
+ return torch.nn.functional.normalize(vetores, p=2, dim=1).tolist()
320
+
321
+
322
+ async def embed(request):
323
+ """Operacao embed: unica que instancia embedder; nao toca a base nem recebe a DSN."""
324
+ embed_contract(request)
325
+ textos = request['textos']
326
+ if any(p.search(t) for p in SECRET_PATTERNS for t in textos):
327
+ raise QueryError('embeddings.conteudo-recusado')
328
+ prefixo = model_prefix(request['modelo'], request['papel'])
329
+ entrada = [prefixo + t for t in textos]
330
+ truncados = []
331
+ if request['alvo'] == 'primario':
332
+ vetores = await embed_primary(request['modelo'], request['dim'], entrada)
333
+ else:
334
+ local = LocalEmbeddingProvider(request['modelo'])
335
+ vetores = await local.embed_batch(entrada)
336
+ truncados = list(local.truncados)
337
+ # A biblioteca nao confere a dimensao devolvida (D8): a ponte confere vetor a vetor.
338
+ if (not isinstance(vetores, list) or len(vetores) != len(textos)
339
+ or any(not isinstance(v, list) or len(v) != request['dim']
340
+ or not all(isinstance(x, float) and math.isfinite(x) for x in v) for v in vetores)):
341
+ raise QueryError('embeddings.dimensao-divergente')
342
+ return {'alvo': request['alvo'], 'modelo': request['modelo'], 'dim': request['dim'], 'truncados': truncados,
343
+ 'vetores': [[round(x, 7) for x in v] for v in vetores]}
344
+
345
+
346
+ def health_contract(request):
347
+ if not isinstance(request, dict) or set(request) != {'op'}:
348
+ raise QueryError('memory.health.invalid')
349
+
350
+
351
+ async def health(store):
352
+ """Sonda barata (I-38 T4): contagens, versao da biblioteca e dependencias do fallback, sem rede nem torch."""
353
+ import importlib.metadata
354
+ import importlib.util
355
+ # Versao e dependencias sao informacao, nao saude: falha nelas nunca derruba o regime.
356
+ try:
357
+ versao = importlib.metadata.version('orkmind')
358
+ except Exception:
359
+ versao = None
360
+ def presente(modulo):
361
+ try:
362
+ return importlib.util.find_spec(modulo) is not None
363
+ except Exception:
364
+ return False
365
+ dependencias = all(presente(m) for m in ('torch', 'transformers', 'huggingface_hub'))
366
+ return {'contagens': {c: await store.count(c) for c in await store.list_collections()},
367
+ 'orkmind': versao, 'fallback': {'dependencias': dependencias}}
368
+
369
+
370
+ ORK_COLLECTIONS = ('decision', 'handoff', 'rule', 'learning', 'roadmap')
371
+
372
+
373
+ def fts_contract(request):
374
+ valid = isinstance(request, dict) and set(request) == {'op', 'tenant', 'texto'}
375
+ def text(value, maximum):
376
+ return isinstance(value, str) and 0 < len(value.strip()) and len(value) <= maximum and re.search(r'[\x00-\x1f\x7f]', value) is None
377
+ if not valid or not text(request['tenant'], 128) or not text(request['texto'], 2000):
378
+ raise QueryError('memory.fts.invalid')
379
+
380
+
381
+ async def fts(request, store):
382
+ """FTS da biblioteca (I-38 D9) com fronteira de tenant obrigatoria: devolve so ids, na ordem do ranking."""
383
+ # A janela cobre a base inteira: ausencia so conta quando nada ficou de fora do corte.
384
+ limit = await store.count() + 1
385
+ entries = await store.search_by_text(request['texto'], limit=limit)
386
+ if len(entries) >= limit:
387
+ raise QueryError('memory.query.window-saturated')
388
+ return {'ids': [e.id for e in entries if e.collection in ORK_COLLECTIONS
389
+ and request['tenant'] in (e.tags or {}).get('project', [])]}
390
+
391
+
191
392
  async def execute(request, store):
192
393
  from orkmind.core.models import MemoryEntry
193
394
  from orkmind.core.semantic_layer import SemanticLayer
@@ -196,6 +397,12 @@ async def execute(request, store):
196
397
  op = request['op']
197
398
  if op == 'stats':
198
399
  return {c: await store.count(c) for c in await store.list_collections()}
400
+ if op == 'health':
401
+ health_contract(request)
402
+ return await health(store)
403
+ if op == 'fts':
404
+ fts_contract(request)
405
+ return await fts(request, store)
199
406
  if op == 'query':
200
407
  tags, collection, limit = query_contract(request)
201
408
  # GovernedStore conserva validade, anti-injection e visibilidade existentes.
@@ -323,8 +530,14 @@ async def main(request):
323
530
  return {'schema':'orkmind.company-brain-api/v1','state':'conflict','error':'brain.api.invalid'}
324
531
  from orkmind.cli.company_brain import service_request
325
532
  return service_request(request['request'])
533
+ if isinstance(request, dict) and request.get('op') == 'embed':
534
+ return await embed(request)
326
535
  if isinstance(request, dict) and request.get('op') == 'query':
327
536
  query_contract(request)
537
+ if isinstance(request, dict) and request.get('op') == 'health':
538
+ health_contract(request)
539
+ if isinstance(request, dict) and request.get('op') == 'fts':
540
+ fts_contract(request)
328
541
  native_schema_fields()
329
542
  from orkmind.core.config import OrkMindConfig
330
543
  from orkmind.store.factory import create_store
@@ -44,6 +44,7 @@ var __importStar = (this && this.__importStar) || (function () {
44
44
  Object.defineProperty(exports, "__esModule", { value: true });
45
45
  exports.JANELA_CURTA_MAX_MS = exports.NOME_ADAPTER = void 0;
46
46
  exports.ambienteDoPerfil = ambienteDoPerfil;
47
+ exports.semContextoDeDespacho = semContextoDeDespacho;
47
48
  exports.disponivel = disponivel;
48
49
  exports.versao = versao;
49
50
  exports.montarComando = montarComando;
@@ -62,6 +63,8 @@ exports.limparAnsi = limparAnsi;
62
63
  exports.logs = logs;
63
64
  exports.logsDaSessao = logsDaSessao;
64
65
  exports.parar = parar;
66
+ exports.pararAchada = pararAchada;
67
+ exports.acharSessaoNasContas = acharSessaoNasContas;
65
68
  exports.ocupacaoDeContexto = ocupacaoDeContexto;
66
69
  const util_1 = require("../util");
67
70
  const runtime_ambiente_1 = require("../runtime-ambiente");
@@ -82,6 +85,14 @@ function ambienteDoPerfil(perfil) {
82
85
  return (0, runtime_profiles_1.ambienteComPerfil)((0, runtime_ambiente_1.ambienteDeAssinatura)(), perfil);
83
86
  }
84
87
  exports.NOME_ADAPTER = 'claude-bg';
88
+ /** RM-037 (defeitosdeco D-4): o ambiente do `claude --bg` sem canal nem chave do contexto do despacho. */
89
+ function semContextoDeDespacho(base, contexto) {
90
+ const env = { ...base };
91
+ delete env.ORK_CANAL;
92
+ for (const chave of Object.keys(contexto ?? {}))
93
+ delete env[chave];
94
+ return env;
95
+ }
85
96
  /** Consultas do perfil interactive; gates e respostas humanas nunca recebem grant. */
86
97
  const CONSULTAS_MCP = [
87
98
  'mcp__orkastery__ork_thread_status',
@@ -91,8 +102,9 @@ const CONSULTAS_MCP = [
91
102
  'mcp__orkastery__ork_artifact_read',
92
103
  'mcp__orkastery__ork_claims_list',
93
104
  ];
105
+ // RM-037 (defeito 1): a decisao autonoma pelo MCP tem o mesmo grant nos dois runtimes.
94
106
  const MUTACOES_WORKTREE = [
95
- 'mcp__orkastery__ork_artifact_write', 'mcp__orkastery__ork_claim_add',
107
+ 'mcp__orkastery__ork_artifact_write', 'mcp__orkastery__ork_claim_add', 'mcp__orkastery__ork_decision_record',
96
108
  'mcp__orkastery__ork_git_commit', 'mcp__orkastery__ork_verify', 'mcp__orkastery__ork_ship',
97
109
  ];
98
110
  /** Regras nativas de arquivo, nao sandbox de processos. Edit tambem cobre Write. */
@@ -129,6 +141,13 @@ function montarComando(pedido) {
129
141
  args.push('--model', pedido.model);
130
142
  if (pedido.effort)
131
143
  args.push('--effort', pedido.effort);
144
+ // RM-037 (defeitosdeco D-4): o contexto do despacho (identidade, thread, canal) chega POR SESSAO.
145
+ // O `claude --bg` entrega a sessao a um processo reserva do daemon da conta, e o daemon guarda o
146
+ // ambiente de quem o iniciou: pelo ambiente, a sessao nascia com a identidade de outro despacho.
147
+ // Medido em 28/09/2026 (2.1.284): o `env` de `--settings` chega ao Bash da sessao e vence o herdado.
148
+ const doDespacho = pedido.ambienteExtra ?? {};
149
+ if (Object.keys(doDespacho).length)
150
+ args.push('--settings', JSON.stringify({ env: doDespacho }));
132
151
  if (pedido.contextoRuntime) {
133
152
  if (pedido.contextoRuntime.host !== 'claude-code')
134
153
  throw Error('runtime.context.invalid: host Claude esperado');
@@ -136,8 +155,10 @@ function montarComando(pedido) {
136
155
  // Somente o servidor gerado: nao mesclar MCP de outros escopos nesta sessao filha.
137
156
  const permissoes = contexto.permissoesFilho === 'worktree'
138
157
  ? permissoesDaWorktree(pedido.cwd, contexto.permiteEditarProduto && !plano) : null;
139
- // PLAN não implementa: nem commit, nem claim, nem verify, nem ship, em nenhum perfil.
140
- const allow = plano ? [...CONSULTAS_MCP, 'mcp__orkastery__ork_artifact_write'] : permissoes?.allow ?? [...CONSULTAS_MCP];
158
+ // PLAN não implementa: nem commit, nem claim, nem verify, nem ship, em nenhum perfil. Decidir não é
159
+ // implementar, e o PLAN é onde as decisões nascem (RM-037, achado S7 do CHECK): a decisão autônoma fica.
160
+ const allow = plano ? [...CONSULTAS_MCP, 'mcp__orkastery__ork_artifact_write', 'mcp__orkastery__ork_decision_record']
161
+ : permissoes?.allow ?? [...CONSULTAS_MCP];
141
162
  const deny = [...(plano ? ESCRITA_DE_ARQUIVO : []), ...(permissoes?.deny ?? [])];
142
163
  args.push('--plugin-dir', contexto.instalacao, '--strict-mcp-config', '--mcp-config', JSON.stringify({ mcpServers: { orkastery: contexto.servidor } }), '--allowedTools', allow.join(','));
143
164
  if (deny.length)
@@ -255,8 +276,10 @@ function acharSessao(chave, sessoes) {
255
276
  function despachar(pedido) {
256
277
  const comando = montarComando(pedido);
257
278
  let ambiente;
279
+ // D-4: nada do contexto do despacho vai ao ambiente do processo `claude`, para um daemon novo nunca
280
+ // nascer com ele; a identidade herdada ja sai em `ambienteDeAssinatura`, e o canal sai aqui.
258
281
  try {
259
- ambiente = { ...ambienteDoPerfil(pedido.perfil), ...(pedido.ambienteExtra ?? {}) };
282
+ ambiente = semContextoDeDespacho(ambienteDoPerfil(pedido.perfil), pedido.ambienteExtra);
260
283
  }
261
284
  catch (e) {
262
285
  return { ok: false, comando, sessionId: null, verificada: false, stdout: '', stderr: '', erro: e.message };
@@ -497,6 +520,13 @@ const FRASES_DE_AUTH_AUSENTE = [
497
520
  * so vale como auth ausente quando o mesmo texto nao traz frase de cota: a cota, mais especifica, vence.
498
521
  */
499
522
  const FRASES_AMPLAS_DE_AUTH = [/\bcodex login\b/i];
523
+ /**
524
+ * RM-037 (defeitosdeco D-6): modelo inexistente ou sem acesso na conta. Medido na transcricao da
525
+ * sessao af32834f (28/09/2026, `fable-5-1` num perfil de conta sem esse modelo): `error: "model_not_found"` e o texto
526
+ * "There's an issue with the selected model (fable-5-1). It may not exist or you may not have access
527
+ * to it". Frase de sobrecarga ("model is overloaded") nao entra: e falha transitoria da infra.
528
+ */
529
+ const FRASES_DE_MODELO_INACESSIVEL = [/\bmodel_not_found\b/i, /issue with the selected model/i];
500
530
  /**
501
531
  * I-33 (D16): CRITERIO UNICO entre ESGOTAMENTO da conta e RATE LIMIT comum. Decisao do dono em
502
532
  * 19/09/2026 (opcao a do A3 do CHECK aa279e17): esgotamento de cota, credito ou limite do plano
@@ -593,6 +623,9 @@ function naturezaDoLimite(saida, agoraMs = Date.now()) {
593
623
  }
594
624
  function parseFalhaDeConta(saida, agoraMs = Date.now()) {
595
625
  const texto = saida ?? '';
626
+ const modelo = FRASES_DE_MODELO_INACESSIVEL.find((r) => r.test(texto));
627
+ if (modelo)
628
+ return { motivo: 'runtime.model-unavailable', resetEm: null, fonte: 'sem-horario', trecho: trechoDa(texto, modelo) };
596
629
  const auth = FRASES_DE_AUTH_AUSENTE.find((r) => r.test(texto));
597
630
  if (auth)
598
631
  return { motivo: 'runtime.auth-missing', resetEm: null, fonte: 'sem-horario', trecho: trechoDa(texto, auth) };
@@ -645,9 +678,15 @@ function conferirAuth(perfil) {
645
678
  }
646
679
  return { ok: true, detalhe: `claude auth status: loggedIn (${metodo})` };
647
680
  }
648
- /** Comando de anexar a sessao (precisa de TTY, entao o CLI imprime em vez de executar). */
649
- function comandoAttach(sessionId) {
650
- return `claude attach ${sessionId}`;
681
+ /**
682
+ * Comando de anexar a sessao (precisa de TTY, entao o CLI imprime em vez de executar). RM-037
683
+ * (defeitosdeco D-2): sessao de um perfil so e anexada com o `CLAUDE_CONFIG_DIR` daquela conta.
684
+ */
685
+ function comandoAttach(sessionId, configDir) {
686
+ if (!configDir)
687
+ return `claude attach ${sessionId}`;
688
+ const dir = /^[A-Za-z0-9_./-]+$/.test(configDir) ? configDir : `'${configDir.replace(/'/g, `'\\''`)}'`;
689
+ return `CLAUDE_CONFIG_DIR=${dir} claude attach ${sessionId}`;
651
690
  }
652
691
  /**
653
692
  * Transforma o dump de tela que o `claude logs` devolve em texto legivel.
@@ -736,10 +775,50 @@ function logsDaSessao(id, linhas = 60, ambiente = (0, runtime_ambiente_1.ambient
736
775
  /** Para uma sessao em background (o `claude stop` tambem trabalha com o id curto). */
737
776
  function parar(chave, ambiente = (0, runtime_ambiente_1.ambienteDeAssinatura)()) {
738
777
  const sessao = acharSessao(chave, listarSessoes(undefined, true, ambiente));
739
- const id = sessao?.id ?? (sessao?.sessionId ?? chave).slice(0, 8);
740
- const r = exec('claude', ['stop', id], undefined, 60000, ambiente);
778
+ return pararAchada(sessao ?? { sessionId: chave }, ambiente);
779
+ }
780
+ /** Para a sessao ja achada numa conta, com o ambiente dessa conta, sem listar de novo. */
781
+ function pararAchada(sessao, ambiente = (0, runtime_ambiente_1.ambienteDeAssinatura)()) {
782
+ const r = exec('claude', ['stop', sessao.id ?? sessao.sessionId.slice(0, 8)], undefined, 60000, ambiente);
741
783
  return { ok: r.ok, texto: limparAnsi(r.stdout + r.stderr).trim() };
742
784
  }
785
+ /**
786
+ * RM-037 (defeitosdeco D-2): o `claude agents` so lista a sessao para a conta que a despachou, entao
787
+ * uma chave e procurada no ambiente do processo e em cada perfil claude-bg informado, sem repetir
788
+ * diretorio efetivo. Consulta que falha nao vira "nao encontrada": vai para `falhas`.
789
+ */
790
+ function acharSessaoNasContas(chave, perfis) {
791
+ const achadas = [], falhas = [], vistos = new Set();
792
+ // GO-FIX (R5a): cada consulta pode custar dezenas de segundos sob carga. UUID completo e unico em
793
+ // qualquer conta, entao a busca para na primeira que acha; prefixo ou nome consulta todas, para
794
+ // recusar a chave ambigua.
795
+ const unica = UUID_CLAUDE.test(chave);
796
+ for (const perfil of [null, ...perfis.filter(p => p.runtime === 'claude-bg')]) {
797
+ let ambiente, configDir;
798
+ try {
799
+ ambiente = ambienteDoPerfil(perfil);
800
+ configDir = path.resolve((0, runtime_profiles_1.diretorioEfetivo)('claude-bg', perfil, ambiente));
801
+ }
802
+ catch (e) {
803
+ falhas.push(`${perfil?.id ?? 'processo'}: ${e.message}`);
804
+ continue;
805
+ }
806
+ if (vistos.has(configDir))
807
+ continue;
808
+ vistos.add(configDir);
809
+ const consulta = consultarSessoes(undefined, true, ambiente);
810
+ if (!consulta.ok) {
811
+ falhas.push(`${perfil?.id ?? 'processo'}: ${consulta.detalhe}`);
812
+ continue;
813
+ }
814
+ const sessao = acharSessao(chave, consulta.sessoes);
815
+ if (sessao)
816
+ achadas.push({ sessao, perfil, configDir, ambiente });
817
+ if (sessao && unica)
818
+ break;
819
+ }
820
+ return { achadas, falhas };
821
+ }
743
822
  /**
744
823
  * Ocupacao da janela de contexto de uma sessao, entre 0 e 1.
745
824
  *
@@ -12,7 +12,9 @@ const falha = () => ({ classificacao: 'gate_blocked', motivo: 'runtime.unavailab
12
12
  const sucesso = () => ({ classificacao: 'fase_concluida', motivo: null });
13
13
  const humano = () => ({ classificacao: 'human.pending', motivo: 'human.pending' });
14
14
  /** I-33 (D12): motivos da conta so saem de erro estruturado do runtime (`falhaDeContaDoErroCodex`). */
15
- const MOTIVOS_DA_CONTA = ['runtime.quota-exhausted', 'runtime.auth-missing'];
15
+ const MOTIVOS_DA_CONTA = ['runtime.quota-exhausted', 'runtime.auth-missing',
16
+ // GO-FIX (R3, RM-037): o modelo inacessivel tambem so vale do erro estruturado, nunca do texto do agente.
17
+ 'runtime.model-unavailable'];
16
18
  function motivo(v) {
17
19
  if (v === undefined || v === null)
18
20
  return null;
package/dist/board.js CHANGED
@@ -50,6 +50,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
50
50
  exports.perfisDetectados = perfisDetectados;
51
51
  exports.threadsDeTodosOsPerfis = threadsDeTodosOsPerfis;
52
52
  exports.estadosDeSessao = estadosDeSessao;
53
+ exports.vagaDoDespacho = vagaDoDespacho;
54
+ exports.esperarVaga = esperarVaga;
53
55
  exports.planejar = planejar;
54
56
  exports.devolverVagas = devolverVagas;
55
57
  exports.textoDoReap = textoDoReap;
@@ -123,6 +125,78 @@ function estadosDeSessao() {
123
125
  .filter((s) => !!s.sessionId)
124
126
  .map((s) => [s.sessionId, (0, hitl_1.estadoBruto)(s)]));
125
127
  }
128
+ /**
129
+ * RM-037 (rm037defeito, defeito 3): o portao de vaga do `ork phase run`. Com `max_parallel_threads: 5`,
130
+ * um sexto despacho saiu as 09h03 de 29/09/2026 com cinco sessoes vivas: o `phase run` nao consultava
131
+ * vaga nenhuma, e o escalonador so via as sessoes `claude agents` de uma conta.
132
+ *
133
+ * Ocupa vaga a outra thread cuja conducao `exec:<thread>` e de uma sessao viva (a prova que vale para
134
+ * claude-bg de qualquer conta e para codex, liberada por `phase_result`, `sessao_morta` ou
135
+ * `session_superseded`), salvo a sessao parada ou escalada para o humano (`sessaoLivreDaVaga`; achados A1 e
136
+ * N1 do CHECK: a pausa prevista e o verify reprovado nao param a sessao, e ela segue contando). Ocupa tambem o
137
+ * despacho em curso de outra thread (conducao de processo do `phase.run` ou do `retry.run`), senao dois
138
+ * pedidos simultaneos passariam juntos. A conducao da propria thread tem portao proprio. Somente leitura.
139
+ */
140
+ function vagaDoDespacho(carregado, threadId, quando = (0, util_1.agora)(),
141
+ /** S-5 do CHECK 3: o `desde` da tomada deste despacho; despacho em curso de outra thread so conta se veio antes. */
142
+ desdeProprio) {
143
+ const { raiz, manifesto } = carregado;
144
+ const limite = Math.max(1, manifesto.concurrency.max_parallel_threads);
145
+ const staleMin = manifesto.concurrency.stale_after_min;
146
+ const ocupam = [];
147
+ for (const id of (0, thread_1.listarIds)(raiz)) {
148
+ if (id === threadId)
149
+ continue;
150
+ let atual = null;
151
+ try {
152
+ atual = (0, conducao_1.conducaoDaThread)(raiz, id);
153
+ }
154
+ catch {
155
+ continue;
156
+ }
157
+ if (!atual)
158
+ continue;
159
+ const despachando = atual.dono.tipo === 'processo' && (atual.operacao === 'phase.run' || atual.operacao === 'retry.run');
160
+ if (atual.dono.tipo !== 'sessao' && !despachando)
161
+ continue;
162
+ // Dois despachos disputando a ultima vaga nao se recusam um ao outro: quem tomou antes fica com ela.
163
+ if (despachando && desdeProprio && (atual.desde > desdeProprio || (atual.desde === desdeProprio && id > threadId)))
164
+ continue;
165
+ if (atual.dono.tipo === 'sessao') {
166
+ try {
167
+ if ((0, ocupacao_1.sessaoLivreDaVaga)((0, ledger_1.lerLedger)((0, thread_1.dirThread)(raiz, id)), atual.desde, quando, staleMin, atual.dono.sessionId))
168
+ continue;
169
+ }
170
+ catch { /* ledger ilegivel: a conducao viva continua contando */ }
171
+ }
172
+ ocupam.push({ thread: id, fase: atual.fase, desde: atual.desde,
173
+ sessao: atual.dono.tipo === 'sessao' ? atual.dono.sessionId : null,
174
+ runtime: atual.dono.tipo === 'sessao' ? atual.dono.runtime : null });
175
+ }
176
+ if (ocupam.length < limite)
177
+ return null;
178
+ ocupam.sort((a, b) => a.desde.localeCompare(b.desde));
179
+ return {
180
+ limite,
181
+ ocupam,
182
+ detalhe: `concurrency.limite: o projeto ja tem ${ocupam.length} sessao(oes) viva(s) em outras threads e o limite e ${limite} ` +
183
+ `(concurrency.max_parallel_threads): ${ocupam.map((o) => `${o.thread} ${o.fase ?? '-'} ` +
184
+ (o.sessao ? `${o.runtime} ${o.sessao.slice(0, 8)}` : 'despacho em curso')).join('; ')}`,
185
+ correcao: 'espere uma sessao terminar e repita com --esperar <min>; sessao que morreu sem evento sai com ' +
186
+ 'ork conducao status <thread>; ou suba concurrency.max_parallel_threads no orkastery.yaml',
187
+ };
188
+ }
189
+ /** `--esperar`: espera a vaga do projeto ate o prazo. Devolve true quando ha vaga. */
190
+ function esperarVaga(carregado, threadId, esperarMs) {
191
+ const prazo = Date.now() + esperarMs;
192
+ for (;;) {
193
+ if (!vagaDoDespacho(carregado, threadId))
194
+ return true;
195
+ if (Date.now() >= prazo)
196
+ return false;
197
+ (0, conducao_1.dormir)(Math.min(2000, prazo - Date.now()));
198
+ }
199
+ }
126
200
  /**
127
201
  * Escalonador por maquina: decide quem avanca agora e quem espera, com motivo tipado.
128
202
  *
@@ -79,8 +79,8 @@ function pontaLocal(raiz, remoto, branch) {
79
79
  * Traz a ponta da branch. `ponta: null` quando ela ainda nao existe no remoto; sem rede, a ultima
80
80
  * copia local com `atualizado: false`.
81
81
  */
82
- function buscarBranch(raiz, remoto, branch, prefixo, timeoutMs) {
83
- const r = git(raiz, ['fetch', '--quiet', '--no-tags', remoto, `+refs/heads/${branch}:${(0, exports.refRemota)(remoto, branch)}`], { timeoutMs });
82
+ function buscarBranch(raiz, remoto, branch, prefixo, timeoutMs, env) {
83
+ const r = git(raiz, ['fetch', '--quiet', '--no-tags', remoto, `+refs/heads/${branch}:${(0, exports.refRemota)(remoto, branch)}`], { timeoutMs, env });
84
84
  if (r.ok)
85
85
  return { ponta: exigirGit(raiz, ['rev-parse', '--verify', (0, exports.refRemota)(remoto, branch)], prefixo).trim(), atualizado: true };
86
86
  if (/couldn't find remote ref|could not find remote ref/i.test(r.stderr)) {
@@ -102,7 +102,7 @@ function jsonsDaPonta(raiz, ponta, dir, prefixo) {
102
102
  * Grava uma versao nova da branch em cima de `ponta`, sem tocar arvore de trabalho nem indice:
103
103
  * indice temporario, blobs, `write-tree` e `commit-tree`. Push sem forca; recusa volta `false`.
104
104
  */
105
- function gravarNaBranch(raiz, remoto, branch, ponta, mudancas, mensagem, prefixo) {
105
+ function gravarNaBranch(raiz, remoto, branch, ponta, mudancas, mensagem, prefixo, rede = {}) {
106
106
  const indice = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ork-branch-estado-')), 'index');
107
107
  const env = { GIT_INDEX_FILE: indice };
108
108
  try {
@@ -121,7 +121,8 @@ function gravarNaBranch(raiz, remoto, branch, ponta, mudancas, mensagem, prefixo
121
121
  }
122
122
  const arvore = exigirGit(raiz, ['write-tree'], prefixo, { env }).trim();
123
123
  const commit = exigirGit(raiz, ['commit-tree', arvore, ...(ponta ? ['-p', ponta] : []), '-m', mensagem], prefixo).trim();
124
- const push = git(raiz, ['push', '--quiet', remoto, `${commit}:refs/heads/${branch}`]);
124
+ // `rede`: prazo e ambiente so do que vai a rede (RM-037, achado A3: o fechamento nao espera 60 s).
125
+ const push = git(raiz, ['push', '--quiet', remoto, `${commit}:refs/heads/${branch}`], rede);
125
126
  if (push.ok) {
126
127
  git(raiz, ['update-ref', (0, exports.refRemota)(remoto, branch), commit]);
127
128
  return commit;