@orkastery/cli 0.2.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (257) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +86 -65
  3. package/adapters/README.md +30 -15
  4. package/adapters/claude-code/.claude-plugin/plugin.json +11 -5
  5. package/adapters/claude-code/README.md +110 -8
  6. package/adapters/claude-code/commands/check.md +4 -3
  7. package/adapters/claude-code/commands/goal.md +3 -2
  8. package/adapters/claude-code/commands/master.md +3 -2
  9. package/adapters/claude-code/commands/onboarding.md +21 -0
  10. package/adapters/claude-code/commands/ork.md +99 -27
  11. package/adapters/claude-code/hooks/hooks.json +6 -1
  12. package/adapters/claude-code/hooks/ork-guard.js +1 -1
  13. package/adapters/claude-code/hooks/ork-sensor.js +66 -0
  14. package/adapters/codex/skills/ork/SKILL.md +57 -0
  15. package/adapters/hermes/README.md +161 -0
  16. package/adapters/hermes/bin/ork-abrir-thread.sh +2 -0
  17. package/adapters/hermes/bin/ork-brain.sh +4 -0
  18. package/adapters/hermes/bin/ork-hitl-answer.py +82 -0
  19. package/adapters/hermes/bin/ork-maestro.sh +6 -0
  20. package/adapters/hermes/bin/ork-master-enviar.py +84 -0
  21. package/adapters/hermes/bin/ork-pulse-enviar.py +47 -0
  22. package/adapters/hermes/hermes.plugin.json +24 -4
  23. package/adapters/hermes/hitl-ingress/__init__.py +356 -0
  24. package/adapters/hermes/hitl-ingress/plugin.yaml +4 -0
  25. package/adapters/hermes/skills/orkastery-devmaster/SKILL.md +75 -22
  26. package/adapters/openclaw/README.md +136 -27
  27. package/adapters/openclaw/bin/ork-brain.sh +4 -0
  28. package/adapters/openclaw/construir.sh +19 -0
  29. package/adapters/openclaw/dist/hitl-ingress.js +199 -0
  30. package/adapters/openclaw/dist/index.js +437 -0
  31. package/adapters/openclaw/openclaw.plugin.json +39 -134
  32. package/adapters/openclaw/package.json +26 -0
  33. package/adapters/openclaw/src/hitl-ingress.ts +180 -0
  34. package/adapters/openclaw/src/index.ts +460 -0
  35. package/adapters/openclaw/src/tipos-openclaw.d.ts +58 -0
  36. package/adapters/openclaw/tsconfig.json +15 -0
  37. package/assets/docs/markdownlint-cli2.jsonc +22 -0
  38. package/assets/docs/padroes/documentacao-de-produto.md +133 -0
  39. package/assets/docs/padroes/roadmap-de-produto.md +98 -0
  40. package/assets/docs/produto/README.md +14 -0
  41. package/assets/docs/produto/_modelo-feature.md +59 -0
  42. package/assets/docs/roadmap/README.md +14 -0
  43. package/assets/docs/roadmap/_modelo-item.md +73 -0
  44. package/assets/orkmind-native-schema.json +32 -0
  45. package/assets/orkmind_bridge.py +357 -0
  46. package/assets/orkmind_fixture.py +67 -0
  47. package/assets/orkmind_prospective.py +138 -0
  48. package/assets/reference-tariffs-i07.json +23 -0
  49. package/dist/adapters/claude-bg.js +488 -39
  50. package/dist/adapters/codex-controller-sensor.js +449 -0
  51. package/dist/adapters/codex-controller-worker.js +361 -0
  52. package/dist/adapters/codex-controller.js +252 -0
  53. package/dist/adapters/codex-events.js +205 -0
  54. package/dist/adapters/codex-question.js +26 -0
  55. package/dist/adapters/codex-runner.js +133 -0
  56. package/dist/adapters/codex.js +394 -0
  57. package/dist/agents-md.js +84 -0
  58. package/dist/auditoria.js +12 -9
  59. package/dist/auditrun.js +5 -4
  60. package/dist/board.js +174 -23
  61. package/dist/branch-de-estado.js +136 -0
  62. package/dist/canarios-hitl.js +177 -0
  63. package/dist/canarios-i43.js +543 -0
  64. package/dist/canarios-pulse.js +147 -0
  65. package/dist/canarios-sensores.js +129 -0
  66. package/dist/canarios.js +111 -2
  67. package/dist/catalogo.js +9 -0
  68. package/dist/ci.js +223 -0
  69. package/dist/ciclos.js +2 -1
  70. package/dist/claim-lint.js +64 -0
  71. package/dist/claims.js +60 -0
  72. package/dist/company-brain-capture.js +195 -0
  73. package/dist/company-brain-cli.js +123 -0
  74. package/dist/company-brain-client.js +61 -0
  75. package/dist/company-brain-contract.js +166 -0
  76. package/dist/company-brain-journal.js +192 -0
  77. package/dist/company-brain-mcp.js +33 -0
  78. package/dist/company-brain-migration.js +60 -0
  79. package/dist/company-brain-source.js +177 -0
  80. package/dist/company-brain-worker.js +18 -0
  81. package/dist/conducao-texto.js +61 -0
  82. package/dist/conducao.js +876 -0
  83. package/dist/contrato-publico.js +39 -0
  84. package/dist/creation-operation-store.js +251 -0
  85. package/dist/creation-operation.js +148 -0
  86. package/dist/decisao-autonoma.js +183 -0
  87. package/dist/delegation.js +79 -0
  88. package/dist/demo.js +120 -0
  89. package/dist/docs.js +828 -0
  90. package/dist/doctor.js +184 -25
  91. package/dist/entrega-pr.js +117 -0
  92. package/dist/escopo-escrita.js +71 -0
  93. package/dist/estado-thread.js +222 -0
  94. package/dist/evalrunner.js +12 -0
  95. package/dist/fabrica-estado.js +329 -0
  96. package/dist/fabrica-publicar.js +76 -0
  97. package/dist/fix.js +22 -1
  98. package/dist/gates.js +59 -11
  99. package/dist/handoff.js +39 -23
  100. package/dist/hitl-canais.js +333 -0
  101. package/dist/hitl-classificacao.js +131 -0
  102. package/dist/hitl-contract.js +465 -0
  103. package/dist/hitl-estado.js +136 -0
  104. package/dist/hitl-gates.js +554 -0
  105. package/dist/hitl-ingress-receipt.js +382 -0
  106. package/dist/hitl-local-atestado.js +57 -0
  107. package/dist/hitl-local-receipt.js +328 -0
  108. package/dist/hitl-local.js +143 -0
  109. package/dist/hitl-lock.js +139 -0
  110. package/dist/hitl-lote.js +223 -0
  111. package/dist/hitl-native-offer.js +97 -0
  112. package/dist/hitl-native.js +65 -0
  113. package/dist/hitl-presentation.js +209 -0
  114. package/dist/hitl-public-receipt.js +176 -0
  115. package/dist/hitl-resumo.js +209 -0
  116. package/dist/hitl-sessions.js +475 -0
  117. package/dist/hitl.js +711 -0
  118. package/dist/horario.js +269 -0
  119. package/dist/hosts.js +198 -22
  120. package/dist/index.js +1741 -94
  121. package/dist/indice.js +99 -0
  122. package/dist/init.js +28 -3
  123. package/dist/integracoes-locais.js +17 -0
  124. package/dist/leases.js +65 -21
  125. package/dist/ledger-stats.js +272 -0
  126. package/dist/ledger.js +109 -2
  127. package/dist/licoes.js +221 -0
  128. package/dist/liveness.js +218 -0
  129. package/dist/maestro-actions.js +51 -0
  130. package/dist/maestro-authority.js +152 -0
  131. package/dist/maestro-cli.js +97 -0
  132. package/dist/maestro-contract.js +55 -0
  133. package/dist/maestro-discovery.js +132 -0
  134. package/dist/maestro-runtime.js +268 -0
  135. package/dist/maestro-snapshot.js +86 -0
  136. package/dist/maestro-sources.js +224 -0
  137. package/dist/manifest.js +165 -6
  138. package/dist/maquina.js +102 -0
  139. package/dist/master-audit.js +101 -0
  140. package/dist/master-batch.js +43 -0
  141. package/dist/master-digest.js +149 -0
  142. package/dist/master-migracao.js +155 -0
  143. package/dist/master.js +361 -77
  144. package/dist/mcp-artifacts.js +234 -0
  145. package/dist/mcp-git.js +433 -0
  146. package/dist/mcp-install.js +310 -0
  147. package/dist/mcp-maestro.js +25 -0
  148. package/dist/mcp-server.js +469 -0
  149. package/dist/mcp-ship.js +436 -0
  150. package/dist/mcp-verify.js +173 -0
  151. package/dist/memoria-humana.js +207 -0
  152. package/dist/memoria.js +314 -78
  153. package/dist/memory-migration.js +279 -0
  154. package/dist/memory-prospective.js +148 -0
  155. package/dist/modos-migracao.js +191 -0
  156. package/dist/modos.js +151 -15
  157. package/dist/monitor-lock.js +110 -0
  158. package/dist/objective.js +449 -0
  159. package/dist/ocupacao.js +267 -0
  160. package/dist/onboarding.js +303 -0
  161. package/dist/orkmind.js +389 -90
  162. package/dist/orquestracao.js +96 -69
  163. package/dist/phase.js +612 -151
  164. package/dist/playbook-capabilities.js +150 -0
  165. package/dist/playbook-contracts.js +188 -0
  166. package/dist/policies.js +72 -0
  167. package/dist/portfolio-context.js +61 -0
  168. package/dist/portfolio.js +180 -0
  169. package/dist/preflight.js +207 -0
  170. package/dist/process-audit.js +112 -0
  171. package/dist/project-state.js +93 -0
  172. package/dist/prompts.js +25 -5
  173. package/dist/prova-minima.js +112 -0
  174. package/dist/pulse-cadencia.js +163 -0
  175. package/dist/pulse-consentimento.js +260 -0
  176. package/dist/pulse-delivery.js +323 -0
  177. package/dist/pulse-resposta.js +542 -0
  178. package/dist/pulse.js +262 -0
  179. package/dist/ratelimit.js +35 -2
  180. package/dist/recall.js +80 -3
  181. package/dist/redacao-saida.js +45 -0
  182. package/dist/redacao-url.js +66 -0
  183. package/dist/retry.js +640 -118
  184. package/dist/roadmap-reservas.js +243 -0
  185. package/dist/runtime-ambiente.js +38 -0
  186. package/dist/runtime-context.js +215 -0
  187. package/dist/runtime-profiles.js +869 -0
  188. package/dist/runtimes.js +103 -0
  189. package/dist/sandbox.js +9 -0
  190. package/dist/session-events.js +203 -0
  191. package/dist/session-watcher-claude.js +637 -0
  192. package/dist/session-watcher.js +700 -0
  193. package/dist/sessoes-adopt.js +154 -0
  194. package/dist/sessoes-inventario.js +159 -0
  195. package/dist/sessoes.js +16 -35
  196. package/dist/setup.js +691 -0
  197. package/dist/ship.js +168 -19
  198. package/dist/slug.js +1 -1
  199. package/dist/thread-close.js +115 -0
  200. package/dist/thread.js +142 -28
  201. package/dist/tokens.js +1 -1
  202. package/dist/util.js +4 -2
  203. package/dist/verify-sandbox.js +265 -0
  204. package/dist/verify.js +270 -25
  205. package/dist/versao.js +60 -0
  206. package/dist/worktree.js +31 -2
  207. package/dist/write-activation.js +291 -0
  208. package/dist/yaml.js +5 -1
  209. package/eval/casos/master-metrics.json +1 -1
  210. package/eval/casos/onboarding.json +102 -0
  211. package/eval/casos/orkastery-bootstrap.json +37 -3
  212. package/eval/casos/scope-check-capability-map.json +45 -7
  213. package/eval/casos/ship-release.json +3 -3
  214. package/eval/fixtures/b0-slug-e-modos/caso.json +35 -22
  215. package/eval/fixtures/b2-master-log/caso.json +1 -1
  216. package/eval/fixtures/fx-adapter-editado-detectado/caso.json +19 -0
  217. package/eval/fixtures/fx-auto-quiet/caso.json +11 -0
  218. package/eval/fixtures/fx-blanket-approve/caso.json +10 -0
  219. package/eval/fixtures/fx-check-runtime-cruzado/caso.json +20 -0
  220. package/eval/fixtures/fx-codex-dry/caso.json +18 -0
  221. package/eval/fixtures/fx-donewhen-executavel/caso.json +15 -0
  222. package/eval/fixtures/fx-estado-dividido/caso.json +15 -0
  223. package/eval/fixtures/fx-fase-orfa/caso.json +116 -0
  224. package/eval/fixtures/fx-happy/caso.json +6 -4
  225. package/eval/fixtures/fx-hitl-latency/caso.json +20 -0
  226. package/eval/fixtures/fx-indice-reversao/caso.json +25 -0
  227. package/eval/fixtures/fx-listagem-abertas/caso.json +16 -0
  228. package/eval/fixtures/fx-maestro-bootstrap/caso.json +21 -0
  229. package/eval/fixtures/fx-modo-aposentado-escritor/caso.json +21 -0
  230. package/eval/fixtures/fx-modo-aposentado-leitor/caso.json +16 -0
  231. package/eval/fixtures/fx-objective-oscillation/caso.json +14 -0
  232. package/eval/fixtures/fx-omnicanal/caso.json +17 -0
  233. package/eval/fixtures/fx-sensores-runtime/caso.json +17 -0
  234. package/monitor/company-brain.cjs +17 -0
  235. package/monitor/pulse-scope.cjs +45 -0
  236. package/monitor/pulse.cron +18 -0
  237. package/monitor/varredura-pulse.sh +27 -0
  238. package/package.json +28 -7
  239. package/references/definition-of-done.md +2 -2
  240. package/schemas/claims.schema.json +25 -0
  241. package/schemas/company-brain.schema.json +1158 -0
  242. package/schemas/creation-operation.schema.json +365 -0
  243. package/schemas/maestro-snapshot.schema.json +2716 -0
  244. package/skills/README.md +3 -3
  245. package/skills/core/onboarding/SKILL.md +50 -0
  246. package/skills/core/orkastery-bootstrap/SKILL.md +95 -65
  247. package/skills/core/thread-state/SKILL.md +3 -1
  248. package/skills/governance/decision-triage/SKILL.md +4 -4
  249. package/skills/governance/narrative-guardian/SKILL.md +1 -1
  250. package/skills/governance/roadmap-keeper/SKILL.md +1 -1
  251. package/skills/governance/scope-check-capability-map/SKILL.md +17 -7
  252. package/skills/observability/thread-tracing/SKILL.md +1 -1
  253. package/skills/phases/check-quality/SKILL.md +2 -2
  254. package/skills/phases/goal-definition/SKILL.md +1 -1
  255. package/skills/phases/master-metrics/SKILL.md +9 -7
  256. package/skills/phases/plan-specification/SKILL.md +1 -1
  257. package/skills/phases/ship-release/SKILL.md +1 -1
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env python3
2
+ """Entrega uma página de digest com escolhas que chegam ao Hermes como texto humano."""
3
+ import argparse
4
+ import asyncio
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import re
9
+ import sys
10
+
11
+
12
+ def validar(payload, target):
13
+ if not re.fullmatch(r'telegram:-?\d+(?::\d+)?', target):
14
+ raise ValueError('destino Telegram explícito obrigatório')
15
+ if not isinstance(payload, dict) or payload.get('contrato') != 'ork.master-digest-page/v1':
16
+ raise ValueError('contrato de página inválido')
17
+ texto = payload.get('texto')
18
+ if not isinstance(texto, str) or not texto.strip() or len(texto.encode('utf-16-le')) // 2 > 3900:
19
+ raise ValueError('texto ausente ou maior que uma página')
20
+ opcoes = payload.get('opcoes')
21
+ if not isinstance(opcoes, list) or len(opcoes) > 12:
22
+ raise ValueError('opções inválidas')
23
+ if any(not isinstance(opcao, str) or not opcao.strip() or len(opcao) > 200 for opcao in opcoes):
24
+ raise ValueError('opção inválida')
25
+ partes = target.split(':')
26
+ return partes[1], int(partes[2]) if len(partes) == 3 else None
27
+
28
+
29
+ async def entregar(payload, target, bot):
30
+ chat, topico = validar(payload, target)
31
+ # Teclado de texto usa o fluxo humano já autenticado pelo gateway do Hermes.
32
+ # Não depende de um callback desconhecido nem executa comando ao enviar o digest.
33
+ argumentos = {'chat_id': chat, 'text': payload['texto']}
34
+ if topico is not None:
35
+ argumentos['message_thread_id'] = topico
36
+ if payload['opcoes']:
37
+ argumentos['reply_markup'] = {
38
+ 'keyboard': [[{'text': opcao}] for opcao in payload['opcoes']],
39
+ 'resize_keyboard': True,
40
+ 'one_time_keyboard': True,
41
+ 'input_field_placeholder': 'Escolha a classe para ratificar a proposta',
42
+ }
43
+ resposta = await bot.send_message(**argumentos)
44
+ recibo = getattr(resposta, 'message_id', None)
45
+ if not isinstance(recibo, int) or recibo <= 0:
46
+ raise ValueError('Telegram não confirmou a mensagem')
47
+ return {'success': True, 'message_id': recibo, 'platform': 'telegram'}
48
+
49
+
50
+ async def enviar_pelo_hermes(payload, target):
51
+ validar(payload, target)
52
+ home = Path(os.environ.get('HERMES_HOME', Path.home() / '.hermes'))
53
+ agent = Path(os.environ.get('HERMES_AGENT_DIR', home / 'hermes-agent'))
54
+ sys.path.insert(0, str(agent))
55
+ from dotenv import load_dotenv
56
+ load_dotenv(home / '.env', override=False)
57
+ from gateway.config import load_gateway_config, Platform
58
+ from telegram import Bot
59
+ config = load_gateway_config().platforms.get(Platform.TELEGRAM)
60
+ if not config or not config.enabled or not config.token:
61
+ raise ValueError('Telegram não configurado no Hermes')
62
+ async with Bot(token=config.token) as bot:
63
+ return await entregar(payload, target, bot)
64
+
65
+
66
+ def main():
67
+ parser = argparse.ArgumentParser(description=__doc__)
68
+ parser.add_argument('--target', required=True)
69
+ args = parser.parse_args()
70
+ try:
71
+ bruto = sys.stdin.read(65537)
72
+ if len(bruto.encode('utf-8')) > 65536:
73
+ raise ValueError('payload excede o limite')
74
+ recibo = asyncio.run(enviar_pelo_hermes(json.loads(bruto), args.target))
75
+ print(json.dumps(recibo))
76
+ return 0
77
+ except Exception as exc:
78
+ # Configuração, credenciais e resposta bruta nunca entram no erro de transporte.
79
+ print(json.dumps({'success': False, 'erro': type(exc).__name__}))
80
+ return 1
81
+
82
+
83
+ if __name__ == '__main__':
84
+ sys.exit(main())
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env python3
2
+ """Transporte de texto do pulse pela API de mensagem do Hermes, sem inferência."""
3
+ import argparse
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import re
8
+ import sys
9
+
10
+
11
+ def texto_seguro(message):
12
+ message = re.sub(r'(?i)MEDIA\s*:', 'MEDIA [texto]:', message)
13
+ return re.sub(r'(?i)\[\[(?:audio_as_voice|as_document)\]\]', '[diretiva em texto]', message)
14
+
15
+
16
+ def main():
17
+ parser = argparse.ArgumentParser(description=__doc__)
18
+ parser.add_argument('--target', required=True, help='Destino explícito: telegram:chat_id[:topic_id]')
19
+ parser.add_argument('--message', required=True)
20
+ args = parser.parse_args()
21
+ if not re.fullmatch(r'telegram:-?\d+(?::\d+)?', args.target):
22
+ parser.error('use identificador explícito do chat Telegram autorizado')
23
+ home = Path(os.environ.get('HERMES_HOME', Path.home() / '.hermes'))
24
+ agent = Path(os.environ.get('HERMES_AGENT_DIR', home / 'hermes-agent'))
25
+ sys.path.insert(0, str(agent))
26
+ try:
27
+ from dotenv import load_dotenv
28
+ load_dotenv(home / '.env', override=False)
29
+ from tools.send_message_tool import send_message_tool
30
+ # Conteúdo do runtime é texto. Não pode pedir ao host que anexe arquivo local.
31
+ message = texto_seguro(args.message)
32
+ result = send_message_tool({'action': 'send', 'target': args.target, 'message': message})
33
+ data = json.loads(result) if isinstance(result, str) else result
34
+ if (not isinstance(data, dict) or data.get('success') is not True or data.get('skipped')
35
+ or not (data.get('message_id') or data.get('message_ids'))):
36
+ print(json.dumps({'success': False, 'erro': 'Hermes não confirmou a entrega'}))
37
+ return 1
38
+ print(json.dumps({k: data[k] for k in ['success', 'message_id', 'message_ids', 'platform'] if k in data}))
39
+ return 0
40
+ except Exception as exc:
41
+ # Não imprimir configuração, credencial ou resposta bruta de uma API.
42
+ print(json.dumps({'success': False, 'erro': type(exc).__name__}))
43
+ return 1
44
+
45
+
46
+ if __name__ == '__main__':
47
+ sys.exit(main())
@@ -5,17 +5,37 @@
5
5
  "description": "Conducao de looping threads em 6 fases pelo nucleo `ork`. O Hermes vira podio: le a #TAG do pedido, chama o `ork` e apresenta os gates ao humano.",
6
6
  "license": "MIT",
7
7
  "kind": "skill-router",
8
- "skills": ["./skills/orkastery-devmaster"],
8
+ "skills": [
9
+ "./skills/orkastery-devmaster"
10
+ ],
9
11
  "bin": {
10
- "ork-abrir-thread": "./bin/ork-abrir-thread.sh"
12
+ "ork_maestro": "./bin/ork-maestro.sh",
13
+ "ork-abrir-thread": "./bin/ork-abrir-thread.sh",
14
+ "ork_brain": "./bin/ork-brain.sh",
15
+ "ork_gate_answer": "./bin/ork-hitl-answer.py",
16
+ "ork_session_answer": "./bin/ork-hitl-answer.py"
11
17
  },
12
18
  "requires": {
13
19
  "ork": ">=0.1.0",
14
20
  "comando": "{{ork_bin}}"
15
21
  },
16
22
  "conduction": {
17
- "tags": ["#Look", "#Ork", "#Classic", "#Maestro", "#Auto"],
23
+ "tags": [
24
+ "#Classic",
25
+ "#Maestro",
26
+ "#Auto",
27
+ "#Fast"
28
+ ],
29
+ "aposentadas": [
30
+ "#Look",
31
+ "#Ork"
32
+ ],
18
33
  "parse": "ork modos --do-pedido \"<pedido>\"",
19
- "nota": "O host transporta a tag. A validacao contra conduction.allowed_modes e do nucleo."
34
+ "nota": "O host transporta a tag. A validacao contra conduction.allowed_modes e do nucleo. Uma #TAG aposentada recebe recusa tipada modo.aposentado do nucleo, com saida != 0; o host nao a traduz nem a ignora."
35
+ },
36
+ "onboarding": {
37
+ "pauta": "ork onboarding",
38
+ "estado": "ork onboarding show --json",
39
+ "nota": "Etapas e validação são do núcleo; valores secretos somente em ~/.hermes/.env."
20
40
  }
21
41
  }
@@ -0,0 +1,356 @@
1
+ """Ingresso nativo Hermes: callback síncrono, I/O limitado no loop do host.
2
+
3
+ D12: este host é o canal `hermes`. O canal entra no corpo assinado do envelope, não
4
+ num campo solto: Hermes e OpenClaw compartilham transporte, mas têm chaves e identidades
5
+ próprias; sem o canal dentro do HMAC o recibo perderia essa proveniência.
6
+ """
7
+ import asyncio
8
+ import hashlib
9
+ import hmac
10
+ import json
11
+ import logging
12
+ import os
13
+ import re
14
+ import subprocess
15
+ import time
16
+ from datetime import datetime, timezone
17
+
18
+ CANAL = 'hermes'
19
+ COMMAND = re.compile(r'^/ork (gate|session) ([a-zA-Z0-9][a-zA-Z0-9._-]{0,79}) ([a-zA-Z0-9][a-zA-Z0-9._-]{0,79}) ([\s\S]{1,4096})$')
20
+ NATIVE_COMMAND = re.compile(r'^/ork (offer|gate|session) ([a-zA-Z0-9][a-zA-Z0-9._-]{0,79}) ([a-zA-Z0-9][a-zA-Z0-9._-]{0,79})(?: ([\s\S]{1,4096}))?$')
21
+ # I-41 (GO-FIX 1): as duas formas da resposta ao resumo do pulse, que o dono digita sem barra e
22
+ # sem identificador: "P4EJ a" ao resumo e "1a 2c" as perguntas. As MESMAS fontes vivem no nucleo
23
+ # (GRAMATICA_DO_PULSE, core/src/pulse-resposta.ts) e um teste do nucleo confere que continuam
24
+ # iguais. O codigo comeca por letra e tem digito: conversa comum nao tem esta forma, e ele
25
+ # nunca se confunde com a resposta ao lote, que comeca pelo numero da pergunta.
26
+ PULSE_CONSENT = re.compile(r'^[ \t]*(?=[A-HJKMNP-TV-Z][2-9A-HJKMNP-TV-Z]{0,2}[2-9])[A-HJKMNP-TV-Z][2-9A-HJKMNP-TV-Z]{3}[ \t]+[^\r\n]{1,40}$', re.IGNORECASE)
27
+ PULSE_LOTE = re.compile(r'^[ \t]*[0-9]{1,2}[ \t]*[a-zA-Z](?:[ \t,;]*[0-9]{1,2}[ \t]*[a-zA-Z])*[ \t]*[.!]?[ \t]*$')
28
+ # I-50 (RM-039): a tag da cadencia do resumo, sozinha na mensagem. Comeca por '#': nunca se
29
+ # confunde com as outras duas formas, e tag no meio de uma frase continua indo para o assistente.
30
+ PULSE_CADENCIA = re.compile(r'^[ \t]*#OrkPulse(?:On(?:-(?:15|30|60)m)?|Off)[ \t]*[.!]?[ \t]*$', re.IGNORECASE)
31
+ # O endereco do pulse no corpo assinado: nem thread nem pedido. Quem traduz e o nucleo.
32
+ PULSE_ALVO, PULSE_ENDERECO = 'pulse', 'resposta'
33
+ CORE_TIMEOUT = 10
34
+ CONFIRM_TIMEOUT = 3
35
+ MAX_ENTRIES = 1024
36
+ MAX_PENDING = 16
37
+ CONFIRM_TEXT = {
38
+ 'gate': 'Orkastery: resposta ao gate registrada pelo núcleo.',
39
+ 'session': 'Orkastery: resposta entregue à sessão pelo núcleo.',
40
+ }
41
+ logger = logging.getLogger(__name__)
42
+ # Só metadados: nunca guardar resposta, chave, stdout ou exceção nestes registros.
43
+ _entries = {}
44
+ _tasks = set()
45
+
46
+
47
+ def _allowed(name, value):
48
+ return value in {s.strip() for s in os.environ.get(name, '').split(',') if s.strip()}
49
+
50
+
51
+ def _status(entry=None, replay=False):
52
+ effect = entry['effect'] if entry else 'unconfirmed'
53
+ confirmation = entry['confirmation'] if entry else 'not_attempted'
54
+ return {'action': 'skip', 'reason': f'ork.hitl: effect={effect}; confirmation={confirmation}',
55
+ 'effect': effect, 'confirmation': confirmation, 'replay': replay}
56
+
57
+
58
+ def _is_pulse(text):
59
+ return '\x00' not in text and bool(PULSE_CONSENT.fullmatch(text) or PULSE_LOTE.fullmatch(text)
60
+ or PULSE_CADENCIA.fullmatch(text))
61
+
62
+
63
+ def _receipt_ok(receipt, operation, pedido):
64
+ if operation == 'pulse':
65
+ # O texto que volta ao dono e montado pelo nucleo; este adaptador so o transporta.
66
+ return (isinstance(receipt, dict) and receipt.get('ok') is True
67
+ and receipt.get('contrato') == 'ork.pulse-resposta/v1'
68
+ and isinstance(receipt.get('mensagem'), str) and bool(receipt['mensagem'])
69
+ and type(receipt.get('repetida')) is bool)
70
+ states = {'aprovado', 'recusado', 'aguardando'} if operation == 'gate' else {'entregue'}
71
+ return (isinstance(receipt, dict) and receipt.get('ok') is True
72
+ and receipt.get('pedidoId') == pedido and receipt.get('estado') in states
73
+ and type(receipt.get('repetida')) is bool
74
+ and (operation == 'gate' or isinstance(receipt.get('sessionId'), str)
75
+ and bool(receipt['sessionId'])))
76
+
77
+
78
+ def _call_core(argv, envelope, root, env):
79
+ return subprocess.run(argv, input=json.dumps(envelope, ensure_ascii=False), cwd=root,
80
+ env=env, text=True, encoding='utf-8', capture_output=True,
81
+ timeout=CORE_TIMEOUT, check=False)
82
+
83
+
84
+ async def _deliver(entry, msg, operation, pedido, argv, envelope, root, env):
85
+ try:
86
+ # A espera no executor também tem orçamento. Cancelamento não prova rollback.
87
+ entry['effect'] = 'unknown'
88
+ result = await asyncio.wait_for(
89
+ asyncio.to_thread(_call_core, argv, envelope, root, env), CORE_TIMEOUT + 1)
90
+ receipt = json.loads(result.stdout) if result.returncode == 0 else None
91
+ if not _receipt_ok(receipt, operation, pedido):
92
+ return
93
+ entry['effect'] = 'confirmed'
94
+ if receipt['repetida']:
95
+ # Após restart não sabemos se o canal já recebeu. Nunca reenviar às cegas.
96
+ entry['confirmation'] = 'unknown'
97
+ return
98
+ entry['confirmation'] = 'unknown'
99
+ reply = receipt['mensagem'] if operation == 'pulse' else CONFIRM_TEXT[operation]
100
+ sent = await asyncio.wait_for(msg.reply_text(
101
+ reply, do_quote=False, parse_mode=None,
102
+ read_timeout=CONFIRM_TIMEOUT, write_timeout=CONFIRM_TIMEOUT,
103
+ connect_timeout=CONFIRM_TIMEOUT, pool_timeout=CONFIRM_TIMEOUT), CONFIRM_TIMEOUT)
104
+ from telegram import Message
105
+ if (isinstance(sent, Message) and type(sent.message_id) is int and sent.message_id > 0
106
+ and sent.chat.id == msg.chat.id and sent.from_user.id == msg.get_bot().id
107
+ and sent.from_user.is_bot is True and sent.text == reply):
108
+ entry['confirmation'] = 'sent'
109
+ except (Exception, asyncio.CancelledError):
110
+ # Timeout/exceção/recibo inválido: efeito pode existir; canal pode ter recebido.
111
+ # Nunca ecoar detalhes do CLI/PTB, nem transformar incerteza em sucesso.
112
+ pass
113
+ finally:
114
+ entry['done'] = True
115
+ logger.info('ork.hitl: ref=%s effect=%s confirmation=%s',
116
+ entry['ref'], entry['effect'], entry['confirmation'])
117
+
118
+
119
+ def _ingress(*, event, gateway, **_kwargs):
120
+ text = getattr(event, 'text', '')
121
+ if not isinstance(text, str):
122
+ return None
123
+ pulse = not text.startswith('/ork ') and _is_pulse(text)
124
+ if not text.startswith('/ork ') and not pulse:
125
+ return None
126
+ if getattr(getattr(event, 'source', None), 'platform', None) and event.source.platform.value != 'telegram':
127
+ # O resumo do pulse sai pelo Telegram; a resposta a ele so e reconhecida la.
128
+ return None if pulse else _native_ingress(event, gateway, _kwargs.get('session_store'))
129
+ # invoke_hook instalado NÃO resolve coroutine. Todo caminho /ork, e a resposta ao pulse, retorna skip.
130
+ try:
131
+ from gateway.platforms.base import MessageEvent
132
+ from telegram import Message
133
+ if not isinstance(event, MessageEvent) or getattr(event, 'internal', False):
134
+ return _status()
135
+ msg, source = event.raw_message, event.source
136
+ if (not isinstance(msg, Message) or source.platform.value != 'telegram'
137
+ or gateway._is_user_authorized(source) is not True):
138
+ return _status()
139
+ user, chat = str(msg.from_user.id), str(msg.chat.id)
140
+ bot = os.environ.get('ORK_HITL_TELEGRAM_BOT_ID', '')
141
+ if (msg.from_user.is_bot is not False or not bot or str(msg.get_bot().id) != bot
142
+ or user == bot or getattr(msg, 'forward_origin', None) or getattr(msg, 'sender_chat', None)
143
+ or str(source.user_id) != user or str(source.chat_id) != chat
144
+ or type(msg.message_id) is not int or msg.message_id <= 0
145
+ or str(event.message_id) != str(msg.message_id) or msg.text != text
146
+ or not _allowed('ORK_HITL_TELEGRAM_USERS', user)
147
+ or not _allowed('ORK_HITL_TELEGRAM_CHATS', chat)):
148
+ return _status()
149
+ if pulse:
150
+ # O dono respondeu ao resumo ou ao lote. O texto inteiro e a resposta, e o endereco
151
+ # assinado e o do pulse: este adaptador nao conhece pedido e nao escolhe nada.
152
+ operation, thread, pedido, answer = 'pulse', PULSE_ALVO, PULSE_ENDERECO, text
153
+ else:
154
+ match = COMMAND.fullmatch(text)
155
+ if not match or '\x00' in text:
156
+ return _status()
157
+ operation, thread, pedido, answer = match.groups()
158
+ now = datetime.now(timezone.utc)
159
+ age = (now - msg.date).total_seconds()
160
+ # FX1: este canal assina com a SUA chave. ORK_HITL_INGRESS_KEY fica so para o
161
+ # envelope v1 legado, que este adaptador nao produz mais.
162
+ key = os.environ.get('ORK_HITL_INGRESS_KEY_HERMES', '')
163
+ root = os.environ.get('ORK_HITL_ROOT', '')
164
+ if not 0 <= age <= 60 or len(key.encode()) < 32 or not os.path.isabs(root):
165
+ return _status()
166
+ envelope = {'resposta': answer, 'canal': CANAL, 'origem': 'telegram', 'por': f'telegram:{user}',
167
+ 'mensagem': f'telegram:{chat}:{msg.message_id}',
168
+ 'recebidoEm': msg.date.astimezone(timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z')}
169
+ # FX5: `conta` tem posicao fixa no corpo v2, e e `None` neste canal: a allowlist do
170
+ # Hermes e de usuario e chat, nao de conta. Declarar conta aqui seria recusado pelo
171
+ # nucleo, e omitir a posicao mudaria a aridade do corpo assinado.
172
+ body = ['ork.hitl-answer/v2', thread, pedido, CANAL, None, envelope['origem'], envelope['por'],
173
+ envelope['mensagem'], envelope['recebidoEm'], answer]
174
+ envelope['prova'] = hmac.new(key.encode(), json.dumps(body, ensure_ascii=False, separators=(',', ':')).encode(), hashlib.sha256).hexdigest()
175
+ # Cada subcomando tem a sua flag de stdin: `gate answer` exige --resposta-stdin e
176
+ # `sessions answer` exige --stdin. Mandar a mesma flag para os dois fazia toda
177
+ # resposta de sessao ser recusada pelo proprio CLI, antes de qualquer conferencia.
178
+ if operation == 'pulse':
179
+ argv = [os.environ.get('ORK_BIN', 'ork'), 'pulse', 'responder', '--resposta-stdin',
180
+ '--origem', 'telegram', '--canal', CANAL,
181
+ '--por', envelope['por'], '--mensagem', envelope['mensagem']]
182
+ else:
183
+ argv = [os.environ.get('ORK_BIN', 'ork'), 'gate' if operation == 'gate' else 'sessions',
184
+ 'answer', thread, pedido,
185
+ '--resposta-stdin' if operation == 'gate' else '--stdin',
186
+ '--origem', 'telegram', '--canal', CANAL,
187
+ '--por', envelope['por'], '--mensagem', envelope['mensagem']]
188
+ # Usar o loop que possui o bot HTTP; nunca asyncio.run/thread com esse bot.
189
+ loop = asyncio.get_running_loop()
190
+ identity = (root, bot, chat, str(msg.message_id))
191
+ fingerprint = operation + ':' + envelope['prova']
192
+ previous = _entries.get(identity)
193
+ if previous:
194
+ return _status(previous, replay=True) if hmac.compare_digest(previous['fingerprint'], fingerprint) else _status()
195
+ for identity_old, old in list(_entries.items()):
196
+ if old['done'] and time.monotonic() >= old['expires']:
197
+ del _entries[identity_old]
198
+ if len(_entries) >= MAX_ENTRIES or len(_tasks) >= MAX_PENDING:
199
+ return _status()
200
+ entry = {'fingerprint': fingerprint, 'effect': 'pending', 'confirmation': 'not_attempted',
201
+ 'done': False, 'expires': time.monotonic() + 61,
202
+ 'ref': hashlib.sha256(json.dumps(identity).encode()).hexdigest()[:16]}
203
+ _entries[identity] = entry # Reserva antes de agendar, sem await no callback.
204
+ work = _deliver(entry, msg, operation, pedido, argv, envelope, root, dict(os.environ))
205
+ try:
206
+ task = loop.create_task(work)
207
+ except Exception:
208
+ work.close()
209
+ entry.update(effect='unconfirmed', done=True)
210
+ return _status(entry)
211
+ _tasks.add(task)
212
+ task.add_done_callback(_tasks.discard)
213
+ return _status(entry)
214
+ except Exception:
215
+ pass
216
+ return _status()
217
+
218
+
219
+ def register(ctx):
220
+ ctx.register_hook('pre_gateway_dispatch', _ingress)
221
+
222
+
223
+ async def _native_core(argv, root, envelope=None):
224
+ # Subprocesso assíncrono limitado: não prende executor thread no shutdown.
225
+ proc = await asyncio.create_subprocess_exec(*argv, cwd=root,
226
+ stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
227
+ limit=65536)
228
+ try:
229
+ data = None if envelope is None else json.dumps(envelope, ensure_ascii=False).encode()
230
+ out, _ = await asyncio.wait_for(proc.communicate(data), CORE_TIMEOUT)
231
+ if proc.returncode or len(out) > 65536:
232
+ raise ValueError('native.core.unavailable')
233
+ return json.loads(out)
234
+ except BaseException:
235
+ if proc.returncode is None:
236
+ proc.kill()
237
+ await proc.wait()
238
+ raise
239
+
240
+
241
+ async def _native_deliver(entry, msg, match, binding, key, root):
242
+ operation, thread, pedido, answer = match.groups()
243
+ cli = os.environ.get('ORK_BIN', 'ork')
244
+ try:
245
+ compact = lambda v: json.dumps(v, ensure_ascii=False, separators=(',', ':'))
246
+ offer = dict(native=dict(binding, messageId=str(msg.id)),
247
+ recebidoEm=msg.created_at.astimezone(timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z'))
248
+ body = ['ork.hitl-native-offer/v1', thread, pedido] + [offer['native'][k] for k in
249
+ ['host', 'installationId', 'connectionId', 'sessionId', 'accountId', 'channelId', 'conversationId', 'personId', 'messageId']] + [offer['recebidoEm']]
250
+ offer['prova'] = hmac.new(key.encode(), compact(body).encode(), hashlib.sha256).hexdigest()
251
+ view = await _native_core([cli, 'gate', 'context', thread, pedido, '--native-offer-stdin'], root, offer)
252
+ p = view.get('pedido', {})
253
+ if (p.get('id') != pedido or p.get('thread') != thread or (operation != 'offer' and p.get('alvo', {}).get('tipo') != operation)
254
+ or not any(c.get('canal') == 'hermes' and c.get('transporte') == 'native' and c.get('estado') == 'disponivel' for c in view.get('canais', []))
255
+ or not re.fullmatch('[a-f0-9]{64}', view.get('contexto', ''))
256
+ or not re.fullmatch('[a-f0-9]{64}', view.get('pedidoSha256', ''))):
257
+ return
258
+ if operation == 'offer':
259
+ text = view.get('apresentacao', {}).get('mensagem')
260
+ if not isinstance(text, str):
261
+ return
262
+ # I-35: o prazo chega ao dono no fuso dele; nucleo de outra versao pode nao
263
+ # ter posto o prazo local na mensagem, entao o campo local vai junto.
264
+ prazo_local = view.get('prazoLocal')
265
+ if isinstance(prazo_local, str) and prazo_local and prazo_local not in text:
266
+ text += '\nPrazo (fuso do dono): ' + prazo_local
267
+ import discord
268
+ entry['effect'] = 'presented'
269
+ entry['confirmation'] = 'unknown'
270
+ text += '\nCanal nativo Hermes disponível nesta conversa.'
271
+ sent = await asyncio.wait_for(msg.reply(text, mention_author=False,
272
+ allowed_mentions=discord.AllowedMentions.none()), CONFIRM_TIMEOUT)
273
+ if isinstance(sent, discord.Message) and sent.channel.id == msg.channel.id and sent.content == text:
274
+ entry['confirmation'] = 'sent'
275
+ return
276
+ n = dict(binding, messageId=str(msg.id), context=view['contexto'],
277
+ pedidoSha256=view['pedidoSha256'], expiresAt=p['prazo'])
278
+ identity = [n[k] for k in ['host', 'installationId', 'connectionId', 'sessionId', 'channelId', 'accountId', 'conversationId', 'messageId']]
279
+ envelope = dict(origem='native', canal='hermes', conta=n['accountId'], native=n,
280
+ por='native:hermes:' + n['personId'], mensagem='native:' + hashlib.sha256(compact(identity).encode()).hexdigest(),
281
+ recebidoEm=msg.created_at.astimezone(timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z'), resposta=answer)
282
+ body = ['ork.hitl-native/v1', thread, pedido] + [n[k] for k in ['host', 'installationId', 'connectionId', 'sessionId',
283
+ 'accountId', 'channelId', 'conversationId', 'personId', 'messageId', 'context', 'pedidoSha256', 'expiresAt']] + [envelope[k] for k in ['origem', 'canal', 'conta', 'por', 'mensagem', 'recebidoEm', 'resposta']]
284
+ envelope['prova'] = hmac.new(key.encode(), compact(body).encode(), hashlib.sha256).hexdigest()
285
+ entry['effect'] = 'unknown'
286
+ receipt = await _native_core([cli, 'gate' if operation == 'gate' else 'sessions', 'answer', thread, pedido,
287
+ '--resposta-stdin' if operation == 'gate' else '--stdin', '--origem', 'native', '--canal', 'hermes',
288
+ '--conta', n['accountId'], '--por', envelope['por'], '--mensagem', envelope['mensagem']], root, envelope)
289
+ if not _receipt_ok(receipt, operation, pedido):
290
+ return
291
+ entry['effect'] = 'confirmed'
292
+ if receipt['repetida']:
293
+ entry['confirmation'] = 'unknown'
294
+ return
295
+ entry['confirmation'] = 'unknown'
296
+ import discord
297
+ sent = await asyncio.wait_for(msg.reply(CONFIRM_TEXT[operation], mention_author=False,
298
+ allowed_mentions=discord.AllowedMentions.none()), CONFIRM_TIMEOUT)
299
+ if isinstance(sent, discord.Message) and sent.channel.id == msg.channel.id and sent.content == CONFIRM_TEXT[operation]:
300
+ entry['confirmation'] = 'sent'
301
+ except (Exception, asyncio.CancelledError):
302
+ pass
303
+ finally:
304
+ entry['done'] = True
305
+ logger.info('ork.hitl.native: ref=%s effect=%s confirmation=%s', entry['ref'], entry['effect'], entry['confirmation'])
306
+
307
+
308
+ def _native_ingress(event, gateway, session_store):
309
+ """Callback Discord do gateway instalado; terminal/ACP sem atestado fica indisponível."""
310
+ try:
311
+ from gateway.platforms.base import MessageEvent
312
+ import discord
313
+ if not isinstance(event, MessageEvent) or getattr(event, 'internal', False):
314
+ return _status()
315
+ msg, source = event.raw_message, event.source
316
+ binding = json.loads(os.environ.get('ORK_HITL_NATIVE_BINDING_HERMES', 'null'))
317
+ key, root = os.environ.get('ORK_HITL_NATIVE_KEY_HERMES', ''), os.environ.get('ORK_HITL_ROOT', '')
318
+ fields = {'host', 'installationId', 'connectionId', 'sessionId', 'accountId', 'channelId', 'conversationId', 'personId'}
319
+ if (not isinstance(binding, dict) or set(binding) != fields or any(not isinstance(v, str) or not v for v in binding.values())
320
+ or binding['host'] != 'hermes' or binding['channelId'] != 'discord' or source.platform.value != 'discord'
321
+ or not isinstance(msg, discord.Message) or source.is_bot is not False or msg.author.bot is not False
322
+ or msg.webhook_id is not None or msg.application_id is not None
323
+ or gateway._is_user_authorized(source) is not True or len(key.encode()) < 32 or not os.path.isabs(root)
324
+ or str(msg.author.id) != source.user_id or source.user_id != binding['personId']
325
+ or str(msg.channel.id) != source.chat_id or source.chat_id != binding['conversationId']
326
+ or str(getattr(msg.guild, 'id', '')) != binding['accountId']
327
+ or str(event.message_id) != str(msg.id) or msg.content != event.text):
328
+ return _status()
329
+ age = (datetime.now(timezone.utc) - msg.created_at).total_seconds()
330
+ if not 0 <= age <= 60:
331
+ return _status()
332
+ # API observada no SessionStore instalado; não cria nem reseta sessão por consulta.
333
+ entries = [s for s in session_store.list_sessions() if s.session_id == binding['sessionId']
334
+ and s.session_key == session_store._generate_session_key(source) and not s.suspended]
335
+ if len(entries) != 1:
336
+ return _status()
337
+ match = NATIVE_COMMAND.fullmatch(event.text)
338
+ if not match or '\x00' in event.text or (match[1] == 'offer' and match[4] is not None) or (match[1] != 'offer' and not match[4]):
339
+ return _status()
340
+ identity = (root, binding['connectionId'], binding['sessionId'], source.chat_id, str(msg.id))
341
+ if identity in _entries:
342
+ return _status(_entries[identity], replay=True)
343
+ for old_id, old in list(_entries.items()):
344
+ if old['done'] and time.monotonic() >= old['expires']:
345
+ del _entries[old_id]
346
+ if len(_entries) >= MAX_ENTRIES or len(_tasks) >= MAX_PENDING:
347
+ return _status()
348
+ entry = dict(effect='pending', confirmation='not_attempted', done=False, expires=time.monotonic() + 61,
349
+ ref=hashlib.sha256(json.dumps(identity).encode()).hexdigest()[:16])
350
+ _entries[identity] = entry
351
+ task = asyncio.get_running_loop().create_task(_native_deliver(entry, msg, match, binding, key, root))
352
+ _tasks.add(task)
353
+ task.add_done_callback(_tasks.discard)
354
+ return _status(entry)
355
+ except Exception:
356
+ return _status()
@@ -0,0 +1,4 @@
1
+ name: orkastery-hitl
2
+ version: 0.1.0
3
+ description: Resposta HITL pelos callbacks autenticados Telegram ou Discord nativo, sem tool do modelo.
4
+ kind: standalone