@daniellins/power-claude 0.16.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.

Potentially problematic release.


This version of @daniellins/power-claude might be problematic. Click here for more details.

Files changed (467) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/bin/cli.js +124 -0
  4. package/installer/__tests__/doctor-ready-oracles.test.cjs +140 -0
  5. package/installer/__tests__/merge-settings.test.cjs +330 -0
  6. package/installer/adopt.js +161 -0
  7. package/installer/copy.js +203 -0
  8. package/installer/doctor.js +266 -0
  9. package/installer/install.js +155 -0
  10. package/installer/manifest.js +102 -0
  11. package/installer/motor-scripts.js +50 -0
  12. package/installer/motor-scripts.json +39 -0
  13. package/installer/uninstall.js +99 -0
  14. package/installer/update.js +376 -0
  15. package/installer/util.js +113 -0
  16. package/installer/wizard.js +64 -0
  17. package/package.json +26 -0
  18. package/scripts/__fixtures__/anti-path-demo/agents/ap-writer.md +13 -0
  19. package/scripts/__fixtures__/anti-path-demo/templates/write-tmpl.md +7 -0
  20. package/scripts/__fixtures__/anti-path-demo/workflows/wf-ap.smoke.json +10 -0
  21. package/scripts/__fixtures__/anti-path-demo/workflows/wf-ap.yaml +25 -0
  22. package/scripts/__fixtures__/assess-tier/retrofit-leve/pod/agents/content-chief.md +1 -0
  23. package/scripts/__fixtures__/assess-tier/retrofit-leve/pod/workflows/wf-criacao-redteam.yaml +3 -0
  24. package/scripts/__fixtures__/assess-tier/retrofit-leve/pod-spec.json +13 -0
  25. package/scripts/__fixtures__/fidelity/fake-green-average-in-band.yaml +21 -0
  26. package/scripts/__fixtures__/fidelity/fake-green-average.yaml +18 -0
  27. package/scripts/__fixtures__/fidelity/fake-green-denominator.yaml +19 -0
  28. package/scripts/__fixtures__/fidelity/fake-green-missing-evidence.yaml +18 -0
  29. package/scripts/__fixtures__/fidelity/fake-green-threshold.yaml +18 -0
  30. package/scripts/__fixtures__/fidelity/fake-green-weight.yaml +18 -0
  31. package/scripts/__fixtures__/fidelity/golden.yaml +31 -0
  32. package/scripts/__fixtures__/fidelity/trindade-incoherent.yaml +15 -0
  33. package/scripts/__fixtures__/forge-canonical/pod-spec-example.json +37 -0
  34. package/scripts/__fixtures__/migrate-squad/authored/agents/offer-engineer.md +17 -0
  35. package/scripts/__fixtures__/migrate-squad/authored/knowledge/no-source.md +7 -0
  36. package/scripts/__fixtures__/migrate-squad/authored/knowledge/value-stack-method.md +8 -0
  37. package/scripts/__fixtures__/migrate-squad/authored/specs/wf-sample.smoke.json +24 -0
  38. package/scripts/__fixtures__/migrate-squad/authored/specs/wf-sample.spec.json +45 -0
  39. package/scripts/__fixtures__/migrate-squad/legacy-squad/HEADLINE.md +3 -0
  40. package/scripts/__fixtures__/migrate-squad/legacy-squad/README.md +4 -0
  41. package/scripts/__fixtures__/migrate-squad/legacy-squad/agents/hormozi-offer.md +14 -0
  42. package/scripts/__fixtures__/migrate-squad/legacy-squad/checklists/dead-redundant.md +4 -0
  43. package/scripts/__fixtures__/migrate-squad/legacy-squad/checklists/method-guide.md +6 -0
  44. package/scripts/__fixtures__/migrate-squad/legacy-squad/checklists/veto-screen.md +4 -0
  45. package/scripts/__fixtures__/migrate-squad/legacy-squad/config/config.yaml +2 -0
  46. package/scripts/__fixtures__/migrate-squad/legacy-squad/data/case-library.md +3 -0
  47. package/scripts/__fixtures__/migrate-squad/legacy-squad/scripts/validate-mvn.py +16 -0
  48. package/scripts/__fixtures__/migrate-squad/legacy-squad/sops/sop-sample.md +3 -0
  49. package/scripts/__fixtures__/migrate-squad/legacy-squad/squad-io.yaml +14 -0
  50. package/scripts/__fixtures__/migrate-squad/legacy-squad/tasks/gather-context.md +7 -0
  51. package/scripts/__fixtures__/migrate-squad/legacy-squad/templates/output-tmpl.md +5 -0
  52. package/scripts/__fixtures__/migrate-squad/legacy-squad/workflows/wf-sample.yaml +24 -0
  53. package/scripts/__fixtures__/pipeline/pipelines/pipeline-checkpoint.yaml +21 -0
  54. package/scripts/__fixtures__/pipeline/pipelines/pipeline-demo.yaml +22 -0
  55. package/scripts/__fixtures__/pipeline/pods/pipe-a/agents/maker.md +13 -0
  56. package/scripts/__fixtures__/pipeline/pods/pipe-a/memory/learnings.consolidated.md +8 -0
  57. package/scripts/__fixtures__/pipeline/pods/pipe-a/memory/learnings.jsonl +83 -0
  58. package/scripts/__fixtures__/pipeline/pods/pipe-a/templates/make-tmpl.md +8 -0
  59. package/scripts/__fixtures__/pipeline/pods/pipe-a/workflows/wf-a.yaml +16 -0
  60. package/scripts/__fixtures__/pipeline/pods/pipe-b/agents/user.md +13 -0
  61. package/scripts/__fixtures__/pipeline/pods/pipe-b/memory/learnings.consolidated.md +8 -0
  62. package/scripts/__fixtures__/pipeline/pods/pipe-b/memory/learnings.jsonl +14 -0
  63. package/scripts/__fixtures__/pipeline/pods/pipe-b/templates/use-tmpl.md +6 -0
  64. package/scripts/__fixtures__/pipeline/pods/pipe-b/workflows/wf-b.yaml +16 -0
  65. package/scripts/__fixtures__/pod-run/agents/forge-chief.md +13 -0
  66. package/scripts/__fixtures__/pod-run/agents/pod-critic.md +13 -0
  67. package/scripts/__fixtures__/pod-run/templates/build-tmpl.md +8 -0
  68. package/scripts/__fixtures__/pod-run/templates/gate-tmpl.md +6 -0
  69. package/scripts/__fixtures__/pod-run/workflows/wf-min.yaml +43 -0
  70. package/scripts/__fixtures__/pod-run/workflows/wf-skip-modes.yaml +50 -0
  71. package/scripts/__fixtures__/pod-workflow/invalid-checkpoint-all-skippable.yaml +30 -0
  72. package/scripts/__fixtures__/pod-workflow/invalid-checkpoint-criteria-escalar-de-bloco.yaml +40 -0
  73. package/scripts/__fixtures__/pod-workflow/invalid-checkpoint-skip-mode-desconhecido.yaml +30 -0
  74. package/scripts/__fixtures__/pod-workflow/invalid-checkpoint-unknown-key.yaml +29 -0
  75. package/scripts/__fixtures__/pod-workflow/invalid-cycle.yaml +26 -0
  76. package/scripts/__fixtures__/pod-workflow/invalid-fix-loop-backedge.yaml +29 -0
  77. package/scripts/__fixtures__/pod-workflow/invalid-fix-loop-forward.yaml +29 -0
  78. package/scripts/__fixtures__/pod-workflow/invalid-handoff-open.yaml +27 -0
  79. package/scripts/__fixtures__/pod-workflow/invalid-knowledge-shape.yaml +18 -0
  80. package/scripts/__fixtures__/pod-workflow/invalid-missing-agent.yaml +15 -0
  81. package/scripts/__fixtures__/pod-workflow/invalid-missing-outputs.yaml +15 -0
  82. package/scripts/__fixtures__/pod-workflow/invalid-missing-template.yaml +15 -0
  83. package/scripts/__fixtures__/pod-workflow/invalid-missing-veto.yaml +13 -0
  84. package/scripts/__fixtures__/pod-workflow/invalid-template-not-on-disk.yaml +16 -0
  85. package/scripts/__fixtures__/pod-workflow/invalid-veto-prose.yaml +17 -0
  86. package/scripts/__fixtures__/pod-workflow/knowledge/a.md +4 -0
  87. package/scripts/__fixtures__/pod-workflow/knowledge/b.md +4 -0
  88. package/scripts/__fixtures__/pod-workflow/knowledge/c.md +4 -0
  89. package/scripts/__fixtures__/pod-workflow/templates/step-tmpl.md +7 -0
  90. package/scripts/__fixtures__/pod-workflow/valid-checkpoint-skip-modes.yaml +29 -0
  91. package/scripts/__fixtures__/pod-workflow/valid-fix-loop.yaml +30 -0
  92. package/scripts/__fixtures__/pod-workflow/valid-knowledge.yaml +30 -0
  93. package/scripts/__fixtures__/pod-workflow/valid-veto-always-true.yaml +20 -0
  94. package/scripts/__fixtures__/pod-workflow/valid.yaml +27 -0
  95. package/scripts/__fixtures__/smoke-canary/agents/canary-reporter.md +13 -0
  96. package/scripts/__fixtures__/smoke-canary/agents/canary-researcher.md +13 -0
  97. package/scripts/__fixtures__/smoke-canary/agents/canary-synth.md +13 -0
  98. package/scripts/__fixtures__/smoke-canary/memory/learnings.consolidated.md +10 -0
  99. package/scripts/__fixtures__/smoke-canary/memory/learnings.jsonl +448 -0
  100. package/scripts/__fixtures__/smoke-canary/templates/report-tmpl.md +9 -0
  101. package/scripts/__fixtures__/smoke-canary/templates/research-tmpl.md +7 -0
  102. package/scripts/__fixtures__/smoke-canary/templates/synth-tmpl.md +9 -0
  103. package/scripts/__fixtures__/smoke-canary/workflows/wf-canary.smoke.json +11 -0
  104. package/scripts/__fixtures__/smoke-canary/workflows/wf-canary.yaml +53 -0
  105. package/scripts/__fixtures__/source-audit/fake-green-bronze-unsignaled.yaml +10 -0
  106. package/scripts/__fixtures__/source-audit/fake-green-counts.yaml +9 -0
  107. package/scripts/__fixtures__/source-audit/fake-green-missing-evidence.yaml +9 -0
  108. package/scripts/__fixtures__/source-audit/fake-green-unclassified.yaml +10 -0
  109. package/scripts/__fixtures__/source-audit/fake-green-verdict.yaml +10 -0
  110. package/scripts/__fixtures__/source-audit/golden-blocked.yaml +10 -0
  111. package/scripts/__fixtures__/source-audit/golden.yaml +11 -0
  112. package/scripts/__fixtures__/sweep-forms/agents/forms-count.md +13 -0
  113. package/scripts/__fixtures__/sweep-forms/agents/forms-field.md +13 -0
  114. package/scripts/__fixtures__/sweep-forms/agents/forms-fileref.md +13 -0
  115. package/scripts/__fixtures__/sweep-forms/agents/forms-json.md +13 -0
  116. package/scripts/__fixtures__/sweep-forms/agents/forms-section.md +13 -0
  117. package/scripts/__fixtures__/sweep-forms/specs/wf-forms.spec.json +5 -0
  118. package/scripts/__fixtures__/sweep-forms/templates/t_count.md +5 -0
  119. package/scripts/__fixtures__/sweep-forms/templates/t_field.md +3 -0
  120. package/scripts/__fixtures__/sweep-forms/templates/t_fileref.md +3 -0
  121. package/scripts/__fixtures__/sweep-forms/templates/t_json.md +0 -0
  122. package/scripts/__fixtures__/sweep-forms/templates/t_section.md +3 -0
  123. package/scripts/__fixtures__/sweep-forms/workflows/wf-forms.smoke.json +13 -0
  124. package/scripts/__fixtures__/sweep-forms/workflows/wf-forms.yaml +67 -0
  125. package/scripts/__fixtures__/validate-story-acs-corpus/corpus-1-noop-state-json.fixture.md +18 -0
  126. package/scripts/__fixtures__/validate-story-acs-corpus/corpus-2-crash-buildworkflowyaml.fixture.md +26 -0
  127. package/scripts/__fixtures__/validate-story-acs-corpus/corpus-3-nasce-verde-skip-modes.fixture.md +29 -0
  128. package/scripts/__fixtures__/validate-story-acs-corpus/corpus-4-guarda-sem-irmao.fixture.md +17 -0
  129. package/scripts/__fixtures__/validate-story-acs-corpus/corpus-5a-diff-stat-sem-guarda.fixture.md +17 -0
  130. package/scripts/__fixtures__/validate-story-acs-corpus/corpus-5b-test-f-sobre-diretorio.fixture.md +20 -0
  131. package/scripts/__fixtures__/veto-checks/copy-clean.md +4 -0
  132. package/scripts/__fixtures__/veto-checks/copy-dashes.md +4 -0
  133. package/scripts/__fixtures__/veto-checks/n8n-bad.md +18 -0
  134. package/scripts/__fixtures__/veto-checks/n8n-ok.md +22 -0
  135. package/scripts/__fixtures__/veto-checks/page-bad.html +7 -0
  136. package/scripts/__fixtures__/veto-checks/phash/copy.png +0 -0
  137. package/scripts/__fixtures__/veto-checks/phash/distinct.png +0 -0
  138. package/scripts/__fixtures__/veto-checks/phash/distinct2.png +0 -0
  139. package/scripts/__fixtures__/veto-checks/phash/make-fixtures.cjs +203 -0
  140. package/scripts/__fixtures__/veto-checks/phash/ref.png +0 -0
  141. package/scripts/__fixtures__/veto-checks/slide-ok.html +15 -0
  142. package/scripts/__fixtures__/veto-checks/slide-overflow.html +14 -0
  143. package/scripts/__fixtures__/veto-checks/sweep-pod/agents/vc-checker.md +13 -0
  144. package/scripts/__fixtures__/veto-checks/sweep-pod/templates/check-tmpl.md +6 -0
  145. package/scripts/__fixtures__/veto-checks/sweep-pod/workflows/wf-vc.smoke.json +10 -0
  146. package/scripts/__fixtures__/veto-checks/sweep-pod/workflows/wf-vc.yaml +19 -0
  147. package/scripts/__fixtures__/voz-do-dono/README.md +23 -0
  148. package/scripts/__fixtures__/voz-do-dono/dono-escalar.yaml +9 -0
  149. package/scripts/__fixtures__/voz-do-dono/dono-guia.yaml +10 -0
  150. package/scripts/__fixtures__/voz-do-dono/peca-3a-pessoa.md +6 -0
  151. package/scripts/__fixtures__/voz-do-dono/peca-com-lexico-proibido.md +6 -0
  152. package/scripts/__fixtures__/voz-do-dono/peca-limpa-1a-pessoa.md +6 -0
  153. package/scripts/__tests__/derive-ready-transitions.test.cjs +269 -0
  154. package/scripts/__tests__/enforce-cycle-authority.test.cjs +869 -0
  155. package/scripts/__tests__/enforce-git-push-authority.test.cjs +701 -0
  156. package/scripts/__tests__/enforce-ready-oracles.test.cjs +745 -0
  157. package/scripts/__tests__/motor-scripts.test.cjs +339 -0
  158. package/scripts/__tests__/pc-run-pod-counter.test.cjs +58 -0
  159. package/scripts/__tests__/pc-run-pod.test.cjs +578 -0
  160. package/scripts/__tests__/validate-pod-workflow.test.cjs +90 -0
  161. package/scripts/__tests__/validate-story-acs.test.cjs +607 -0
  162. package/scripts/anti-path.test.cjs +163 -0
  163. package/scripts/arena.cjs +514 -0
  164. package/scripts/arena.test.cjs +213 -0
  165. package/scripts/brief-leitura-obrigatoria.test.cjs +364 -0
  166. package/scripts/consolidate-knowledge-runner.test.cjs +249 -0
  167. package/scripts/default-funcional.test.cjs +153 -0
  168. package/scripts/derive-ready-transitions.cjs +325 -0
  169. package/scripts/forge-additive.test.cjs +158 -0
  170. package/scripts/forge-assess-tier.cjs +205 -0
  171. package/scripts/forge-assess-tier.test.cjs +114 -0
  172. package/scripts/forge-canonical.test.cjs +624 -0
  173. package/scripts/forge-generate.cjs +1303 -0
  174. package/scripts/forge-harden-learning.cjs +921 -0
  175. package/scripts/forge-harden-learning.test.cjs +242 -0
  176. package/scripts/forge-ingest-memory.cjs +136 -0
  177. package/scripts/forge-upgrade-pod.cjs +368 -0
  178. package/scripts/forge.test.cjs +609 -0
  179. package/scripts/harden-knowledge.test.cjs +612 -0
  180. package/scripts/harness-g5-lite.cjs +243 -0
  181. package/scripts/harness-g5-lite.test.cjs +134 -0
  182. package/scripts/hn-carousel-contrato-entrada.test.cjs +96 -0
  183. package/scripts/migrate-squad-orchestration.test.cjs +146 -0
  184. package/scripts/migration-manifest-sf3.2.test.cjs +382 -0
  185. package/scripts/migration-manifest-sf3.3.test.cjs +170 -0
  186. package/scripts/migration-manifest-sf5.5.test.cjs +189 -0
  187. package/scripts/migration-manifest.cjs +1146 -0
  188. package/scripts/migration-manifest.test.cjs +186 -0
  189. package/scripts/npm-scope-invocation.test.cjs +194 -0
  190. package/scripts/owner-layer.cjs +442 -0
  191. package/scripts/owner-layer.test.cjs +358 -0
  192. package/scripts/pc-run-pipeline.cjs +289 -0
  193. package/scripts/pc-run-pipeline.test.cjs +256 -0
  194. package/scripts/pc-run-pod-countgte.test.cjs +66 -0
  195. package/scripts/pc-run-pod.cjs +1355 -0
  196. package/scripts/phash-gate.test.cjs +433 -0
  197. package/scripts/pod-export-compile.cjs +487 -0
  198. package/scripts/pod-export-compile.test.cjs +312 -0
  199. package/scripts/pod-export-executor.cjs +678 -0
  200. package/scripts/pod-export-executor.test.cjs +1341 -0
  201. package/scripts/pod-export-hardening.test.cjs +531 -0
  202. package/scripts/pod-export-postprocess.cjs +427 -0
  203. package/scripts/pod-export-postprocess.test.cjs +558 -0
  204. package/scripts/pod-export-queue-race.test.cjs +865 -0
  205. package/scripts/pod-export-release.cjs +473 -0
  206. package/scripts/pod-export-release.test.cjs +267 -0
  207. package/scripts/pod-export-service.cjs +1297 -0
  208. package/scripts/pod-export-service.test.cjs +489 -0
  209. package/scripts/pod-export-tools.cjs +470 -0
  210. package/scripts/pod-export-ui.cjs +805 -0
  211. package/scripts/pod-export-ui.test.cjs +301 -0
  212. package/scripts/pod-memory-budget.test.cjs +309 -0
  213. package/scripts/pod-memory-note.cjs +357 -0
  214. package/scripts/pod-memory-note.test.cjs +452 -0
  215. package/scripts/pod-memory.cjs +1252 -0
  216. package/scripts/pod-memory.test.cjs +951 -0
  217. package/scripts/render-png.test.cjs +307 -0
  218. package/scripts/smoke-isolation.test.cjs +100 -0
  219. package/scripts/smoke-pod-workflow.cjs +755 -0
  220. package/scripts/smoke-sweep.test.cjs +170 -0
  221. package/scripts/validate-adopt.js +235 -0
  222. package/scripts/validate-branding.js +84 -0
  223. package/scripts/validate-budgets.js +140 -0
  224. package/scripts/validate-fidelity-output.cjs +267 -0
  225. package/scripts/validate-fidelity-output.test.cjs +101 -0
  226. package/scripts/validate-install.js +230 -0
  227. package/scripts/validate-no-clone.cjs +220 -0
  228. package/scripts/validate-no-clone.test.cjs +77 -0
  229. package/scripts/validate-pipeline.cjs +145 -0
  230. package/scripts/validate-pod-workflow.cjs +878 -0
  231. package/scripts/validate-source-audit.cjs +170 -0
  232. package/scripts/validate-source-audit.test.cjs +90 -0
  233. package/scripts/validate-story-acs.cjs +862 -0
  234. package/scripts/validate-update-scope-motor.js +421 -0
  235. package/scripts/veto-checks/html-valid.cjs +92 -0
  236. package/scripts/veto-checks/lexical-check.cjs +62 -0
  237. package/scripts/veto-checks/phash-gate.cjs +589 -0
  238. package/scripts/veto-checks/render-gate.cjs +165 -0
  239. package/scripts/veto-checks/render-png.cjs +358 -0
  240. package/scripts/veto-checks.test.cjs +219 -0
  241. package/scripts/watcher-audit.cjs +178 -0
  242. package/template/.claude/CLAUDE.md +103 -0
  243. package/template/.claude/agents/pc-analyst.md +100 -0
  244. package/template/.claude/agents/pc-architect.md +32 -0
  245. package/template/.claude/agents/pc-dev.md +27 -0
  246. package/template/.claude/agents/pc-devops.md +45 -0
  247. package/template/.claude/agents/pc-planner.md +32 -0
  248. package/template/.claude/agents/pc-po.md +25 -0
  249. package/template/.claude/agents/pc-qa.md +25 -0
  250. package/template/.claude/commands/power/academy.md +9 -0
  251. package/template/.claude/commands/power/automation.md +9 -0
  252. package/template/.claude/commands/power/content-carousel.md +267 -0
  253. package/template/.claude/commands/power/content.md +9 -0
  254. package/template/.claude/commands/power/dev.md +9 -0
  255. package/template/.claude/commands/power/forge.md +224 -0
  256. package/template/.claude/commands/power/hormozi.md +221 -0
  257. package/template/.claude/commands/power/modo.md +57 -0
  258. package/template/.claude/hooks/README.md +114 -0
  259. package/template/.claude/hooks/context-budget.cjs +64 -0
  260. package/template/.claude/hooks/enforce-cycle-authority.cjs +457 -0
  261. package/template/.claude/hooks/enforce-git-push-authority.cjs +925 -0
  262. package/template/.claude/hooks/enforce-ready-oracles.cjs +502 -0
  263. package/template/.claude/rules/authority.md +76 -0
  264. package/template/.claude/rules/curadoria-de-conhecimento.md +30 -0
  265. package/template/.claude/rules/model-matrix.md +34 -0
  266. package/template/.claude/rules/orchestration.md +133 -0
  267. package/template/.claude/settings.json +50 -0
  268. package/template/.claude/skills/pc-apply-qa-fixes/SKILL.md +108 -0
  269. package/template/.claude/skills/pc-apply-qa-fixes/references/fix-loop.md +40 -0
  270. package/template/.claude/skills/pc-apply-qa-fixes/references/fix-request-template.md +70 -0
  271. package/template/.claude/skills/pc-arena/SKILL.md +96 -0
  272. package/template/.claude/skills/pc-close-story/SKILL.md +105 -0
  273. package/template/.claude/skills/pc-commit/SKILL.md +108 -0
  274. package/template/.claude/skills/pc-develop-story/SKILL.md +116 -0
  275. package/template/.claude/skills/pc-full-cycle/SKILL.md +150 -0
  276. package/template/.claude/skills/pc-pipeline/SKILL.md +63 -0
  277. package/template/.claude/skills/pc-pipeline/references/gate-templates.md +46 -0
  278. package/template/.claude/skills/pc-review-story/SKILL.md +129 -0
  279. package/template/.claude/skills/pc-review-story/references/false-positive-detection.md +50 -0
  280. package/template/.claude/skills/pc-review-story/references/nfr-assess.md +47 -0
  281. package/template/.claude/skills/pc-review-story/references/risk-profile.md +75 -0
  282. package/template/.claude/skills/pc-review-story/references/security-checklist.md +69 -0
  283. package/template/.claude/skills/pc-review-story/references/test-design.md +37 -0
  284. package/template/.claude/skills/pc-review-story/references/trace-requirements.md +46 -0
  285. package/template/.claude/skills/pc-run-pod/SKILL.md +123 -0
  286. package/template/.claude/skills/pc-validate-story/SKILL.md +114 -0
  287. package/template/.claude/skills/pc-wave/SKILL.md +133 -0
  288. package/template/.env.example +10 -0
  289. package/template/knowledge/analyst/brainstorming.md +40 -0
  290. package/template/knowledge/analyst/brief-skeleton.md +32 -0
  291. package/template/knowledge/analyst/competitor-skeleton.md +39 -0
  292. package/template/knowledge/analyst/elicitation-menu.md +49 -0
  293. package/template/knowledge/analyst/market-research-skeleton.md +36 -0
  294. package/template/knowledge/analyst/research-prompt-structure.md +48 -0
  295. package/template/knowledge/analyst/roi-skeleton.md +51 -0
  296. package/template/knowledge/voice-dna/README.md +99 -0
  297. package/template/knowledge/voice-dna/voice-dna.example.yaml +157 -0
  298. package/template/pods/README.md +64 -0
  299. package/template/pods/academy/README.md +37 -0
  300. package/template/pods/academy/agents/academy-chief.md +27 -0
  301. package/template/pods/academy/examples/palestra.example.md +142 -0
  302. package/template/pods/academy/pod-spec.json +44 -0
  303. package/template/pods/academy/templates/materiais-tmpl.md +17 -0
  304. package/template/pods/academy/templates/revisao-didatica-tmpl.md +20 -0
  305. package/template/pods/academy/workflows/wf-materiais-revisao.yaml +30 -0
  306. package/template/pods/authority-content/README.md +43 -0
  307. package/template/pods/authority-content/agents/content-chief.md +28 -0
  308. package/template/pods/authority-content/pod-spec.json +45 -0
  309. package/template/pods/authority-content/templates/criacao-na-voz-tmpl.md +17 -0
  310. package/template/pods/authority-content/templates/red-team-factual-tmpl.md +23 -0
  311. package/template/pods/authority-content/workflows/wf-criacao-redteam.yaml +30 -0
  312. package/template/pods/automation/README.md +37 -0
  313. package/template/pods/automation/agents/langgraph-architect.md +30 -0
  314. package/template/pods/automation/agents/n8n-architect.md +30 -0
  315. package/template/pods/automation/examples/README.md +46 -0
  316. package/template/pods/automation/examples/n8n-content-radar.example.json +380 -0
  317. package/template/pods/automation/knowledge/langgraph-patterns.md +126 -0
  318. package/template/pods/automation/knowledge/n8n-patterns.md +131 -0
  319. package/template/pods/automation/memory/README.md +16 -0
  320. package/template/pods/automation/memory/learnings.consolidated.md +8 -0
  321. package/template/pods/automation/memory/learnings.jsonl +0 -0
  322. package/template/pods/automation/pod-spec.json +109 -0
  323. package/template/pods/automation/specs/wf-langgraph-build.spec.json +107 -0
  324. package/template/pods/automation/squad-io.yaml +28 -0
  325. package/template/pods/automation/templates/lg-build-tmpl.md +17 -0
  326. package/template/pods/automation/templates/lg-design-tmpl.md +23 -0
  327. package/template/pods/automation/templates/lg-discover-tmpl.md +20 -0
  328. package/template/pods/automation/templates/lg-handoff-tmpl.md +20 -0
  329. package/template/pods/automation/templates/lg-test-tmpl.md +20 -0
  330. package/template/pods/automation/templates/n8n-build-tmpl.md +14 -0
  331. package/template/pods/automation/templates/n8n-design-tmpl.md +23 -0
  332. package/template/pods/automation/templates/n8n-discover-tmpl.md +20 -0
  333. package/template/pods/automation/templates/n8n-handoff-tmpl.md +20 -0
  334. package/template/pods/automation/templates/n8n-test-tmpl.md +20 -0
  335. package/template/pods/automation/workflows/wf-langgraph-build.smoke.json +11 -0
  336. package/template/pods/automation/workflows/wf-langgraph-build.yaml +64 -0
  337. package/template/pods/automation/workflows/wf-n8n-build.smoke.json +11 -0
  338. package/template/pods/automation/workflows/wf-n8n-build.yaml +65 -0
  339. package/template/pods/content-carousel/README.md +91 -0
  340. package/template/pods/content-carousel/agents/carousel-builder.md +28 -0
  341. package/template/pods/content-carousel/agents/carousel-visual.md +24 -0
  342. package/template/pods/content-carousel/agents/hn-curator.md +74 -0
  343. package/template/pods/content-carousel/config/heuristic-watcher-rubric.yaml +59 -0
  344. package/template/pods/content-carousel/knowledge/aquisicao-ancora.md +443 -0
  345. package/template/pods/content-carousel/knowledge/cta-brief.md +25 -0
  346. package/template/pods/content-carousel/knowledge/dono.example.yaml +45 -0
  347. package/template/pods/content-carousel/knowledge/identidade-visual-mascote.md +62 -0
  348. package/template/pods/content-carousel/knowledge/image-generation.md +62 -0
  349. package/template/pods/content-carousel/knowledge/selecao-de-ancora.md +60 -0
  350. package/template/pods/content-carousel/knowledge/slides-html.md +56 -0
  351. package/template/pods/content-carousel/memory/README.md +18 -0
  352. package/template/pods/content-carousel/memory/learnings.consolidated.md +6 -0
  353. package/template/pods/content-carousel/memory/learnings.jsonl +0 -0
  354. package/template/pods/content-carousel/pod-spec.json +100 -0
  355. package/template/pods/content-carousel/squad-io.yaml +10 -0
  356. package/template/pods/content-carousel/templates/assembly-tmpl.md +23 -0
  357. package/template/pods/content-carousel/templates/digest-tmpl.md +29 -0
  358. package/template/pods/content-carousel/templates/visuals-tmpl.md +20 -0
  359. package/template/pods/content-carousel/workflows/wf-content-carousel.smoke.json +23 -0
  360. package/template/pods/content-carousel/workflows/wf-content-carousel.yaml +80 -0
  361. package/template/pods/dev-ia/README.md +45 -0
  362. package/template/pods/dev-ia/agents/db-architect.md +33 -0
  363. package/template/pods/dev-ia/agents/ux-architect.md +29 -0
  364. package/template/pods/dev-ia/knowledge/db/migration-protocol.md +95 -0
  365. package/template/pods/dev-ia/knowledge/db/rls-patterns.md +101 -0
  366. package/template/pods/dev-ia/knowledge/db/schema-review-checklist.md +76 -0
  367. package/template/pods/dev-ia/memory/README.md +16 -0
  368. package/template/pods/dev-ia/memory/learnings.consolidated.md +8 -0
  369. package/template/pods/dev-ia/memory/learnings.jsonl +0 -0
  370. package/template/pods/dev-ia/pod-spec.json +34 -0
  371. package/template/pods/dev-ia/templates/schema-review-tmpl.md +23 -0
  372. package/template/pods/dev-ia/templates/ux-flow-review-tmpl.md +23 -0
  373. package/template/pods/dev-ia/tools/db-ops/backup-manager.sh +321 -0
  374. package/template/pods/dev-ia/tools/db-ops/best-practices-auditor.sh +534 -0
  375. package/template/pods/dev-ia/tools/db-ops/bootstrap-runner.sh +255 -0
  376. package/template/pods/dev-ia/tools/db-ops/data-loader.sh +616 -0
  377. package/template/pods/dev-ia/tools/db-ops/detect-database.sh +113 -0
  378. package/template/pods/dev-ia/tools/db-ops/explain-analyzer.sh +285 -0
  379. package/template/pods/dev-ia/tools/db-ops/health-checker.sh +323 -0
  380. package/template/pods/dev-ia/tools/db-ops/migration-runner.sh +308 -0
  381. package/template/pods/dev-ia/tools/db-ops/postgresql-schema-loader.sh +334 -0
  382. package/template/pods/dev-ia/tools/db-ops/query-runner.sh +171 -0
  383. package/template/pods/dev-ia/tools/db-ops/rls-policy-installer.sh +480 -0
  384. package/template/pods/dev-ia/tools/db-ops/rls-test-suite.sh +172 -0
  385. package/template/pods/dev-ia/tools/db-ops/schema-context-loader.sh +176 -0
  386. package/template/pods/hormozi-method/README.md +52 -0
  387. package/template/pods/hormozi-method/agents/acquisition-strategist.md +33 -0
  388. package/template/pods/hormozi-method/agents/conversion-copywriter.md +34 -0
  389. package/template/pods/hormozi-method/agents/monetization-architect.md +35 -0
  390. package/template/pods/hormozi-method/agents/offer-engineer.md +37 -0
  391. package/template/pods/hormozi-method/knowledge/core-four-leads.md +51 -0
  392. package/template/pods/hormozi-method/knowledge/grand-slam-offer.md +66 -0
  393. package/template/pods/hormozi-method/knowledge/growth-decision.md +38 -0
  394. package/template/pods/hormozi-method/knowledge/hooks-and-ads.md +56 -0
  395. package/template/pods/hormozi-method/knowledge/launch-email.md +31 -0
  396. package/template/pods/hormozi-method/knowledge/money-model.md +46 -0
  397. package/template/pods/hormozi-method/knowledge/opportunity-screening.md +44 -0
  398. package/template/pods/hormozi-method/knowledge/pages.md +25 -0
  399. package/template/pods/hormozi-method/knowledge/pos-lancamento-leads.md +56 -0
  400. package/template/pods/hormozi-method/knowledge/pricing.md +36 -0
  401. package/template/pods/hormozi-method/knowledge/retention-ltv.md +37 -0
  402. package/template/pods/hormozi-method/knowledge/scale-roadmap.md +35 -0
  403. package/template/pods/hormozi-method/knowledge/swipefiles-curados.md +44 -0
  404. package/template/pods/hormozi-method/knowledge/value-equation-diagnostic.md +77 -0
  405. package/template/pods/hormozi-method/memory/README.md +16 -0
  406. package/template/pods/hormozi-method/memory/learnings.consolidated.md +6 -0
  407. package/template/pods/hormozi-method/memory/learnings.jsonl +0 -0
  408. package/template/pods/hormozi-method/pod-spec.json +50 -0
  409. package/template/pods/hormozi-method/specs/wf-full-launch-sequence.spec.json +149 -0
  410. package/template/pods/hormozi-method/specs/wf-lead-magnet-pipeline.spec.json +125 -0
  411. package/template/pods/hormozi-method/specs/wf-money-model-design.spec.json +117 -0
  412. package/template/pods/hormozi-method/specs/wf-paid-ads-campaign.spec.json +132 -0
  413. package/template/pods/hormozi-method/specs/wf-scaling-diagnostic.spec.json +121 -0
  414. package/template/pods/hormozi-method/squad-io.yaml +43 -0
  415. package/template/pods/hormozi-method/templates/conversion-copywriter-tmpl.md +20 -0
  416. package/template/pods/hormozi-method/templates/launch-execucao-tmpl.md +17 -0
  417. package/template/pods/hormozi-method/templates/launch-fechamento-tmpl.md +17 -0
  418. package/template/pods/hormozi-method/templates/launch-pos-tmpl.md +17 -0
  419. package/template/pods/hormozi-method/templates/launch-preparacao-tmpl.md +17 -0
  420. package/template/pods/hormozi-method/templates/lm-copy-tmpl.md +17 -0
  421. package/template/pods/hormozi-method/templates/lm-distribuicao-tmpl.md +18 -0
  422. package/template/pods/hormozi-method/templates/lm-estrategia-tmpl.md +17 -0
  423. package/template/pods/hormozi-method/templates/mm-arquitetura-tmpl.md +18 -0
  424. package/template/pods/hormozi-method/templates/mm-copy-tmpl.md +17 -0
  425. package/template/pods/hormozi-method/templates/mm-diagnostico-tmpl.md +14 -0
  426. package/template/pods/hormozi-method/templates/monetization-architect-tmpl.md +17 -0
  427. package/template/pods/hormozi-method/templates/offer-engineer-tmpl.md +20 -0
  428. package/template/pods/hormozi-method/templates/paid-criativos-tmpl.md +19 -0
  429. package/template/pods/hormozi-method/templates/paid-fundacao-tmpl.md +17 -0
  430. package/template/pods/hormozi-method/templates/paid-teste-escala-tmpl.md +17 -0
  431. package/template/pods/hormozi-method/templates/scaling-diagnostico-tmpl.md +17 -0
  432. package/template/pods/hormozi-method/templates/scaling-priorizacao-tmpl.md +17 -0
  433. package/template/pods/hormozi-method/templates/scaling-roadmap-tmpl.md +17 -0
  434. package/template/pods/hormozi-method/workflows/wf-full-launch-sequence.smoke.json +12 -0
  435. package/template/pods/hormozi-method/workflows/wf-full-launch-sequence.yaml +56 -0
  436. package/template/pods/hormozi-method/workflows/wf-grand-slam-offer.smoke.json +11 -0
  437. package/template/pods/hormozi-method/workflows/wf-grand-slam-offer.yaml +45 -0
  438. package/template/pods/hormozi-method/workflows/wf-lead-magnet-pipeline.smoke.json +9 -0
  439. package/template/pods/hormozi-method/workflows/wf-lead-magnet-pipeline.yaml +45 -0
  440. package/template/pods/hormozi-method/workflows/wf-money-model-design.smoke.json +11 -0
  441. package/template/pods/hormozi-method/workflows/wf-money-model-design.yaml +45 -0
  442. package/template/pods/hormozi-method/workflows/wf-paid-ads-campaign.smoke.json +9 -0
  443. package/template/pods/hormozi-method/workflows/wf-paid-ads-campaign.yaml +46 -0
  444. package/template/pods/hormozi-method/workflows/wf-scaling-diagnostic.smoke.json +11 -0
  445. package/template/pods/hormozi-method/workflows/wf-scaling-diagnostic.yaml +44 -0
  446. package/template/pods/squad-forge/README.md +53 -0
  447. package/template/pods/squad-forge/agents/forge-chief.md +31 -0
  448. package/template/pods/squad-forge/agents/mind-extractor.md +29 -0
  449. package/template/pods/squad-forge/agents/pod-critic.md +26 -0
  450. package/template/pods/squad-forge/config/heuristic-watcher-rubric.yaml +74 -0
  451. package/template/pods/squad-forge/knowledge/fidelity-rubric.yaml +176 -0
  452. package/template/pods/squad-forge/knowledge/g6-memory-check.md +17 -0
  453. package/template/pods/squad-forge/knowledge/memory-ingest.md +16 -0
  454. package/template/pods/squad-forge/knowledge/source-audit-rubric.yaml +60 -0
  455. package/template/pods/squad-forge/memory/README.md +16 -0
  456. package/template/pods/squad-forge/memory/learnings.consolidated.md +8 -0
  457. package/template/pods/squad-forge/memory/learnings.jsonl +0 -0
  458. package/template/pods/squad-forge/pod-spec.json +166 -0
  459. package/template/pods/squad-forge/squad-io.yaml +13 -0
  460. package/template/pods/squad-forge/tasks/pipeline.md +158 -0
  461. package/template/pods/squad-forge/templates/discovery-tmpl.md +40 -0
  462. package/template/pods/squad-forge/templates/generation-tmpl.md +18 -0
  463. package/template/pods/squad-forge/templates/red-team-tmpl.md +17 -0
  464. package/template/pods/squad-forge/templates/register-tmpl.md +17 -0
  465. package/template/pods/squad-forge/templates/workflow-tmpl.yaml +101 -0
  466. package/template/pods/squad-forge/workflows/wf-forge.smoke.json +12 -0
  467. package/template/pods/squad-forge/workflows/wf-forge.yaml +60 -0
@@ -0,0 +1,1341 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * pod-export-executor.test.cjs — suíte HERMÉTICA do executor de produção (Story SF10.2, F2).
5
+ *
6
+ * NÃO toca rede: Messages API (Anthropic) e OpenRouter são INJETADAS (callModel/imageTransport);
7
+ * SSRF/traversal fazem short-circuit antes de qualquer IO. NÃO toca exports/.power-claude reais:
8
+ * compila content-carousel (fixture de CI, precedente do compile.test) para um bundle em tmp e
9
+ * dirige o executor de ponta a ponta com o MOTOR real e o COMPILADOR real — só o LLM/imagem/http
10
+ * são mocks. Cobre AC2 (loop max_tokens), AC3 (modelo pinado), AC4 (write-por-peça), AC5 (read por
11
+ * path/tamanho), AC6 (contenção das 4 tools), AC7 (whitelist do payload), AC8 (memória isolada),
12
+ * AC9 (reuso por import), AC16 (coerência de framework_version).
13
+ */
14
+
15
+ const fs = require('node:fs');
16
+ const os = require('node:os');
17
+ const path = require('node:path');
18
+ const crypto = require('node:crypto');
19
+
20
+ const tools = require('./pod-export-tools.cjs');
21
+ const exec = require('./pod-export-executor.cjs');
22
+ const motor = require('./pc-run-pod.cjs');
23
+ const compiler = require('./pod-export-compile.cjs');
24
+ const validator = require('./validate-pod-workflow.cjs');
25
+ // Oráculo REAL do veto lexical (SF10.11): a contagem que o command_check blessed usa em produção.
26
+ const lexical = require('./veto-checks/lexical-check.cjs');
27
+
28
+ const REPO_ROOT = path.resolve(__dirname, '..');
29
+ const FIXTURE_POD = path.join(REPO_ROOT, 'template', 'pods', 'content-carousel');
30
+
31
+ let failures = 0;
32
+ function assert(ok, msg) {
33
+ if (ok) console.log(`[OK ] ${msg}`);
34
+ else { console.error(`[FALHA] ${msg}`); failures++; }
35
+ }
36
+ function tmpDir(label) { return fs.mkdtempSync(path.join(os.tmpdir(), `sf10.2-${label}-`)); }
37
+ function sha(p) { return crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // AC6/AC7 — contenção das 4 tools (SSRF, traversal, zero shell) + whitelist do payload.
41
+ // ---------------------------------------------------------------------------
42
+
43
+ function toolsSecurityTests() {
44
+ console.log('\n--- AC6/AC7: contenção das tools (SSRF, traversal, whitelist, read por path) ---');
45
+
46
+ // isBlockedHost: privados/loopback/metadata → true; público → false.
47
+ const blocked = ['169.254.169.254', 'localhost', '127.0.0.1', '10.1.2.3', '172.16.0.1', '172.31.255.1', '192.168.0.1', '::1', 'fc00::1', 'foo.internal', '0.0.0.0'];
48
+ assert(blocked.every((h) => tools.isBlockedHost(h)), `isBlockedHost barra todos os privados/metadata (${blocked.length})`);
49
+ const allowedHosts = ['news.ycombinator.com', 'hacker-news.firebaseio.com', 'christianselig.com', '8.8.8.8'];
50
+ assert(allowedHosts.every((h) => !tools.isBlockedHost(h)), 'isBlockedHost NÃO barra hosts públicos');
51
+ assert(!tools.isBlockedHost('172.32.0.1') && !tools.isBlockedHost('172.15.0.1'), 'isBlockedHost respeita a fronteira 172.16-31 (172.15/172.32 são públicos)');
52
+
53
+ // SSRF negado (sem rede — short-circuit): metadata, loopback, privado, host fora da whitelist.
54
+ return Promise.all([
55
+ tools.httpFetch('http://169.254.169.254/latest/meta-data/', { whitelistHosts: ['169.254.169.254'] }),
56
+ tools.httpFetch('http://localhost:8080/x', { whitelistHosts: [] }),
57
+ tools.httpFetch('http://10.0.0.5/x', { whitelistHosts: [] }),
58
+ tools.httpFetch('https://evil.example.com/x', { whitelistHosts: ['news.ycombinator.com'] }),
59
+ tools.httpFetch('ftp://news.ycombinator.com/x', { whitelistHosts: ['news.ycombinator.com'] }),
60
+ ]).then((rs) => {
61
+ assert(!rs[0].ok && /bloqueado/.test(rs[0].error), 'SSRF: metadata 169.254.169.254 bloqueado MESMO estando na whitelist (DF2-3)');
62
+ assert(!rs[1].ok && /bloqueado/.test(rs[1].error), 'SSRF: localhost bloqueado');
63
+ assert(!rs[2].ok && /bloqueado/.test(rs[2].error), 'SSRF: 10.0.0.5 (RFC1918) bloqueado');
64
+ assert(!rs[3].ok && /fora da whitelist/.test(rs[3].error), 'SSRF: host fora da whitelist negado');
65
+ assert(!rs[4].ok && /protocolo/.test(rs[4].error), 'protocolo não-http negado');
66
+
67
+ // Positivo: host whitelisted + transport injetado → passa a guarda e chega ao transport.
68
+ let seen = null;
69
+ return tools.httpFetch('https://news.ycombinator.com/item?id=1', {
70
+ whitelistHosts: ['news.ycombinator.com'],
71
+ transport: (url) => { seen = url; return { ok: true, status: 200, body: 'OK', bytes: 2 }; },
72
+ }).then((r) => {
73
+ assert(r.ok && /item\?id=1/.test(seen), 'fetch: host na whitelist chega ao transport (caminho positivo)');
74
+ });
75
+ }).then(() => {
76
+ // write_file traversal / path absoluto NEGADOS; confinado OK.
77
+ const run = tmpDir('write');
78
+ let threwTraversal = false; let threwAbs = false;
79
+ try { tools.writeFile('../escape.txt', 'x', { runDir: run }); } catch { threwTraversal = true; }
80
+ try { tools.writeFile('/etc/pwn.txt', 'x', { runDir: run }); } catch { threwAbs = true; }
81
+ assert(threwTraversal, 'write_file: traversal "../escape.txt" NEGADO');
82
+ assert(threwAbs, 'write_file: path absoluto NEGADO');
83
+ const w = tools.writeFile('pieces/01-capa.html', '<html></html>', { runDir: run });
84
+ assert(w.ok && fs.existsSync(path.join(run, 'pieces/01-capa.html')), 'write_file: path confinado ESCREVE');
85
+
86
+ // read_image: gerada → path+tamanho (SEM base64); referência → base64; traversal negado.
87
+ const gen = exec; void gen;
88
+ const rGen = tools.readImage('pieces/01-capa.html', { runDir: run });
89
+ assert(rGen.ok && rGen.kind === 'generated' && rGen.base64 === undefined && rGen.bytes > 0,
90
+ 'read_image: imagem GERADA confirmada por path+tamanho, SEM base64 (AC5/Achado 4)');
91
+ const inputs = tmpDir('inputs');
92
+ fs.writeFileSync(path.join(inputs, 'ref.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
93
+ const rRef = tools.readImage('inputs:ref.png', { runDir: run, inputsDir: inputs });
94
+ assert(rRef.ok && rRef.kind === 'reference' && typeof rRef.base64 === 'string', 'read_image: referência pinada devolve base64 (multimodal)');
95
+ const rTrav = tools.readImage('inputs:../secret.png', { runDir: run, inputsDir: inputs });
96
+ assert(!rTrav.ok && /fora de inputs/.test(rTrav.error), 'read_image: traversal em inputs: NEGADO');
97
+
98
+ // openrouter_image: sem modelo → erro; traversal → erro ANTES da rede; transport → escreve + confirma.
99
+ return tools.openrouterImage('p', 'assets/cover.png', { runDir: run, model: '', imageTransport: async () => ({}) }).then((e0) => {
100
+ assert(!e0.ok && /modelo/.test(e0.error), 'openrouter_image: sem modelo pinado → erro');
101
+ return tools.openrouterImage('p', '../evil.png', { runDir: run, model: 'm', imageTransport: async () => ({}) });
102
+ }).then((e1) => {
103
+ assert(!e1.ok && /traversal/.test(e1.error), 'openrouter_image: out_path traversal NEGADO antes da rede');
104
+ const png1x1 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
105
+ let usedModel = null;
106
+ return tools.openrouterImage('desenhe', 'assets/cover.png', {
107
+ runDir: run, model: 'google/gemini-2.5-flash-image',
108
+ imageTransport: async (payload) => { usedModel = payload.model; return { choices: [{ message: { images: [{ image_url: { url: png1x1 } }] } }] }; },
109
+ }).then((r) => {
110
+ assert(r.ok && r.bytes > 0 && r.base64 === undefined && usedModel === 'google/gemini-2.5-flash-image',
111
+ 'openrouter_image: modelo PINADO usado; PNG escrito; confirmado por path+tamanho, SEM base64');
112
+ });
113
+ });
114
+ }).then(() => {
115
+ // AC7 — whitelist derivada SÓ do payload declarado (host de URL pinada ∪ fetch_allow_hosts).
116
+ const wl = tools.computeWhitelist({ fetch_allow_hosts: ['hacker-news.firebaseio.com', 'news.ycombinator.com'], article_url: 'https://christianselig.com/x/y', title: 'not a url', hn_id: 42 });
117
+ assert(wl.includes('christianselig.com') && wl.includes('news.ycombinator.com') && wl.length === 3,
118
+ `whitelist = fetch_allow_hosts ∪ host(article_url), ignora não-URLs (${wl.join(', ')})`);
119
+ const wl2 = tools.computeWhitelist({ evil: 'http://169.254.169.254/' });
120
+ assert(wl2.includes('169.254.169.254'), 'computeWhitelist inclui o host declarado, mas isBlockedHost o barra em runtime (defesa em profundidade)');
121
+ });
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // AC9 — reuso por import, ZERO reimplementação (estático).
126
+ // ---------------------------------------------------------------------------
127
+
128
+ function reuseTests() {
129
+ console.log('\n--- AC9: reuso do motor por import, sem reimplementação ---');
130
+ const src = fs.readFileSync(path.join(__dirname, 'pod-export-executor.cjs'), 'utf8');
131
+ assert(/require\('\.\/pc-run-pod\.cjs'\)/.test(src), "executor importa './pc-run-pod.cjs' (motor de produção)");
132
+ const banned = [/function\s+verifyStep\b/, /function\s+evalVeto\b/, /function\s+advance\s*\(/, /function\s+writeState\b/, /function\s+checkTemplateConformance\b/];
133
+ assert(banned.every((re) => !re.test(src)), 'executor NÃO reimplementa verifyStep/evalVeto/advance/writeState/checkTemplateConformance');
134
+ const toolsSrc = fs.readFileSync(path.join(__dirname, 'pod-export-tools.cjs'), 'utf8');
135
+ const shell = [/child_process/, /execSync/, /execFileSync/, /\bspawn\b/, /\bexec\(/];
136
+ assert(shell.every((re) => !re.test(toolsSrc)) && shell.every((re) => !re.test(src)), 'AC6: ZERO shell em tools/executor (sem child_process/exec/spawn)');
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // AC16 — coerência fail-loud de framework_version.
141
+ // ---------------------------------------------------------------------------
142
+
143
+ function coherenceTests() {
144
+ console.log('\n--- AC16: coerência de framework_version (fail-loud) ---');
145
+ const engine = exec.engineFrameworkVersion();
146
+ const dir = tmpDir('lock');
147
+ const okLock = path.join(dir, 'ok.lock.json');
148
+ fs.writeFileSync(okLock, JSON.stringify({ framework_version: engine }));
149
+ const badLock = path.join(dir, 'bad.lock.json');
150
+ fs.writeFileSync(badLock, JSON.stringify({ framework_version: '0.0.0-mismatch' }));
151
+ const bundle = { pod: 'x' };
152
+ let ok = false; try { exec.assertFrameworkCoherence(bundle, okLock, engine); ok = true; } catch { /* */ }
153
+ assert(ok, `coerência: versões iguais (${engine}) → run permitido`);
154
+ let mism = false; try { exec.assertFrameworkCoherence(bundle, badLock, engine); } catch (e) { mism = /MISMATCH/.test(e.message); }
155
+ assert(mism, 'coerência: MISMATCH de framework_version → run RECUSADO (fail-loud)');
156
+ let noLock = false; try { exec.assertFrameworkCoherence(bundle, path.join(dir, 'ausente.json'), engine); } catch (e) { noLock = /não encontrado/.test(e.message); }
157
+ assert(noLock, 'coerência: lock ausente → run RECUSADO');
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // AC2/AC4 — loop de tool-use: max_tokens NÃO descarta tool_use; write-por-peça.
162
+ // ---------------------------------------------------------------------------
163
+
164
+ async function loopTests() {
165
+ console.log('\n--- AC2/AC4: loop max_tokens sem descartar tool_use + write-por-peça ---');
166
+ const run = tmpDir('loop');
167
+ let tu = 0;
168
+ const scripted = [];
169
+ // 7 peças, UMA por turn, TODAS com stop_reason=max_tokens (o bug antigo descartaria a 1ª e pararia).
170
+ for (let i = 1; i <= 7; i++) {
171
+ scripted.push({ stop_reason: 'max_tokens', content: [{ type: 'tool_use', id: `tu${++tu}`, name: 'write_file', input: { path: `pieces/0${i}.html`, content: `<html>peca ${i}</html>` } }], usage: { input_tokens: 10, output_tokens: 10 } });
172
+ }
173
+ // turn final: artefato + SEM tool_use encerra.
174
+ scripted.push({ stop_reason: 'max_tokens', content: [{ type: 'tool_use', id: `tu${++tu}`, name: 'write_file', input: { path: 'artifacts/assembly.md', content: '# final' } }], usage: { input_tokens: 5, output_tokens: 5 } });
175
+ scripted.push({ stop_reason: 'end_turn', content: [{ type: 'text', text: 'pronto' }], usage: { input_tokens: 1, output_tokens: 1 } });
176
+ let call = 0;
177
+ const callModel = async () => scripted[call++];
178
+ const capturedModels = [];
179
+ const callModelCapturing = async (args) => { capturedModels.push(args.model); return scripted[call++]; };
180
+ call = 0;
181
+ const r = await exec.runPhase({ system: 's', userContent: '# RODADA — fase assembly', apiKey: 'k', model: 'claude-sonnet-5', env: { runDir: run }, callModel: callModelCapturing });
182
+ const pieces = fs.readdirSync(path.join(run, 'pieces')).filter((f) => f.endsWith('.html'));
183
+ assert(pieces.length === 7, `AC4: 7 peças escritas, UMA por turn (escritas: ${pieces.length})`);
184
+ assert(fs.existsSync(path.join(run, 'artifacts/assembly.md')), 'AC4: artefato final escrito após as peças');
185
+ assert(r.iters === 9, `AC2: loop percorreu 9 turns (não parou no 1º max_tokens) — foram ${r.iters}`);
186
+ assert(capturedModels.every((m) => m === 'claude-sonnet-5'), 'AC3: o modelo pinado é passado ao callModel em todo turn');
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // AC1/AC8/AC10 — end-to-end HERMÉTICO com o motor + compilador reais (só LLM/imagem mockados).
191
+ // ---------------------------------------------------------------------------
192
+
193
+ function digestArtifact() {
194
+ return [
195
+ '## Noticia', 'Titulo da noticia de teste.',
196
+ '## Sintese Acessivel', 'Resumo simples.',
197
+ '## Angulos para Slides', '- angulo 1', '- angulo 2', '- angulo 3', '- angulo 4', '- angulo 5', '- angulo 6',
198
+ // `contrato_entrada` (Story SF11.6 / ADR-020 DF-SF10.21-3): a etapa `digest` do pod-fixture
199
+ // ganhou a assertion field_matches ^(ancora:hn)$ — sem esta linha o motor bloqueia a fase.
200
+ '', 'confianca: alta', 'contrato_entrada: ancora:hn', '',
201
+ ].join('\n');
202
+ }
203
+ function visualsArtifact() {
204
+ return [
205
+ '## Prompt da Capa', 'Prompt da capa gerado.',
206
+ '## Prompt do CTA', 'Prompt do CTA gerado.',
207
+ '## Arquivos Gerados', '- assets/cover.png', '- assets/cta.png',
208
+ '## Conformidade de Referencia', 'Estilo inspirado, composicao propria.',
209
+ '', 'referencia_copiada: nao', 'confianca: alta', '',
210
+ ].join('\n');
211
+ }
212
+ // ---------------------------------------------------------------------------
213
+ // LUGAR DAS PEÇAS — `ADR-028 DF-SF12.2-1/-2` (Story SF12.2).
214
+ //
215
+ // Até a SF12.2 este mock escrevia as 7 peças em `pieces/0N.html`. `pieces/` NUNCA foi
216
+ // convenção de runtime: ZERO ocorrências em pod-export-executor/service/compile/ui/tools,
217
+ // em deploy/, installer/ e template/ — era escolha arbitrária deste test double, escrita na
218
+ // SF10.2, antes de o `render-png` existir e antes de o pod declarar qualquer lugar.
219
+ //
220
+ // O pod DECLARA o lugar (knowledge/slides-html.md "Formato base", agents/carousel-builder.md,
221
+ // templates/assembly-tmpl.md): as 7 peças ficam na RAIZ do runDir, porque o veto
222
+ // `command_check render-png.cjs` recebe `--html-dir ".."` como arg LITERAL (ADR-011 proíbe
223
+ // interpolação). O mock passa a modelar um agente que OBEDECE a spec que o bundle lhe entrega
224
+ // — e o caso desobediente NÃO foi apagado: virou o controle negativo `lugarDasPecasTest()`.
225
+ // ---------------------------------------------------------------------------
226
+ const PIECE_NAMES = ['01-capa.html', '02-slide.html', '03-slide.html', '04-slide.html', '05-slide.html', '06-slide.html', '07-cta.html'];
227
+
228
+ /** Peças na raiz do runDir (caminho obediente) ou sob um subdiretório (controle negativo). */
229
+ function piecePath(i, subdir) {
230
+ return subdir ? `${subdir}/${PIECE_NAMES[i - 1]}` : PIECE_NAMES[i - 1];
231
+ }
232
+
233
+ /**
234
+ * SF12.2/ADR-024 — destrava o gate de TEXTO de `digest` quando ELE NÃO é o objeto do teste.
235
+ * O pod-template ganhou `digest.checkpoint` bloqueante (`skip_modes: ["yolo"]`) e o executor
236
+ * chama `startRun` SEM `mode` ⇒ `interactive` ⇒ toda run de export para lá primeiro. Testes cujo
237
+ * objeto é `visuals`/`assembly` atravessam o gate como o operador do serviço faria (`approve`),
238
+ * em vez de fingir um modo que o executor nunca usa. Quem TESTA o gate (`endToEndTest`,
239
+ * `fixerOrderingTest`, `exportHerdaGateDeTextoTest`, `lugarDasPecasTest`) o faz explicitamente.
240
+ */
241
+ async function driveAlemDoGateDeTexto(ctx, run) {
242
+ let r = await exec.drive(ctx, run, {});
243
+ if (r.awaiting === 'digest') { motor.approve(ctx); r = await exec.drive(ctx, run, {}); }
244
+ return r;
245
+ }
246
+
247
+ /** HTML mínimo e autocontido de uma peça (o `render-png` mede o PNG, não o conteúdo). */
248
+ function pecaHtml(i, extra = '') {
249
+ return `<!doctype html>\n<html lang="pt-BR">\n<head><meta charset="utf-8"><title>${PIECE_NAMES[i - 1]}</title>\n`
250
+ + '<style>html,body{width:1080px;height:1350px;margin:0;background:#101820;color:#fff;font-family:sans-serif}</style>\n'
251
+ + `</head>\n<body><main>peca ${i}${extra}</main></body>\n</html>\n`;
252
+ }
253
+
254
+ /**
255
+ * SF12.2/ADR-028 — próxima peça a escrever, decidida SÓ pela conversa recebida (nunca por contador
256
+ * externo, para não encenar o resultado). Existe porque a fase `assembly` do pod-template passou a
257
+ * ter o veto `render-png`: sem as 7 peças NA RAIZ a etapa bloqueia, e mocks conversacionais que só
258
+ * escreviam o artefato precisam produzir o ENTREGÁVEL antes dele, como o agente real faz.
259
+ */
260
+ function proximaPeca(convo) {
261
+ const i = PIECE_NAMES.findIndex((n) => !convo.includes(`escrito: ${n}`));
262
+ return i < 0 ? null : i + 1;
263
+ }
264
+
265
+ /** Lista as peças HTML da RAIZ do runDir (nunca recursivo — o lugar é o que se verifica). */
266
+ function rootPieces(runDir) {
267
+ return fs.readdirSync(runDir, { withFileTypes: true })
268
+ .filter((e) => e.isFile() && e.name.endsWith('.html'))
269
+ .map((e) => e.name)
270
+ .sort();
271
+ }
272
+
273
+ function assemblyArtifact() {
274
+ // Sem em-dash/en-dash (lexical-check --pattern [—–] --max 0).
275
+ // Paths do `## Pacote Final` RELATIVOS À RAIZ (ADR-028): `01-capa.html`, nunca `pieces/01-capa.html`.
276
+ return [
277
+ '## Slides Internos', '- slide 1', '- slide 2', '- slide 3', '- slide 4', '- slide 5',
278
+ '## Slide CTA', 'Chamada final forte.',
279
+ '## Pacote Final', '- 01-capa.html', '- 07-cta.html',
280
+ '', 'confianca: alta', '',
281
+ ].join('\n');
282
+ }
283
+
284
+ async function endToEndTest() {
285
+ console.log('\n--- AC1/AC8/AC10: end-to-end hermético (compilador + motor reais, LLM/imagem mock) ---');
286
+ // 1) Compila content-carousel para um bundle em tmp (rede de endpoint OFF) + lock coerente.
287
+ const bundleDir = tmpDir('bundle');
288
+ const lockPath = path.join(tmpDir('lockout'), 'export.lock.json');
289
+ const { bundleFiles, lock } = await compiler.compile(FIXTURE_POD, { checkEndpoint: false });
290
+ compiler.writeBundle(bundleDir, bundleFiles);
291
+ fs.writeFileSync(lockPath, compiler.serializeLock(lock));
292
+ assert(lock.framework_version === exec.engineFrameworkVersion(), 'bundle fresco: lock.framework_version == motor (coerente)');
293
+
294
+ const bundleMemPath = path.join(bundleDir, 'memory', 'learnings.consolidated.md');
295
+ const memShaBefore = fs.existsSync(bundleMemPath) ? sha(bundleMemPath) : null;
296
+
297
+ // 2) Deps injetadas (mock): callModel roteia por fase; openrouter_image devolve PNG mínimo.
298
+ const png1x1 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
299
+ const usedImageModels = [];
300
+ const turnByPhase = {};
301
+ const capturedAgentModels = [];
302
+ const callModelInner = async ({ model, messages }) => {
303
+ capturedAgentModels.push(model);
304
+ const first = messages[0].content;
305
+ const m = /fase (\w+)/.exec(typeof first === 'string' ? first : JSON.stringify(first));
306
+ const phase = m ? m[1] : 'unknown';
307
+ turnByPhase[phase] = (turnByPhase[phase] || 0) + 1;
308
+ const t = turnByPhase[phase];
309
+ const tuId = `${phase}-${t}`;
310
+ if (phase === 'digest') {
311
+ if (t === 1) return { stop_reason: 'tool_use', content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: 'artifacts/digest.md', content: digestArtifact() } }] };
312
+ return { stop_reason: 'end_turn', content: [{ type: 'text', text: 'digest pronto' }] };
313
+ }
314
+ if (phase === 'visuals') {
315
+ if (t === 1) return { stop_reason: 'tool_use', content: [{ type: 'tool_use', id: tuId, name: 'openrouter_image', input: { prompt: 'capa', out_path: 'assets/cover.png' } }] };
316
+ if (t === 2) return { stop_reason: 'tool_use', content: [{ type: 'tool_use', id: tuId, name: 'read_image', input: { ref: 'assets/cover.png' } }] };
317
+ if (t === 3) return { stop_reason: 'tool_use', content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: 'artifacts/visuals.md', content: visualsArtifact() } }] };
318
+ return { stop_reason: 'end_turn', content: [{ type: 'text', text: 'visuals pronto' }] };
319
+ }
320
+ if (phase === 'assembly') {
321
+ // ADR-028: peças na RAIZ, com os nomes que o pod declara — agente OBEDIENTE à spec do bundle.
322
+ if (t <= 7) return { stop_reason: 'max_tokens', content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: piecePath(t), content: pecaHtml(t) } }] };
323
+ if (t === 8) return { stop_reason: 'tool_use', content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: 'artifacts/assembly.md', content: assemblyArtifact() } }] };
324
+ return { stop_reason: 'end_turn', content: [{ type: 'text', text: 'assembly pronto' }] };
325
+ }
326
+ return { stop_reason: 'end_turn', content: [{ type: 'text', text: 'noop' }] };
327
+ };
328
+ // Toda resposta carrega usage (DF3-4): o executor acumula por turn (runPhase) e drive soma no run.
329
+ const callModel = async (args) => { const r = await callModelInner(args); return { ...r, usage: r.usage || { input_tokens: 12, output_tokens: 8 } }; };
330
+ const imageTransport = async (payload) => { usedImageModels.push(payload.model); return { choices: [{ message: { images: [{ image_url: { url: png1x1 } }] } }] }; };
331
+
332
+ // 3) setupRun (AC16 conferida) + startRun (memoryPodDir efêmero) + drive.
333
+ const runsRoot = tmpDir('runs');
334
+ const payload = { pod: 'content-carousel', workflow: 'wf-content-carousel', fetch_allow_hosts: [], article_url: 'https://example.com/a' };
335
+ const logs = [];
336
+ const run = exec.setupRun({ bundleDir, lockOverride: lockPath, payload, deps: { callModel, imageTransport }, log: (m) => logs.push(m) });
337
+ assert(run.coherence.engineVersion === run.coherence.lockVersion, 'AC16: setupRun confere coerência antes do run');
338
+ assert(run.whitelist.includes('example.com'), 'AC7: whitelist do run derivada do payload (host de article_url)');
339
+
340
+ const memoryPodDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pex-mem-'));
341
+ const { ctx } = motor.startRun({ workflowPath: path.join(bundleDir, 'workflow.yaml'), podRootOverride: bundleDir, pod: run.pod, workflow: run.workflow, runsRoot, runId: 'e2e-1', memoryPodDir });
342
+ // SF12.2/ADR-024: o pod-template ganhou o GATE DE TEXTO em `digest` (`skip_modes: ["yolo"]`).
343
+ // O executor chama `startRun` SEM `mode` ⇒ `interactive` ⇒ a 1ª parada é `digest`. O serviço já
344
+ // roteia `awaiting` de QUALQUER fase (`classifyOutcome`), logo isto é herança, não código novo.
345
+ const r0 = await exec.drive(ctx, run, {});
346
+ assert(r0.awaiting === 'digest', `SF12.2: 1ª parada do export é o gate de TEXTO em digest (awaiting=${r0.awaiting})`);
347
+ const apTexto = motor.approve(ctx);
348
+ assert(apTexto.action === 'approved' && apTexto.step === 'digest', `SF12.2: approve do gate de texto libera a fase cara (${apTexto.action}/${apTexto.step})`);
349
+
350
+ const r1 = await exec.drive(ctx, run, {});
351
+ assert(r1.awaiting === 'assembly', `parou no checkpoint blocking da fase assembly (awaiting=${r1.awaiting})`);
352
+ // DF3-4 — o executor agora EXPÕE usage REAL no RESULTADO (agregado das fases pelo drive), o campo
353
+ // que o ledger do serviço consome. Prova que o freio deixa de registrar 0.
354
+ const usage = {
355
+ input_tokens: (r0.usage.input_tokens || 0) + (r1.usage.input_tokens || 0),
356
+ output_tokens: (r0.usage.output_tokens || 0) + (r1.usage.output_tokens || 0),
357
+ images: (r0.usage.images || 0) + (r1.usage.images || 0),
358
+ };
359
+ assert(usage.input_tokens > 0 && usage.output_tokens > 0, `DF3-4: drive expõe usage REAL agregado (tokens somados das fases: in=${usage.input_tokens})`);
360
+ assert(usage.images >= 1, `DF3-4: usage.images conta imagens OpenRouter geradas com sucesso (${usage.images})`);
361
+
362
+ // Artefatos verificados EM DISCO pelo motor real (vetos + template) → provam AC1/AC10.
363
+ for (const a of ['digest', 'visuals', 'assembly']) {
364
+ assert(fs.existsSync(path.join(ctx.runDir, 'artifacts', `${a}.md`)), `artefato ${a}.md verificado em disco pelo motor`);
365
+ }
366
+ const pieces = rootPieces(ctx.runDir);
367
+ assert(pieces.length === 7, `AC4 end-to-end: 7 peças HTML escritas uma-por-turn (${pieces.length})`);
368
+ assert(pieces.join(',') === PIECE_NAMES.join(','), `ADR-028 AC26: as 7 peças estão na RAIZ do runDir, com os nomes do pod (${pieces.join(',')})`);
369
+
370
+ // SF12.2/ADR-025 — o VETO `render-png` rodou DENTRO do runtime de export: prova em disco, não
371
+ // por ausência de bloqueio. Mesma classe de evidência do AC7 (smoke): contagem + IHDR dos 7 PNG.
372
+ const pngDir = path.join(ctx.runDir, 'png');
373
+ const pngs = fs.existsSync(pngDir) ? fs.readdirSync(pngDir).filter((f) => f.endsWith('.png')).sort() : [];
374
+ assert(pngs.length === 7, `AC25/ADR-028: o veto render-png renderizou 7 PNG no runtime de EXPORT (${pngs.length})`);
375
+ const dims = pngs.map((f) => {
376
+ const b = fs.readFileSync(path.join(pngDir, f));
377
+ return `${b.readUInt32BE(16)}x${b.readUInt32BE(20)}`;
378
+ });
379
+ assert(dims.every((d) => d === '1080x1350'), `AC25/ADR-028: IHDR de cada PNG do export mede 1080x1350 (${[...new Set(dims)].join(', ')})`);
380
+ assert(fs.readFileSync(path.join(ctx.runDir, 'artifacts', 'assembly.md'), 'utf8').includes('pc:entregavel-png'),
381
+ 'AC25/ADR-028: o bloco `pc:entregavel-png` foi gravado no artefato do run de EXPORT');
382
+ assert(usedImageModels.length >= 1 && usedImageModels.every((m) => m === 'google/gemini-2.5-flash-image'), 'AC3 end-to-end: imagem usa o modelo PINADO do bundle');
383
+ assert(capturedAgentModels.every((m) => m === 'claude-sonnet-5'), 'AC3 end-to-end: agente usa o modelo PINADO do bundle');
384
+
385
+ // AC8 — memória do BUNDLE nunca escrita por um run (isolamento mecânico DF2-2).
386
+ const memShaAfter = fs.existsSync(bundleMemPath) ? sha(bundleMemPath) : null;
387
+ assert(memShaBefore === memShaAfter, 'AC8: memory/learnings.consolidated.md do BUNDLE inalterado após o run');
388
+ const bundleRawLog = path.join(bundleDir, 'memory', 'learnings.jsonl');
389
+ assert(!fs.existsSync(bundleRawLog), 'AC8: nenhum learnings.jsonl escrito dentro do bundle');
390
+
391
+ // approve → complete (checkpoint humano; sem identidade nesta fase — F3). O fechamento limpo
392
+ // grava o padrão-ok NO memoryPodDir efêmero (sink descartável), NUNCA no bundle.
393
+ const statePath = motor.findStateByRunId(runsRoot, 'e2e-1');
394
+ const memApprove = fs.mkdtempSync(path.join(os.tmpdir(), 'pex-mem-'));
395
+ const ctx2 = motor.openRun({ statePath, workflowPath: path.join(bundleDir, 'workflow.yaml'), podRootOverride: bundleDir, memoryPodDir: memApprove });
396
+ const ap = motor.approve(ctx2);
397
+ assert(ap.action === 'approved' && ap.next && ap.next.action === 'complete', `approve → complete (${ap.action}/${ap.next && ap.next.action})`);
398
+ assert(fs.existsSync(path.join(memApprove, 'memory')), 'AC8: aprendizado do fechamento foi para o memoryPodDir EFÊMERO (sink), não o bundle');
399
+ const memShaFinal = fs.existsSync(bundleMemPath) ? sha(bundleMemPath) : null;
400
+ assert(memShaBefore === memShaFinal, 'AC8: memory do BUNDLE segue inalterado mesmo após o complete/approve');
401
+ void memoryPodDir;
402
+ }
403
+
404
+ // ---------------------------------------------------------------------------
405
+ // SF10.11 (AC1/AC5/DF-SF10.11+12-1) — o FIXER roda ENTRE runPhase e motor.advance, end-to-end,
406
+ // com o MOTOR e o VETO REAIS. Prova de ORDEM: o modelo escreve travessões no artefato que o
407
+ // `lexical-check` (--max 0) verifica; se o fixer rodasse DEPOIS do advance (ou não rodasse), a
408
+ // etapa seria BLOQUEADA e a fase re-executaria. Passar na 1ª tentativa só é possível com o fixer
409
+ // como ÚLTIMO ESCRITOR ANTES DA VERIFICAÇÃO.
410
+ // ---------------------------------------------------------------------------
411
+
412
+ const EM_DASH = '—';
413
+ const EN_DASH = '–';
414
+
415
+ async function fixerOrderingTest() {
416
+ console.log('\n--- SF10.11: fixer determinístico ENTRE runPhase e motor.advance (motor/veto reais) ---');
417
+ const bundleDir = tmpDir('bundle-fix');
418
+ const lockPath = path.join(tmpDir('lockout-fix'), 'export.lock.json');
419
+ const { bundleFiles, lock } = await compiler.compile(FIXTURE_POD, { checkEndpoint: false });
420
+ compiler.writeBundle(bundleDir, bundleFiles);
421
+ fs.writeFileSync(lockPath, compiler.serializeLock(lock));
422
+
423
+ // Artefato de assembly COM a classe mecânica que o veto blessed conta (e que o modelo, no achado
424
+ // de SF10.7, não removeu em 3 reamostragens caras).
425
+ const assemblySujo = [
426
+ '## Slides Internos',
427
+ `- slide 1 ${EM_DASH} gancho`, `- slide 2 ${EM_DASH} conflito`, '- slide 3', '- slide 4', '- slide 5',
428
+ '## Slide CTA', `Chamada final ${EM_DASH} forte.`,
429
+ '## Pacote Final', '- 01-capa.html', '- 07-cta.html',
430
+ '', 'confianca: alta', '',
431
+ ].join('\n');
432
+
433
+ const png1x1 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
434
+ const turnByPhase = {};
435
+ const callModel = async ({ messages }) => {
436
+ const first = messages[0].content;
437
+ const m = /fase (\w+)/.exec(typeof first === 'string' ? first : JSON.stringify(first));
438
+ const phase = m ? m[1] : 'unknown';
439
+ turnByPhase[phase] = (turnByPhase[phase] || 0) + 1;
440
+ const t = turnByPhase[phase];
441
+ const tuId = `${phase}-${t}`;
442
+ const wrap = (r) => ({ ...r, usage: { input_tokens: 10, output_tokens: 5 } });
443
+ if (phase === 'digest') {
444
+ if (t === 1) return wrap({ stop_reason: 'tool_use', content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: 'artifacts/digest.md', content: digestArtifact() } }] });
445
+ return wrap({ stop_reason: 'end_turn', content: [{ type: 'text', text: 'ok' }] });
446
+ }
447
+ if (phase === 'visuals') {
448
+ if (t === 1) return wrap({ stop_reason: 'tool_use', content: [{ type: 'tool_use', id: tuId, name: 'openrouter_image', input: { prompt: 'capa', out_path: 'assets/cover.png' } }] });
449
+ if (t === 2) return wrap({ stop_reason: 'tool_use', content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: 'artifacts/visuals.md', content: visualsArtifact() } }] });
450
+ return wrap({ stop_reason: 'end_turn', content: [{ type: 'text', text: 'ok' }] });
451
+ }
452
+ if (phase === 'assembly') {
453
+ // As 7 peças HTML (que NENHUM veto do wf inspeciona LEXICALMENTE) também nascem com travessão.
454
+ // ADR-028: na RAIZ do runDir — o `render-png` as procura ali (arg literal `--html-dir ".."`).
455
+ if (t <= 7) return wrap({ stop_reason: 'max_tokens', content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: piecePath(t), content: pecaHtml(t, ` ${EM_DASH} copy publicada de 2020${EN_DASH}2024`) } }] });
456
+ if (t === 8) return wrap({ stop_reason: 'tool_use', content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: 'artifacts/assembly.md', content: assemblySujo } }] });
457
+ return wrap({ stop_reason: 'end_turn', content: [{ type: 'text', text: 'ok' }] });
458
+ }
459
+ return wrap({ stop_reason: 'end_turn', content: [{ type: 'text', text: 'noop' }] });
460
+ };
461
+ const imageTransport = async () => ({ choices: [{ message: { images: [{ image_url: { url: png1x1 } }] } }] });
462
+
463
+ const runsRoot = tmpDir('runs-fix');
464
+ const payload = { pod: 'content-carousel', workflow: 'wf-content-carousel', fetch_allow_hosts: [], article_url: 'https://example.com/a' };
465
+ const logs = [];
466
+ const run = exec.setupRun({ bundleDir, lockOverride: lockPath, payload, deps: { callModel, imageTransport }, log: (m) => logs.push(m) });
467
+ const memoryPodDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pex-mem-'));
468
+ const { ctx } = motor.startRun({ workflowPath: path.join(bundleDir, 'workflow.yaml'), podRootOverride: bundleDir, pod: run.pod, workflow: run.workflow, runsRoot, runId: 'fixer-1', memoryPodDir });
469
+ // SF12.2/ADR-024: destrava o gate de TEXTO de `digest` antes de chegar em `assembly` (o objeto
470
+ // deste teste é a ORDEM fixer × veto na fase `assembly`, não o gate).
471
+ const rGate = await exec.drive(ctx, run, {});
472
+ assert(rGate.awaiting === 'digest', `SF12.2: 1ª parada é o gate de texto (awaiting=${rGate.awaiting})`);
473
+ motor.approve(ctx);
474
+ const r = await exec.drive(ctx, run, {});
475
+
476
+ // O veto lexical (command_check blessed, --max 0) NÃO bloqueou: a etapa avançou ao checkpoint.
477
+ assert(r.awaiting === 'assembly', `SF10.11: com travessões no artefato, a etapa AVANÇOU até o checkpoint (awaiting=${r.awaiting}) — o fixer correu antes do veto`);
478
+ assert(turnByPhase.assembly === 9, `SF10.11 (ORDEM): a fase assembly rodou UMA vez só (9 turns; um retry daria 18) — foram ${turnByPhase.assembly}`);
479
+ assert(!logs.some((l) => /VETO DISPAROU/.test(l)), 'SF10.11: nenhum "VETO DISPAROU" no run — a reprovação evitável deixou de existir');
480
+
481
+ const pattern = '[\\u2014\\u2013]';
482
+ const assemblyDisco = fs.readFileSync(path.join(ctx.runDir, 'artifacts', 'assembly.md'), 'utf8');
483
+ assert(lexical.countMatches(assemblyDisco, pattern) === 0, 'SF10.11: o artefato VERIFICADO ficou com 0 ocorrência (contador real do lexical-check)');
484
+ assert(assemblyDisco.includes('- slide 1, gancho'), 'SF10.11: a substituição aprovada pelo dono (vírgula) é a que foi aplicada');
485
+
486
+ const pecas = rootPieces(ctx.runDir);
487
+ const pecasLimpas = pecas.filter((f) => lexical.countMatches(fs.readFileSync(path.join(ctx.runDir, f), 'utf8'), pattern) === 0);
488
+ assert(pecas.length === 7 && pecasLimpas.length === 7,
489
+ `SF10.11 (AC1/DF-SF10.11+12-0(a)): as 7 PEÇAS HTML publicadas saíram limpas (${pecasLimpas.length}/7) — nenhum veto do wf as inspeciona`);
490
+
491
+ // AC4 — a evidência do fixer sobe no RESULTADO do drive (auditoria do operador na fila).
492
+ assert(Array.isArray(r.fixes) && r.fixes.some((f) => f.step === 'assembly' && f.attempt === 1 && f.files === 8),
493
+ `SF10.11 (AC4): o RESULTADO do drive registra o fixer por tentativa (${JSON.stringify(r.fixes)})`);
494
+ assert(logs.some((l) => /\[fixer\]/.test(l) && /assembly\.md/.test(l)), 'SF10.11 (AC4): a correção nunca é silenciosa no log do run');
495
+ void memoryPodDir;
496
+ }
497
+
498
+ // ===========================================================================
499
+ // SF10.12 — CORREÇÃO GUIADA PÓS-VETO (DF-SF10.12-1/-2).
500
+ //
501
+ // O achado que esta story ataca: antes dela, cada retry reconstruía `userContent` do zero e
502
+ // `messages` nascia vazio, então o artefato REPROVADO nunca entrava no contexto — três amostras
503
+ // independentes, não três edições. Aqui a conversa é RETOMADA com o veredito REAL da máquina.
504
+ //
505
+ // O modelo mockado decide o que fazer OLHANDO A CONVERSA QUE RECEBE (nunca um contador global de
506
+ // tentativa). É o que torna a prova portadora de carga: sob o código antigo ele receberia uma
507
+ // conversa vazia, não veria a imagem já gravada e a REGERARIA. Sob o código novo ele a vê.
508
+ // ===========================================================================
509
+
510
+ /** Artefato de visuals que o veto REPROVA (`referencia_copiada: ^nao$`) e que o fixer TAMBÉM toca. */
511
+ function visualsReprovado() {
512
+ return [
513
+ '## Prompt da Capa', `Prompt da capa ${EM_DASH} gerado com estilo inspirado.`,
514
+ '## Prompt do CTA', 'Prompt do CTA gerado.',
515
+ '## Arquivos Gerados', '- assets/cover.png',
516
+ '## Conformidade de Referencia', 'Composicao propria.',
517
+ '', 'referencia_copiada: sim', 'confianca: alta', '',
518
+ ].join('\n');
519
+ }
520
+
521
+ async function compileFixtureBundle(label) {
522
+ const bundleDir = tmpDir(`bundle-${label}`);
523
+ const lockPath = path.join(tmpDir(`lockout-${label}`), 'export.lock.json');
524
+ const { bundleFiles, lock } = await compiler.compile(FIXTURE_POD, { checkEndpoint: false });
525
+ compiler.writeBundle(bundleDir, bundleFiles);
526
+ fs.writeFileSync(lockPath, compiler.serializeLock(lock));
527
+ return { bundleDir, lockPath };
528
+ }
529
+
530
+ const PNG_1X1 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
531
+
532
+ async function guidedCorrectionTest() {
533
+ console.log('\n--- SF10.12 (AC1/AC3/AC4/AC5): correção guiada pós-veto retoma a MESMA conversa ---');
534
+ const { bundleDir, lockPath } = await compileFixtureBundle('guided');
535
+
536
+ const calls = []; // uma entrada por chamada de modelo (medição, não estimativa)
537
+ let imageApiCalls = 0; // ORÁCULO DE DINHEIRO: chamadas REAIS ao transport do OpenRouter
538
+
539
+ const callModel = async ({ system, messages }) => {
540
+ const first = messages[0].content;
541
+ const m = /fase (\w+)/.exec(typeof first === 'string' ? first : JSON.stringify(first));
542
+ const phase = m ? m[1] : 'unknown';
543
+ const convo = JSON.stringify(messages);
544
+ // MEDIÇÃO do contexto que sobe: bytes REAIS do payload (system + conversa inteira). O proxy
545
+ // de token é declarado (4 chars/token) e aplicado igual nas duas tentativas, então o DELTA é
546
+ // uma medida do crescimento real da conversa, não um número inventado.
547
+ const chars = JSON.stringify({ system, messages }).length;
548
+ const inTok = Math.ceil(chars / 4);
549
+ const recebeuCorrecao = /VERIFICAÇÃO DE MÁQUINA REPROVOU/.test(convo);
550
+ // TEXTO CRU do último turn `user` — é literalmente o que o modelo recebeu. Comparar contra o
551
+ // `convo` (JSON) esconderia escapes (`\"`) e deixaria a asserção de literalidade frouxa.
552
+ const ultima = messages[messages.length - 1];
553
+ const textoRecebido = ultima && ultima.role === 'user' && typeof ultima.content === 'string' ? ultima.content : '';
554
+ const rec = { phase, msgs: messages.length, chars, inTok, recebeuCorrecao, emitiu: null, convo, textoRecebido };
555
+ calls.push(rec);
556
+ const wrap = (name, content) => { rec.emitiu = name; return { stop_reason: 'tool_use', content, usage: { input_tokens: inTok, output_tokens: 8 } }; };
557
+ const fim = () => ({ stop_reason: 'end_turn', content: [{ type: 'text', text: 'ok' }], usage: { input_tokens: inTok, output_tokens: 4 } });
558
+ const tuId = `${phase}-${calls.length}`;
559
+
560
+ if (phase === 'digest') {
561
+ if (!/escrito: artifacts\/digest\.md/.test(convo)) return wrap('write_file', [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: 'artifacts/digest.md', content: digestArtifact() } }]);
562
+ return fim();
563
+ }
564
+ if (phase === 'visuals') {
565
+ // Decisões derivadas SÓ da conversa recebida (posição relativa dos eventos), nunca de estado
566
+ // externo: é assim que o teste mede o efeito da retomada em vez de encenar o resultado.
567
+ const iImg = convo.lastIndexOf('imagem salva: assets/cover.png');
568
+ const iCorr = convo.lastIndexOf('VERIFICAÇÃO DE MÁQUINA REPROVOU');
569
+ const iWrite = convo.lastIndexOf('escrito: artifacts/visuals.md');
570
+ if (iImg < 0) return wrap('openrouter_image', [{ type: 'tool_use', id: tuId, name: 'openrouter_image', input: { prompt: 'capa', out_path: 'assets/cover.png' } }]);
571
+ if (iCorr > iWrite) return wrap('write_file', [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: 'artifacts/visuals.md', content: visualsArtifact() } }]);
572
+ if (iWrite < 0) return wrap('write_file', [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: 'artifacts/visuals.md', content: visualsReprovado() } }]);
573
+ return fim();
574
+ }
575
+ if (phase === 'assembly') {
576
+ // ADR-028: as 7 pecas na RAIZ ANTES do artefato (o veto render-png as exige em disco).
577
+ const nx = proximaPeca(convo);
578
+ if (nx) return wrap('write_file', [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: piecePath(nx), content: pecaHtml(nx) } }]);
579
+ if (!/escrito: artifacts\/assembly\.md/.test(convo)) return wrap('write_file', [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: 'artifacts/assembly.md', content: assemblyArtifact() } }]);
580
+ return fim();
581
+ }
582
+ return fim();
583
+ };
584
+ const imageTransport = async () => { imageApiCalls++; return { choices: [{ message: { images: [{ image_url: { url: PNG_1X1 } }] } }] }; };
585
+
586
+ const runsRoot = tmpDir('runs-guided');
587
+ const payload = { pod: 'content-carousel', workflow: 'wf-content-carousel', fetch_allow_hosts: [], article_url: 'https://example.com/a' };
588
+ const logs = [];
589
+ const run = exec.setupRun({ bundleDir, lockOverride: lockPath, payload, deps: { callModel, imageTransport }, log: (msg) => logs.push(msg) });
590
+ const memoryPodDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pex-mem-'));
591
+ const { ctx } = motor.startRun({ workflowPath: path.join(bundleDir, 'workflow.yaml'), podRootOverride: bundleDir, pod: run.pod, workflow: run.workflow, runsRoot, runId: 'guided-1', memoryPodDir });
592
+ const r = await driveAlemDoGateDeTexto(ctx, run);
593
+
594
+ // --- AC1: a tentativa 2 CONTINUOU a conversa, não recomeçou ---
595
+ const visuals = calls.filter((c) => c.phase === 'visuals');
596
+ const guiada = visuals.filter((c) => c.recebeuCorrecao);
597
+ assert(visuals[0].msgs === 1, `AC1: a 1ª tentativa de visuals nasce com conversa VAZIA (messages=${visuals[0].msgs})`);
598
+ assert(guiada.length > 0 && guiada[0].msgs > 1,
599
+ `AC1: a tentativa guiada RETOMA a conversa (${guiada.length > 0 ? guiada[0].msgs : 0} mensagens, não 1) — messages cresce, não reinicia`);
600
+ assert(!visuals.some((c) => c.msgs === 1 && c.recebeuCorrecao), 'AC1: nenhuma correção é entregue numa conversa reconstruída do zero');
601
+
602
+ // --- AC5: as failures[] REAIS do motor, LITERAIS, chegaram ao modelo (nunca paráfrase) ---
603
+ const vetoLog = logs.find((l) => /\[visuals\] VETO DISPAROU/.test(l));
604
+ assert(Boolean(vetoLog), 'AC4: o veto de máquina REPROVOU a 1ª tentativa (referencia_copiada: sim)');
605
+ const falhaLiteral = vetoLog.slice(vetoLog.indexOf('):') + 3).trim();
606
+ assert(falhaLiteral.length > 0 && guiada[0].textoRecebido.includes(falhaLiteral),
607
+ `AC5: a falha REAL do motor viajou LITERAL até o modelo, no texto que ele recebeu ("${falhaLiteral.slice(0, 60)}...")`);
608
+ assert(/veredito LITERAL da máquina/.test(guiada[0].textoRecebido) && guiada[0].textoRecebido.includes(`- ${falhaLiteral}`),
609
+ 'AC5: a falha aparece como item do veredito da máquina, sem reescrita (nenhuma paráfrase substitui o oráculo)');
610
+ assert(logs.some((l) => l.includes('entregue ao modelo (literal)') && l.includes(falhaLiteral)),
611
+ 'AC5: o log do run registra, verbatim, a falha entregue ao modelo (auditável depois do fato)');
612
+ assert(/veto\.assertions\[\d+\]/.test(falhaLiteral), `AC5: o veredito é o do motor, com a forma que ELE emite (${falhaLiteral.slice(0, 40)})`);
613
+
614
+ // --- AC1(ii): o diff do FIXER (SF10.11) é insumo da mensagem de correção ---
615
+ assert(guiada[0].convo.includes('Correções mecânicas JÁ APLICADAS'),
616
+ 'AC1(ii): o diff do fixer da SF10.11 entra na mensagem de correção (senão o modelo reescreve por cima)');
617
+ assert(Array.isArray(r.fixes) && r.fixes.some((f) => f.step === 'visuals'), `AC1(ii): houve fixer nesta tentativa (${JSON.stringify(r.fixes)})`);
618
+
619
+ // --- AC1(iii): ordem de EDITAR, não refazer ---
620
+ assert(/EDITE, não refaça/.test(guiada[0].convo) && /menor alteração possível/.test(guiada[0].convo),
621
+ 'AC1(iii): a ordem explícita é EDITAR o que existe, não refazer a fase');
622
+
623
+ // --- AC3: ZERO openrouter_image na tentativa guiada (três oráculos independentes) ---
624
+ const trailLogs = logs.filter((l) => /\[visuals\] \d+ turns, tools=/.test(l));
625
+ assert(trailLogs.length === 2, `AC3: visuals rodou 2 tentativas (${trailLogs.length}) — a 1ª reprovada, a 2ª guiada`);
626
+ assert(/openrouter_image/.test(trailLogs[0]), `AC3: a 1ª tentativa GEROU a capa (toolTrail: ${trailLogs[0].trim()})`);
627
+ assert(!/openrouter_image/.test(trailLogs[1]),
628
+ `AC3 (ORÁCULO toolTrail): ZERO openrouter_image na tentativa GUIADA (toolTrail: ${trailLogs[1].trim()})`);
629
+ assert(!guiada.some((c) => c.emitiu === 'openrouter_image'), 'AC3: o modelo, vendo a imagem já confirmada na conversa, não pediu para regerá-la');
630
+ assert(imageApiCalls === 1, `AC3 (ORÁCULO DE DINHEIRO): o transport do OpenRouter foi chamado UMA vez no run inteiro (${imageApiCalls}) — a reamostragem cega chamaria 2`);
631
+ assert(r.usage.images === 1, `AC3: usage.images agregado pelo drive confirma 1 imagem no run (${r.usage.images})`);
632
+
633
+ // --- AC3: DELTA de input tokens MEDIDO (a conversa acumulada sobe; é o custo declarado) ---
634
+ const base = visuals[0];
635
+ const guiadaFirst = guiada[0];
636
+ const deltaChars = guiadaFirst.chars - base.chars;
637
+ const deltaTok = guiadaFirst.inTok - base.inTok;
638
+ console.log(` [MEDIDO] 1ª tentativa: ${base.chars} chars / ~${base.inTok} tok (${base.msgs} msg) | tentativa guiada: ${guiadaFirst.chars} chars / ~${guiadaFirst.inTok} tok (${guiadaFirst.msgs} msg) | DELTA: +${deltaChars} chars / +${deltaTok} tok (${(guiadaFirst.chars / base.chars).toFixed(2)}x)`);
639
+ assert(deltaChars > 0 && deltaTok > 0, `AC3: o delta de contexto é POSITIVO e medido (+${deltaChars} chars / +${deltaTok} tok) — o custo da retomada não é escondido`);
640
+ assert(r.usage.input_tokens > 0 && r.usage.output_tokens > 0, `AC3: os turns de edição somam no usage agregado do run (in=${r.usage.input_tokens}, out=${r.usage.output_tokens})`);
641
+
642
+ // --- AC4: o veto rodou DE NOVO sobre a tentativa corrigida e é ele quem liberou a fase ---
643
+ assert(r.awaiting === 'assembly', `AC4: depois da correção guiada o motor VERIFICOU de novo e a fase avançou (awaiting=${r.awaiting})`);
644
+ const visualsDisco = fs.readFileSync(path.join(ctx.runDir, 'artifacts', 'visuals.md'), 'utf8');
645
+ assert(/referencia_copiada: nao/.test(visualsDisco), 'AC4: o artefato EM DISCO é o corrigido — quem aprovou foi o veto de máquina, não o modelo');
646
+ assert(fs.existsSync(path.join(ctx.runDir, 'assets', 'cover.png')), 'AC3: a capa gerada na 1ª tentativa continua em disco (não foi refeita nem perdida)');
647
+ void memoryPodDir;
648
+ }
649
+
650
+ // ===========================================================================
651
+ // SF10.12 AC2 — ZERO LIMITES NOVOS (DF-SF10.12-2). Checagem ESTÁTICA: a correção guiada consome
652
+ // o `MAX_RETRY` que já existe; nenhum 4º limite nasceu ao lado de MAX_TOKENS/MAX_ITERS/MAX_RETRY.
653
+ // ===========================================================================
654
+
655
+ function noNewLimitsTest() {
656
+ console.log('\n--- SF10.12 (AC2): zero limites novos no executor (DF-SF10.12-2) ---');
657
+ const src = fs.readFileSync(path.join(__dirname, 'pod-export-executor.cjs'), 'utf8');
658
+
659
+ const numericConsts = [...src.matchAll(/^const\s+([A-Z][A-Z0-9_]*)\s*=\s*(\d+)\s*;/gm)].map((m) => `${m[1]}=${m[2]}`);
660
+ assert(numericConsts.join(',') === 'MAX_TOKENS=8192,MAX_ITERS=30,MAX_RETRY=3',
661
+ `AC2: as constantes numéricas do arquivo seguem sendo EXATAMENTE as 3 do Adendo F2 (${numericConsts.join(', ')})`);
662
+
663
+ const maxIdents = [...new Set([...src.matchAll(/\bMAX_[A-Z0-9_]+\b/g)].map((m) => m[0]))].sort();
664
+ assert(maxIdents.join(',') === 'MAX_ITERS,MAX_RETRY,MAX_TOKENS', `AC2: nenhum identificador MAX_* novo no arquivo (${maxIdents.join(', ')})`);
665
+
666
+ // O laço que governa tentativas é UM só, e o teto dele é o MAX_RETRY existente.
667
+ const attemptLoops = [...src.matchAll(/for\s*\(\s*let\s+attempt\s*=\s*1;\s*attempt\s*<=\s*MAX_RETRY;/g)];
668
+ assert(attemptLoops.length === 1, `AC2: um único laço de tentativas, limitado por MAX_RETRY (${attemptLoops.length})`);
669
+
670
+ // Nenhuma estrutura de repetição nova no arquivo: a correção guiada é uma MENSAGEM a mais dentro
671
+ // do laço que já existia, não um laço de refino próprio.
672
+ const loopStatements = (src.match(/^\s*(for|while)\s*\(/gm) || []).length;
673
+ assert(loopStatements === 9, `AC2: o arquivo mantém 9 laços de statement (contagem pré-SF10.12) — nenhum laço de refino nasceu (${loopStatements})`);
674
+
675
+ // O corpo de `drive` não ganhou contador próprio de edições guiadas.
676
+ const driveSrc = src.slice(src.indexOf('async function drive('));
677
+ const driveBody = driveSrc.slice(0, driveSrc.indexOf('\n// ---'));
678
+ assert(!/\b(guidedCount|refineCount|criticCount|editCount|MAX_[A-Z]+\s*=)/.test(driveBody), 'AC2: `drive` não tem contador nem teto próprio de correções guiadas');
679
+ assert(/MAX_RETRY - 1|MAX_RETRY-1|attempt < MAX_RETRY/.test(driveBody),
680
+ 'AC2: o número de edições guiadas é DERIVADO de MAX_RETRY (auditável), nunca declarado');
681
+ }
682
+
683
+ // ===========================================================================
684
+ // SF10.12 AC8 — `resume` CAI no comportamento atual. A conversa guiada vive em MEMÓRIA DE PROCESSO
685
+ // (limite DECLARADO em DF-SF10.12-1, nunca descoberto em produção): depois de um crash, a fase
686
+ // recomeça com `buildUserContent` fresco, sem convergência herdada.
687
+ // ===========================================================================
688
+
689
+ async function resumeFallbackTest() {
690
+ console.log('\n--- SF10.12 (AC8): resume cai no comportamento atual (conversa não sobrevive ao processo) ---');
691
+ const { bundleDir, lockPath } = await compileFixtureBundle('resume');
692
+ const runsRoot = tmpDir('runs-resume');
693
+ const payload = { pod: 'content-carousel', workflow: 'wf-content-carousel', fetch_allow_hosts: [], article_url: 'https://example.com/a' };
694
+
695
+ // FASE 1 — o processo que morre: visuals reprova em TODAS as tentativas (MAX_RETRY esgotado).
696
+ const preCrash = [];
697
+ const callModelFail = async ({ messages }) => {
698
+ const first = messages[0].content;
699
+ const phase = (/fase (\w+)/.exec(typeof first === 'string' ? first : JSON.stringify(first)) || [])[1] || 'unknown';
700
+ const convo = JSON.stringify(messages);
701
+ preCrash.push({ phase, msgs: messages.length, recebeuCorrecao: /VERIFICAÇÃO DE MÁQUINA REPROVOU/.test(convo) });
702
+ const usage = { input_tokens: 10, output_tokens: 5 };
703
+ const tuId = `${phase}-${preCrash.length}`;
704
+ if (phase === 'digest' && !/escrito: artifacts\/digest\.md/.test(convo)) return { stop_reason: 'tool_use', content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: 'artifacts/digest.md', content: digestArtifact() } }], usage };
705
+ if (phase === 'visuals') {
706
+ const iCorr = convo.lastIndexOf('VERIFICAÇÃO DE MÁQUINA REPROVOU');
707
+ const iWrite = convo.lastIndexOf('escrito: artifacts/visuals.md');
708
+ // Teimoso de propósito: mesmo guiado, insiste no artefato reprovado. Esgota o MAX_RETRY.
709
+ if (iWrite < 0 || iCorr > iWrite) return { stop_reason: 'tool_use', content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: 'artifacts/visuals.md', content: visualsReprovado() } }], usage };
710
+ }
711
+ return { stop_reason: 'end_turn', content: [{ type: 'text', text: 'ok' }], usage };
712
+ };
713
+ const imageTransport = async () => ({ choices: [{ message: { images: [{ image_url: { url: PNG_1X1 } }] } }] });
714
+ const logs1 = [];
715
+ const run1 = exec.setupRun({ bundleDir, lockOverride: lockPath, payload, deps: { callModel: callModelFail, imageTransport }, log: (m) => logs1.push(m) });
716
+ const mem1 = fs.mkdtempSync(path.join(os.tmpdir(), 'pex-mem-'));
717
+ const { ctx } = motor.startRun({ workflowPath: path.join(bundleDir, 'workflow.yaml'), podRootOverride: bundleDir, pod: run1.pod, workflow: run1.workflow, runsRoot, runId: 'resume-1', memoryPodDir: mem1 });
718
+ const r1 = await driveAlemDoGateDeTexto(ctx, run1);
719
+ assert(r1.blocked === 'visuals', `AC8 (cenário): a fase esgotou MAX_RETRY e ficou bloqueada (${JSON.stringify(r1.blocked)})`);
720
+ assert(preCrash.some((c) => c.phase === 'visuals' && c.recebeuCorrecao && c.msgs > 1),
721
+ 'AC8 (contraste): DENTRO do processo, as tentativas 2 e 3 receberam correção guiada em conversa retomada');
722
+ const guiadasPreCrash = preCrash.filter((c) => c.phase === 'visuals' && c.recebeuCorrecao && c.msgs > 1).length;
723
+ assert(guiadasPreCrash >= 1, `AC2 (derivado): houve edição guiada dentro do teto de MAX_RETRY (${guiadasPreCrash} chamada(s) guiada(s))`);
724
+
725
+ // FASE 2 — o CRASH: o processo morre e leva a conversa junto. `resume` abre o state DO DISCO com
726
+ // um `drive` novinho; nada da conversa anterior existe para ser retomado.
727
+ const posResume = [];
728
+ const callModelOk = async ({ messages }) => {
729
+ const first = messages[0].content;
730
+ const phase = (/fase (\w+)/.exec(typeof first === 'string' ? first : JSON.stringify(first)) || [])[1] || 'unknown';
731
+ const convo = JSON.stringify(messages);
732
+ posResume.push({ phase, msgs: messages.length, first: typeof first === 'string' ? first : '', recebeuCorrecao: /VERIFICAÇÃO DE MÁQUINA REPROVOU/.test(convo) });
733
+ const usage = { input_tokens: 10, output_tokens: 5 };
734
+ const tuId = `r-${phase}-${posResume.length}`;
735
+ if (phase === 'visuals' && !/escrito: artifacts\/visuals\.md/.test(convo)) return { stop_reason: 'tool_use', content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: 'artifacts/visuals.md', content: visualsArtifact() } }], usage };
736
+ if (phase === 'assembly') {
737
+ // ADR-028: as 7 pecas na RAIZ ANTES do artefato (veto render-png).
738
+ const nx = proximaPeca(convo);
739
+ if (nx) return { stop_reason: 'tool_use', content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: piecePath(nx), content: pecaHtml(nx) } }], usage };
740
+ if (!/escrito: artifacts\/assembly\.md/.test(convo)) return { stop_reason: 'tool_use', content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: 'artifacts/assembly.md', content: assemblyArtifact() } }], usage };
741
+ }
742
+ return { stop_reason: 'end_turn', content: [{ type: 'text', text: 'ok' }], usage };
743
+ };
744
+ const statePath = motor.findStateByRunId(runsRoot, 'resume-1');
745
+ assert(Boolean(statePath), 'AC8: o state do run sobreviveu em disco (é a única coisa que sobrevive)');
746
+ const logs2 = [];
747
+ const run2 = exec.setupRun({ bundleDir, lockOverride: lockPath, payload, deps: { callModel: callModelOk, imageTransport }, log: (m) => logs2.push(m) });
748
+ const mem2 = fs.mkdtempSync(path.join(os.tmpdir(), 'pex-mem-'));
749
+ const ctx2 = motor.openRun({ statePath, workflowPath: path.join(bundleDir, 'workflow.yaml'), podRootOverride: bundleDir, memoryPodDir: mem2 });
750
+ const r2 = await exec.drive(ctx2, run2, {});
751
+
752
+ const primeiraPosResume = posResume.find((c) => c.phase === 'visuals');
753
+ assert(Boolean(primeiraPosResume) && primeiraPosResume.msgs === 1,
754
+ `AC8: depois do resume a fase RECOMEÇA com conversa de 1 mensagem (${primeiraPosResume && primeiraPosResume.msgs}) — nada de conversa retomada`);
755
+ assert(/^# RODADA — fase visuals/.test(primeiraPosResume.first),
756
+ 'AC8: o conteúdo é o `buildUserContent` FRESCO (contrato da fase), o comportamento pré-SF10.12');
757
+ assert(!primeiraPosResume.recebeuCorrecao && !/Correções mecânicas JÁ APLICADAS/.test(primeiraPosResume.first),
758
+ 'AC8: nenhuma correção guiada é reconstruída a partir do disco — a promessa NÃO atravessa a retomada');
759
+ assert(r2.awaiting === 'assembly', `AC8: o resume segue funcionando pelo caminho normal (awaiting=${r2.awaiting})`);
760
+ assert(!logs2.some((l) => /CORREÇÃO GUIADA/.test(l) && /visuals/.test(l)) || posResume.filter((c) => c.phase === 'visuals')[0].msgs === 1,
761
+ 'AC8: a 1ª tentativa pós-resume não é guiada (a correção só existe depois de um veto NESTE processo)');
762
+ void mem2;
763
+ }
764
+
765
+ // ===========================================================================
766
+ // SF10.12 — `appendUserTurn`: a retomada NÃO pode quebrar a alternância de papéis da Messages API
767
+ // (uma fase que para em MAX_ITERS deixa a conversa terminando em `user`/tool_results).
768
+ // ===========================================================================
769
+
770
+ function appendUserTurnTest() {
771
+ console.log('\n--- SF10.12: retomada preserva a alternância de papéis da Messages API ---');
772
+ const base = [{ role: 'user', content: 'contrato' }, { role: 'assistant', content: [{ type: 'text', text: 'pronto' }] }];
773
+ const a = exec.appendUserTurn(base, 'CORRIJA');
774
+ assert(a.length === 3 && a[2].role === 'user' && a[2].content === 'CORRIJA', 'conversa terminando em `assistant`: a correção vira um turn `user` novo');
775
+ assert(base.length === 2, 'appendUserTurn é NÃO-MUTANTE (a conversa anterior é evidência de auditoria)');
776
+
777
+ const terminandoEmUser = base.concat([{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'x', content: [{ type: 'text', text: 'escrito' }] }] }]);
778
+ const b = exec.appendUserTurn(terminandoEmUser, 'CORRIJA');
779
+ assert(b.length === 3, 'conversa terminando em `user` (tool_results de MAX_ITERS): nenhuma mensagem `user` consecutiva é criada');
780
+ assert(b[2].role === 'user' && b[2].content.length === 2 && b[2].content[1].text === 'CORRIJA',
781
+ 'a correção entra como bloco DENTRO do turn `user` existente (a API rejeitaria dois `user` seguidos)');
782
+ assert(terminandoEmUser[2].content.length === 1, 'o turn original não foi mutado');
783
+
784
+ const roles = b.map((x) => x.role).join(',');
785
+ assert(roles === 'user,assistant,user', `alternância preservada (${roles})`);
786
+ }
787
+
788
+ // ===========================================================================
789
+ // SF10.19 — PROVENIÊNCIA BLOQUEIA AQUI (ADR-018 Adendo F6-b, DF-SF10.19-2/e2 e /e3).
790
+ //
791
+ // A invariante que a SF10.8 fiou como `command_check` no `wf-*.yaml` MUDOU DE HOSPEDEIRO: o mesmo
792
+ // yaml é consumido por dois drivers e o de dev (`pc-run-pod.cjs`) não confina a superfície de
793
+ // tools, logo lá a premissa dela é insatisfazível (DF-SF10.19-0). Ela não perdeu força — passou a
794
+ // ser asserida in-process pelo EXECUTOR, antes de `motor.advance`, com HALT do run.
795
+ //
796
+ // O QUE ESTA SEÇÃO PROVA (é a metade "o export continua bloqueando" da story):
797
+ // (AC3) o run é RECUSADO quando: imagem em `assets/` fora do ledger; arquivo em `inputs/`
798
+ // divergente do sha registrado; entrada registrada sumida do disco; fase que declara o
799
+ // veto sem imagem produzida (não-vacuidade);
800
+ // (AC3) e isso vale em `run`, em `resume` E na TENTATIVA GUIADA da SF10.12 — os três caminhos;
801
+ // (AC3) pelo menos um cenário CONTORNA a camada 1 de INTEG-1 (escrita direta com `fs`);
802
+ // (AC3) HALT NÃO consome `MAX_RETRY`: a fase não é re-executada depois da violação;
803
+ // (AC4) a mensagem diz o que foi violado, qual arquivo e o que fazer;
804
+ // (inércia) pod SEM o veto declarado não passa a exigir ledger — nenhuma regressão nos demais.
805
+ // ===========================================================================
806
+
807
+ /** Cópia do pod-fixture com o `command_check` do phash-gate declarado em `visuals` (wiring REAL). */
808
+ function podWithPhashVeto(label) {
809
+ const podDir = tmpDir(`pod-${label}`);
810
+ fs.cpSync(FIXTURE_POD, podDir, { recursive: true });
811
+ const wfPath = path.join(podDir, 'workflows', 'wf-content-carousel.yaml');
812
+ const lines = fs.readFileSync(wfPath, 'utf8').split('\n');
813
+ const i = lines.findIndex((l) => /field: referencia_copiada/.test(l));
814
+ if (i < 0) throw new Error('fixture inesperada: assertion referencia_copiada não encontrada no wf');
815
+ lines.splice(i + 1, 0, ' - { type: command_check, command: "phash-gate.cjs", args: ["--refs-dir", "../inputs", "--assets-dir", "../assets"], timeout_ms: 120000, _sources: "fixture SF10.19: reproduz o wiring REAL da etapa visuals do hn-carousel" }');
816
+ fs.writeFileSync(wfPath, lines.join('\n'));
817
+ return podDir;
818
+ }
819
+
820
+ const REF_PNG = path.join(REPO_ROOT, 'scripts', '__fixtures__', 'veto-checks', 'phash', 'ref.png');
821
+ const OTHER_PNG = path.join(REPO_ROOT, 'scripts', '__fixtures__', 'veto-checks', 'phash', 'copy.png');
822
+
823
+ /** Arranca um run REAL (motor + compilador reais) do pod com o veto declarado, inputs materializados. */
824
+ async function startPhashRun(label, callModel, { runId = `prov-${label}` } = {}) {
825
+ const podDir = podWithPhashVeto(label);
826
+ const bundleDir = tmpDir(`bundle-${label}`);
827
+ const lockPath = path.join(tmpDir(`lockout-${label}`), 'export.lock.json');
828
+ const { bundleFiles, lock } = await compiler.compile(podDir, { checkEndpoint: false });
829
+ compiler.writeBundle(bundleDir, bundleFiles);
830
+ fs.writeFileSync(lockPath, compiler.serializeLock(lock));
831
+
832
+ const inputsDir = tmpDir(`inputs-${label}`);
833
+ fs.copyFileSync(REF_PNG, path.join(inputsDir, 'ref-pinterest.png'));
834
+
835
+ const imageTransport = async () => ({ choices: [{ message: { images: [{ image_url: { url: PNG_1X1 } }] } }] });
836
+ const runsRoot = tmpDir(`runs-${label}`);
837
+ const payload = { pod: 'content-carousel', workflow: 'wf-content-carousel', fetch_allow_hosts: [], article_url: 'https://example.com/a', inputs_dir: inputsDir };
838
+ const logs = [];
839
+ const run = exec.setupRun({ bundleDir, lockOverride: lockPath, payload, deps: { callModel, imageTransport }, log: (m) => logs.push(m) });
840
+ const memoryPodDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pex-mem-'));
841
+ const { ctx } = motor.startRun({ workflowPath: path.join(bundleDir, 'workflow.yaml'), podRootOverride: bundleDir, pod: run.pod, workflow: run.workflow, runsRoot, runId, memoryPodDir });
842
+ exec.materializeInputs({ bundleRoot: run.bundleRoot, inputsDir: run.inputsDir, runDir: ctx.runDir, log: run.log });
843
+ return { ctx, run, logs, runsRoot, bundleDir, lockPath, inputsDir, payload, imageTransport, runId };
844
+ }
845
+
846
+ /** Roda `drive` capturando o HALT (a asserção LANÇA — é assim que ela interrompe o run). */
847
+ async function driveCapturandoHalt(ctx, run) {
848
+ try { const r = await driveAlemDoGateDeTexto(ctx, run); return { erro: null, resultado: r }; }
849
+ catch (e) { return { erro: e, resultado: null }; }
850
+ }
851
+
852
+ /** Quantas TENTATIVAS a fase teve (uma linha de toolTrail por tentativa) — oráculo do MAX_RETRY. */
853
+ function tentativasDe(logs, step) {
854
+ return logs.filter((l) => new RegExp(`\\[${step}\\] \\d+ turns, tools=`).test(l)).length;
855
+ }
856
+
857
+ /**
858
+ * Modelo mockado padrão da fase `visuals`: gera a capa, escreve o artefato. `tamper` (opcional) é
859
+ * chamado ANTES do artefato ser escrito — é onde o cenário adultera o run por fora das tools.
860
+ */
861
+ function modeloVisuals({ getRunDir, tamper = null, artefato = visualsArtifact, gerarImagem = true, tamperNaGuiada = false }) {
862
+ return async ({ messages }) => {
863
+ const first = messages[0].content;
864
+ const phase = (/fase (\w+)/.exec(typeof first === 'string' ? first : JSON.stringify(first)) || [])[1] || 'unknown';
865
+ const convo = JSON.stringify(messages);
866
+ const usage = { input_tokens: 10, output_tokens: 5 };
867
+ const tuId = `${phase}-${Math.random().toString(36).slice(2, 8)}`;
868
+ const guiada = /VERIFICAÇÃO DE MÁQUINA REPROVOU/.test(convo);
869
+ if (phase === 'digest') {
870
+ if (!/escrito: artifacts\/digest\.md/.test(convo)) return { stop_reason: 'tool_use', content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: 'artifacts/digest.md', content: digestArtifact() } }], usage };
871
+ return { stop_reason: 'end_turn', content: [{ type: 'text', text: 'ok' }], usage };
872
+ }
873
+ if (phase === 'visuals') {
874
+ if (gerarImagem && !/imagem salva: assets\/cover\.png/.test(convo)) {
875
+ return { stop_reason: 'tool_use', content: [{ type: 'tool_use', id: tuId, name: 'openrouter_image', input: { prompt: 'capa', out_path: 'assets/cover.png' } }], usage };
876
+ }
877
+ const iCorr = convo.lastIndexOf('VERIFICAÇÃO DE MÁQUINA REPROVOU');
878
+ const iWrite = convo.lastIndexOf('escrito: artifacts/visuals.md');
879
+ if (iWrite < 0 || iCorr > iWrite) {
880
+ // ADULTERAÇÃO: escrita DIRETA com `fs`, contornando a camada 1 (denylist das tools). É o
881
+ // cenário em que a camada 2 tem de segurar sozinha — herdado de INTEG-1.
882
+ if (tamper && (!tamperNaGuiada || guiada)) tamper(getRunDir());
883
+ const conteudo = typeof artefato === 'function' ? artefato({ guiada }) : artefato;
884
+ return { stop_reason: 'tool_use', content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: 'artifacts/visuals.md', content: conteudo } }], usage };
885
+ }
886
+ return { stop_reason: 'end_turn', content: [{ type: 'text', text: 'ok' }], usage };
887
+ }
888
+ if (phase === 'assembly') {
889
+ // ADR-028: as 7 pecas na RAIZ ANTES do artefato (veto render-png).
890
+ const nx = proximaPeca(convo);
891
+ if (nx) return { stop_reason: 'tool_use', content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: piecePath(nx), content: pecaHtml(nx) } }], usage };
892
+ if (!/escrito: artifacts\/assembly\.md/.test(convo)) {
893
+ return { stop_reason: 'tool_use', content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: 'artifacts/assembly.md', content: assemblyArtifact() } }], usage };
894
+ }
895
+ }
896
+ return { stop_reason: 'end_turn', content: [{ type: 'text', text: 'ok' }], usage };
897
+ };
898
+ }
899
+
900
+ function provenanceUnitTests() {
901
+ console.log('\n--- SF10.19 (AC2): checagem canônica em pod-export-tools.cjs (implementação ÚNICA) ---');
902
+ const runDir = tmpDir('prov-unit');
903
+ assert(tools.checkRunProvenance({ runDir }).ok === true,
904
+ 'run vazio, sem ledger: NADA a verificar → passa (a mudança é INERTE para pods sem imagem)');
905
+
906
+ fs.mkdirSync(path.join(runDir, 'assets'), { recursive: true });
907
+ fs.copyFileSync(REF_PNG, path.join(runDir, 'assets', 'intrusa.png')); // por fora das tools
908
+ const semLedger = tools.checkRunProvenance({ runDir });
909
+ assert(semLedger.ok === false && /AUSENTE/.test(semLedger.violations.join(' ')),
910
+ 'imagem em assets/ SEM ledger nenhum → violação (nunca "sem ledger, logo tudo bem")');
911
+
912
+ tools.recordProvenance(runDir, 'assets', path.join(runDir, 'assets', 'intrusa.png'), { source: 'openrouter_image' });
913
+ assert(tools.checkRunProvenance({ runDir }).ok === true, 'a mesma imagem, registrada, passa');
914
+
915
+ fs.copyFileSync(OTHER_PNG, path.join(runDir, 'assets', 'intrusa.png')); // bytes trocados depois
916
+ const div = tools.checkRunProvenance({ runDir });
917
+ assert(div.ok === false && /DIVERGE/.test(div.violations.join(' ')), 'bytes trocados depois do registro → DIVERGE');
918
+
919
+ const rd2 = tmpDir('prov-unit2');
920
+ fs.writeFileSync(tools.provenancePathFor(rd2), '{ quebrado', 'utf8');
921
+ const ileg = tools.checkRunProvenance({ runDir: rd2 });
922
+ assert(ileg.ok === false && /ILEGÍVEL/.test(ileg.violations.join(' ')),
923
+ 'ledger que EXISTE e está quebrado → violação mesmo com os diretórios vazios (nunca decidir por cima dele)');
924
+ fs.writeFileSync(tools.provenancePathFor(rd2), JSON.stringify({ version: 99 }), 'utf8');
925
+ assert(tools.checkRunProvenance({ runDir: rd2 }).ok === false, 'ledger de versão desconhecida → violação');
926
+
927
+ const rd3 = tmpDir('prov-unit3');
928
+ const vac = tools.checkRunProvenance({ runDir: rd3, requireNonVacuous: true, stepId: 'visuals' });
929
+ assert(vac.ok === false && /NENHUMA referência/.test(vac.violations.join(' ')) && /visuals/.test(vac.violations.join(' ')),
930
+ 'NÃO-VACUIDADE: fase que declara o veto sem referência materializada → violação nomeando a fase');
931
+ assert(tools.checkRunProvenance({ runDir: rd3 }).ok === true,
932
+ 'e a MESMA situação, numa fase que NÃO declara o veto, passa (a não-vacuidade é por-fase, não global)');
933
+
934
+ let lancou = null;
935
+ try { tools.assertRunProvenance({ runDir: rd3, requireNonVacuous: true, stepId: 'visuals' }); } catch (e) { lancou = e; }
936
+ assert(lancou && /PROVENIÊNCIA VIOLADA/.test(lancou.message) && /HALTADO/.test(lancou.message),
937
+ 'AC4: assertRunProvenance LANÇA (o HALT) com a violação nomeada');
938
+ assert(lancou && /openrouter_image/.test(lancou.message) && /inputs\/" é PROIBIDO|inputs\/. é PROIBIDO/.test(lancou.message),
939
+ 'AC4: a mensagem diz O QUE FAZER (gravar em assets/ com openrouter_image; escrever em inputs/ é proibido)');
940
+ }
941
+
942
+ async function provenanceHaltRunTest() {
943
+ console.log('\n--- SF10.19 (AC3/AC4): `run` — asset fora do ledger (camada 1 CONTORNADA) HALTA o run ---');
944
+ let runDirRef = null;
945
+ const tamper = (runDir) => { fs.copyFileSync(OTHER_PNG, path.join(runDir, 'assets', 'intrusa.png')); };
946
+ const h = await startPhashRun('halt-run', modeloVisuals({ getRunDir: () => runDirRef, tamper }));
947
+ runDirRef = h.ctx.runDir;
948
+ const { erro, resultado } = await driveCapturandoHalt(h.ctx, h.run);
949
+
950
+ assert(Boolean(erro), `AC3: o run foi INTERROMPIDO (não devolveu resultado: ${JSON.stringify(resultado)})`);
951
+ assert(erro && /PROVENIÊNCIA VIOLADA/.test(erro.message) && /visuals/.test(erro.message),
952
+ 'AC3: o HALT nomeia a invariante e a fase');
953
+ assert(erro && /intrusa\.png/.test(erro.message) && /NÃO consta do registro/.test(erro.message),
954
+ `AC4: a mensagem nomeia O ARQUIVO e o que foi violado (${(erro && erro.message || '').slice(0, 120)}…)`);
955
+ assert(erro && /não altere\s+arquivos do run por fora das tools|openrouter_image/.test(erro.message),
956
+ 'AC4: a mensagem é acionável (o que fazer), nunca erro genérico');
957
+ assert(fs.existsSync(path.join(h.ctx.runDir, 'assets', 'intrusa.png')),
958
+ 'sanidade: a imagem intrusa REALMENTE entrou por fora das tools (camada 1 contornada com `fs`)');
959
+ assert(tentativasDe(h.logs, 'visuals') === 1,
960
+ `AC3/e3: HALT NÃO consome MAX_RETRY — a fase rodou 1 vez, não 3 (${tentativasDe(h.logs, 'visuals')})`);
961
+ assert(!h.logs.some((l) => /VETO DISPAROU/.test(l)),
962
+ 'AC3/e3: a violação NÃO foi tratada como reprovação de veto (nada de correção guiada sobre adulteração)');
963
+ const st = motor.readState(h.ctx.statePath);
964
+ assert(st.steps.visuals.status === 'running',
965
+ `AC3: a fase NÃO avançou (status=${st.steps.visuals.status}) — o HALT acontece ANTES de motor.advance`);
966
+ }
967
+
968
+ async function provenanceHaltVariantsTest() {
969
+ console.log('\n--- SF10.19 (AC3): as demais classes de violação, cada uma HALTANDO ---');
970
+ // (a) referência em inputs/ trocada por fora das tools → DIVERGE.
971
+ {
972
+ let rd = null;
973
+ const tamper = (runDir) => { fs.copyFileSync(OTHER_PNG, path.join(runDir, 'inputs', 'ref-pinterest.png')); };
974
+ const h = await startPhashRun('halt-div', modeloVisuals({ getRunDir: () => rd, tamper }));
975
+ rd = h.ctx.runDir;
976
+ const { erro } = await driveCapturandoHalt(h.ctx, h.run);
977
+ assert(erro && /DIVERGE do sha256 registrado/.test(erro.message) && /ref-pinterest\.png/.test(erro.message),
978
+ 'AC3: referência SUBSTITUÍDA depois de materializada → HALT por divergência de sha256 (camada 2 sozinha)');
979
+ }
980
+ // (b) entrada registrada apagada do disco → AUSENTE.
981
+ {
982
+ let rd = null;
983
+ const tamper = (runDir) => { fs.rmSync(path.join(runDir, 'assets', 'cover.png')); };
984
+ const h = await startPhashRun('halt-sumiu', modeloVisuals({ getRunDir: () => rd, tamper }));
985
+ rd = h.ctx.runDir;
986
+ const { erro } = await driveCapturandoHalt(h.ctx, h.run);
987
+ assert(erro && /AUSENTE em disco/.test(erro.message) && /cover\.png/.test(erro.message),
988
+ 'AC3: entrada registrada que SUMIU do disco → HALT (o ledger não pode afirmar o que não existe)');
989
+ }
990
+ // (c) NÃO-VACUIDADE: a fase declara o veto e não produziu imagem nenhuma.
991
+ {
992
+ const h = await startPhashRun('halt-vacuo', modeloVisuals({ getRunDir: () => null, gerarImagem: false }));
993
+ const { erro } = await driveCapturandoHalt(h.ctx, h.run);
994
+ assert(erro && /NENHUMA imagem registrada/.test(erro.message) && /vacuidade/.test(erro.message),
995
+ 'AC3: fase que declara o veto sem imagem produzida → HALT (a não-vacuidade que o exit 3 dava de graça não sumiu)');
996
+ assert(tentativasDe(h.logs, 'visuals') === 1, 'AC3/e3: também aqui o HALT não consome tentativa');
997
+ }
998
+ }
999
+
1000
+ async function provenanceHaltGuidedTest() {
1001
+ console.log('\n--- SF10.19 (AC3): TENTATIVA GUIADA (SF10.12) também atravessa a asserção ---');
1002
+ let rd = null;
1003
+ const tamper = (runDir) => { fs.copyFileSync(OTHER_PNG, path.join(runDir, 'assets', 'intrusa.png')); };
1004
+ // 1ª tentativa: artefato REPROVADO pelo veto (referencia_copiada: sim), SEM adulteração.
1005
+ // Tentativa GUIADA: escreve o artefato bom E adultera — a violação nasce dentro do caminho da SF10.12.
1006
+ const h = await startPhashRun('halt-guiada', modeloVisuals({
1007
+ getRunDir: () => rd,
1008
+ tamper,
1009
+ tamperNaGuiada: true,
1010
+ artefato: ({ guiada }) => (guiada ? visualsArtifact() : visualsReprovado()),
1011
+ }));
1012
+ rd = h.ctx.runDir;
1013
+ const { erro } = await driveCapturandoHalt(h.ctx, h.run);
1014
+ assert(h.logs.some((l) => /\[visuals\] VETO DISPAROU/.test(l)),
1015
+ 'cenário: a 1ª tentativa foi reprovada pelo veto de conteúdo (é o que produz a tentativa guiada)');
1016
+ assert(h.logs.some((l) => /CORREÇÃO GUIADA/.test(l)), 'cenário: a 2ª tentativa É a guiada (conversa retomada, SF10.12)');
1017
+ assert(erro && /PROVENIÊNCIA VIOLADA/.test(erro.message) && /intrusa\.png/.test(erro.message),
1018
+ 'AC3: a adulteração feita NA TENTATIVA GUIADA é pega — o caminho da SF10.12 não escapa da asserção');
1019
+ assert(tentativasDe(h.logs, 'visuals') === 2,
1020
+ `AC3/e3: o run parou na 2ª tentativa; a 3ª (que MAX_RETRY ainda permitia) NÃO rodou (${tentativasDe(h.logs, 'visuals')})`);
1021
+ }
1022
+
1023
+ async function provenanceHaltResumeTest() {
1024
+ console.log('\n--- SF10.19 (AC3): `resume` — a asserção vale no caminho de retomada ---');
1025
+ // Fase 1: o modelo é teimoso e esgota MAX_RETRY (sem adulterar) → run BLOQUEADO, sem HALT.
1026
+ const h = await startPhashRun('halt-resume', modeloVisuals({ getRunDir: () => null, artefato: () => visualsReprovado() }));
1027
+ const r1 = await driveAlemDoGateDeTexto(h.ctx, h.run);
1028
+ assert(r1.blocked === 'visuals',
1029
+ `cenário: sem violação de proveniência, o veto de conteúdo se comporta como sempre (blocked=${JSON.stringify(r1.blocked)})`);
1030
+ assert(tentativasDe(h.logs, 'visuals') === 3, 'cenário: as 3 tentativas do MAX_RETRY foram gastas (contraste com o HALT)');
1031
+ assert(!h.logs.some((l) => /phash-gate/.test(l) && /bloqueou/i.test(l)),
1032
+ 'AC5 no caminho REAL do export: o blessed phash-gate rodou pelo motor e NÃO bloqueou nada');
1033
+
1034
+ // Fase 2: RESUME (openRun + drive novo, conversa não sobrevive) com adulteração.
1035
+ const statePath = motor.findStateByRunId(h.runsRoot, h.runId);
1036
+ const logs2 = [];
1037
+ let rd = null;
1038
+ const tamper = (runDir) => { fs.copyFileSync(OTHER_PNG, path.join(runDir, 'assets', 'intrusa.png')); };
1039
+ const run2 = exec.setupRun({
1040
+ bundleDir: h.bundleDir, lockOverride: h.lockPath, payload: h.payload,
1041
+ deps: { callModel: modeloVisuals({ getRunDir: () => rd, tamper }), imageTransport: h.imageTransport },
1042
+ log: (m) => logs2.push(m),
1043
+ });
1044
+ const mem2 = fs.mkdtempSync(path.join(os.tmpdir(), 'pex-mem-'));
1045
+ const ctx2 = motor.openRun({ statePath, workflowPath: path.join(h.bundleDir, 'workflow.yaml'), podRootOverride: h.bundleDir, memoryPodDir: mem2 });
1046
+ rd = ctx2.runDir;
1047
+ exec.materializeInputs({ bundleRoot: run2.bundleRoot, inputsDir: run2.inputsDir, runDir: ctx2.runDir, log: run2.log });
1048
+ const { erro } = await driveCapturandoHalt(ctx2, run2);
1049
+ assert(erro && /PROVENIÊNCIA VIOLADA/.test(erro.message) && /intrusa\.png/.test(erro.message),
1050
+ 'AC3: no `resume` a asserção também roda — nenhum comando entrega uma fase sem asserir');
1051
+ assert(tentativasDe(logs2, 'visuals') === 1, 'AC3/e3: HALT no resume também não consome MAX_RETRY');
1052
+ }
1053
+
1054
+ async function provenanceHappyPathTest() {
1055
+ console.log('\n--- SF10.19 (não-regressão): com o veto declarado e tudo íntegro, o run ANDA ---');
1056
+ const h = await startPhashRun('feliz', modeloVisuals({ getRunDir: () => null }));
1057
+ const r = await driveAlemDoGateDeTexto(h.ctx, h.run);
1058
+ assert(r.awaiting === 'assembly', `o run atravessa visuals e chega ao checkpoint (awaiting=${r.awaiting})`);
1059
+ assert(tentativasDe(h.logs, 'visuals') === 1, 'nenhum falso-positivo: visuals passou na 1ª tentativa');
1060
+ const art = fs.readFileSync(path.join(h.ctx.runDir, 'artifacts', 'visuals.md'), 'utf8');
1061
+ assert(/pc:medida-similaridade:inicio/.test(art) && /NAO verifica PROVENIENCIA/.test(art),
1062
+ 'o bloco de MEDIDA do blessed está no artefato e declara o que ele não verifica (AC10, também no export)');
1063
+ const led = JSON.parse(fs.readFileSync(tools.provenancePathFor(h.ctx.runDir), 'utf8'));
1064
+ assert(Object.keys(led.inputs).length === 1 && Object.keys(led.assets).length === 1,
1065
+ 'o ledger tem a referência materializada e a imagem gerada (as duas pontas registradas por código)');
1066
+ assert(tools.checkRunProvenance({ runDir: h.ctx.runDir, requireNonVacuous: true, stepId: 'visuals' }).ok === true,
1067
+ 'e a checagem canônica confirma o run íntegro (inclusive não-vacuidade)');
1068
+ }
1069
+
1070
+ // ===========================================================================
1071
+ // SF10.19 (AC3/risco 1 da story) — ESTÁTICO: nenhum caminho do executor avança fase sem asserir,
1072
+ // e nada escreve entre a asserção e `motor.advance`. É a defesa contra a hipótese do QA de que a
1073
+ // asserção fosse contornável por ORDEM DE OPERAÇÕES.
1074
+ // ===========================================================================
1075
+
1076
+ function provenanceWiringStaticTest() {
1077
+ console.log('\n--- SF10.19 (AC3): a asserção está no ÚNICO funil, e nada escreve depois dela ---');
1078
+ const src = fs.readFileSync(path.join(__dirname, 'pod-export-executor.cjs'), 'utf8');
1079
+
1080
+ const chamadas = [...src.matchAll(/tools\.assertRunProvenance\(/g)].length;
1081
+ assert(chamadas === 1, `a asserção é chamada UMA vez, num funil só (${chamadas}) — nada de cópia por comando`);
1082
+
1083
+ // Ela vive em `execPhase`, que TODA tentativa de TODA fase de TODO comando atravessa.
1084
+ const execStart = src.indexOf('async function execPhase(');
1085
+ const execEnd = src.indexOf('function currentState(');
1086
+ const execBody = src.slice(execStart, execEnd);
1087
+ assert(execBody.includes('tools.assertRunProvenance('), 'a asserção vive em `execPhase` (o funil de execução de fase)');
1088
+ assert(!/try\s*{[^}]*assertRunProvenance/s.test(execBody) && !/assertRunProvenance[\s\S]{0,200}catch/.test(execBody),
1089
+ 'a asserção NÃO está dentro de try/catch — lançar é o HALT (fail-loud, sem captura silenciosa)');
1090
+
1091
+ // Entre a asserção (fim de execPhase) e `motor.advance` (em drive) não há escrita nem tool.
1092
+ const depois = execBody.slice(execBody.indexOf('tools.assertRunProvenance('));
1093
+ const driveStart = src.indexOf('async function drive(');
1094
+ const driveBody = src.slice(driveStart, src.indexOf('\n// ---', driveStart));
1095
+ const entre = driveBody.slice(driveBody.indexOf('await execPhase('), driveBody.indexOf('motor.advance(ctx)'));
1096
+ const escritas = /fs\.(write|copy|append|mk|rm|rename)|writeFile|openrouterImage|postProcessAttempt|execTool/;
1097
+ assert(!escritas.test(depois.slice(depois.indexOf(');'))) && !escritas.test(entre),
1098
+ 'ORDEM: nenhuma escrita/tool entre a asserção e `motor.advance` — não há janela para gravar depois de asserir');
1099
+
1100
+ // `drive` é o único a chamar `execPhase`, e chama `motor.advance` logo depois.
1101
+ const advanceAntesDeExec = driveBody.indexOf('motor.advance(ctx)') > driveBody.indexOf('await execPhase(');
1102
+ assert(advanceAntesDeExec, '`motor.advance` vem DEPOIS de `execPhase` (a verificação do motor segue sendo a última linha)');
1103
+
1104
+ // O HALT não virou um 4º limite (ADR-018 Adendo F2 / DF-SF10.12-2): nenhuma constante nova.
1105
+ assert(!/HALT|halted?Count|provCount|MAX_PROV/.test(src.replace(/\/\*[\s\S]*?\*\/|\/\/.*$/gm, '')),
1106
+ 'AC/DF-SF10.12-2: nenhum contador, constante ou estado de "halt" nasceu no código (só a exceção)');
1107
+ }
1108
+
1109
+ // ---------------------------------------------------------------------------
1110
+ // SF11.10/ADR-024 `DF-SF11.10-7` — o RUNTIME DE EXPORT herda o gate de texto, com ZERO
1111
+ // linha alterada em pod-export-executor.cjs / pod-export-compile.cjs.
1112
+ //
1113
+ // Por que este teste existe mesmo sem diff no executor: um comportamento que ninguém
1114
+ // escreveu é exatamente o que ninguém testa — e aqui ele MUDA (o serviço passa a parar
1115
+ // duas vezes por rodada). A cadeia inteira é de terceiros: `startRun` é chamado SEM
1116
+ // `mode` (executor:618) ⇒ default `interactive` ⇒ o gate vale; o `wf` viaja byte a byte
1117
+ // no bundle (compile:340) ⇒ o campo novo chega selado no sha.
1118
+ // ---------------------------------------------------------------------------
1119
+
1120
+ async function exportHerdaGateDeTextoTest() {
1121
+ console.log('\n--- SF11.10/DF-SF11.10-7: export herda o gate de texto (zero linha no executor) ---');
1122
+
1123
+ // SF12.2/ADR-028: até aqui este teste PATCHAVA `checkpoint: null` do wf do template para
1124
+ // fabricar o gate — porque o template ainda não o tinha. A SF12.2 propagou o gate de verdade
1125
+ // (AC2), então o patch foi REMOVIDO: manter um `.replace('checkpoint: null', ...)` agora
1126
+ // acertaria a etapa `visuals` em silêncio e o teste passaria medindo a etapa errada.
1127
+ const podDir = tmpDir('pod-gate');
1128
+ fs.cpSync(FIXTURE_POD, podDir, { recursive: true });
1129
+ const wfPath = path.join(podDir, 'workflows', 'wf-content-carousel.yaml');
1130
+ const wfSrc = fs.readFileSync(wfPath, 'utf8');
1131
+ const wfDoc = validator.parseYaml(wfSrc);
1132
+ const digestStep = wfDoc.steps.find((s) => s.id === 'digest');
1133
+ assert(digestStep.checkpoint && digestStep.checkpoint.blocking === true
1134
+ && Array.isArray(digestStep.checkpoint.skip_modes) && digestStep.checkpoint.skip_modes.includes('yolo'),
1135
+ 'pré-condição (SF12.2 AC2): o wf do pod-template JÁ traz o gate de texto em `digest` com skip_modes: ["yolo"] — sem patch de fixture');
1136
+
1137
+ const bundleDir = tmpDir('bundle-gate');
1138
+ const lockPath = path.join(tmpDir('lockout-gate'), 'export.lock.json');
1139
+ const { bundleFiles, lock } = await compiler.compile(podDir, { checkEndpoint: false });
1140
+ compiler.writeBundle(bundleDir, bundleFiles);
1141
+ fs.writeFileSync(lockPath, compiler.serializeLock(lock));
1142
+
1143
+ // O compilador copia o wf BYTE A BYTE — o campo novo viaja selado, sem mudança no compilador.
1144
+ const bundleWf = fs.readFileSync(path.join(bundleDir, 'workflow.yaml'), 'utf8');
1145
+ assert(bundleWf === wfSrc, 'DF-SF11.10-7: o wf com `skip_modes` viaja BYTE A BYTE para o bundle (zero linha no compilador)');
1146
+
1147
+ const png1x1 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
1148
+ const imagensGeradas = [];
1149
+ const turnByPhase = {};
1150
+ const callModel = async ({ messages }) => {
1151
+ const first = messages[0].content;
1152
+ const m = /fase (\w+)/.exec(typeof first === 'string' ? first : JSON.stringify(first));
1153
+ const phase = m ? m[1] : 'unknown';
1154
+ turnByPhase[phase] = (turnByPhase[phase] || 0) + 1;
1155
+ const t = turnByPhase[phase];
1156
+ const tuId = `${phase}-${t}`;
1157
+ const usage = { input_tokens: 10, output_tokens: 5 };
1158
+ if (phase === 'digest') {
1159
+ if (t === 1) return { stop_reason: 'tool_use', usage, content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: 'artifacts/digest.md', content: digestArtifact() } }] };
1160
+ return { stop_reason: 'end_turn', usage, content: [{ type: 'text', text: 'digest pronto' }] };
1161
+ }
1162
+ if (phase === 'visuals') {
1163
+ if (t === 1) return { stop_reason: 'tool_use', usage, content: [{ type: 'tool_use', id: tuId, name: 'openrouter_image', input: { prompt: 'capa', out_path: 'assets/cover.png' } }] };
1164
+ if (t === 2) return { stop_reason: 'tool_use', usage, content: [{ type: 'tool_use', id: tuId, name: 'read_image', input: { ref: 'assets/cover.png' } }] };
1165
+ if (t === 3) return { stop_reason: 'tool_use', usage, content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: 'artifacts/visuals.md', content: visualsArtifact() } }] };
1166
+ return { stop_reason: 'end_turn', usage, content: [{ type: 'text', text: 'visuals pronto' }] };
1167
+ }
1168
+ if (phase === 'assembly') {
1169
+ // ADR-028: RAIZ do runDir, nomes do pod (o `render-png` da etapa procura ali).
1170
+ if (t <= 7) return { stop_reason: 'max_tokens', usage, content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: piecePath(t), content: pecaHtml(t) } }] };
1171
+ if (t === 8) return { stop_reason: 'tool_use', usage, content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: 'artifacts/assembly.md', content: assemblyArtifact() } }] };
1172
+ return { stop_reason: 'end_turn', usage, content: [{ type: 'text', text: 'assembly pronto' }] };
1173
+ }
1174
+ return { stop_reason: 'end_turn', usage, content: [{ type: 'text', text: 'noop' }] };
1175
+ };
1176
+ const imageTransport = async (payload) => { imagensGeradas.push(payload.model); return { choices: [{ message: { images: [{ image_url: { url: png1x1 } }] } }] }; };
1177
+
1178
+ const runsRoot = tmpDir('runs-gate');
1179
+ const payload = { pod: 'content-carousel', workflow: 'wf-content-carousel', fetch_allow_hosts: [], article_url: 'https://example.com/a' };
1180
+ const run = exec.setupRun({ bundleDir, lockOverride: lockPath, payload, deps: { callModel, imageTransport }, log: () => {} });
1181
+ const memoryPodDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pex-mem-'));
1182
+
1183
+ // EXATAMENTE como o executor chama (pod-export-executor.cjs:618): SEM `mode`.
1184
+ const { ctx } = motor.startRun({
1185
+ workflowPath: path.join(bundleDir, 'workflow.yaml'), podRootOverride: bundleDir,
1186
+ pod: run.pod, workflow: run.workflow, runsRoot, runId: 'gate-1', memoryPodDir,
1187
+ });
1188
+ assert(ctx.state.mode === 'interactive', 'DF-SF11.10-7: startRun sem `mode` no executor ⇒ default `interactive` ⇒ o gate vale no export');
1189
+
1190
+ const r1 = await exec.drive(ctx, run, {});
1191
+ assert(r1.awaiting === 'digest', `AC15: a run de EXPORT para no gate de TEXTO (awaiting=${r1.awaiting})`);
1192
+ assert(imagensGeradas.length === 0, `AC15: NENHUMA imagem foi gerada antes do gate (imagens=${imagensGeradas.length}) — é o custo que a story existe para evitar`);
1193
+ const disk1 = motor.readState(ctx.statePath);
1194
+ assert(disk1.steps.visuals.status === 'pending' && disk1.steps.visuals.started_at === null,
1195
+ 'AC15: a fase cara segue pending/started_at null no instante da parada do export');
1196
+
1197
+ // Retomada pelo caminho REAL do serviço: `approve` (mesma motor.approve do CLI) + `resume`.
1198
+ const statePath = motor.findStateByRunId(runsRoot, 'gate-1');
1199
+ const ctx2 = motor.openRun({ statePath, workflowPath: path.join(bundleDir, 'workflow.yaml'), podRootOverride: bundleDir, memoryPodDir });
1200
+ const ap = motor.approve(ctx2);
1201
+ assert(ap.action === 'approved' && ap.step === 'digest', `AC15: approve do export destrava o gate de texto (${ap.action}/${ap.step})`);
1202
+ const r2 = await exec.drive(ctx2, run, {});
1203
+ assert(r2.awaiting === 'assembly', `AC15: o resume atravessa a fase cara e chega ao gate de FECHAMENTO (awaiting=${r2.awaiting})`);
1204
+ assert(imagensGeradas.length >= 1, 'AC15: a imagem só foi gerada DEPOIS da aprovação do texto');
1205
+
1206
+ // Zero linha no executor/compilador: nenhum dos dois conhece o campo novo.
1207
+ const execSrc = fs.readFileSync(path.join(REPO_ROOT, 'scripts', 'pod-export-executor.cjs'), 'utf8');
1208
+ const compSrc = fs.readFileSync(path.join(REPO_ROOT, 'scripts', 'pod-export-compile.cjs'), 'utf8');
1209
+ assert(!execSrc.includes('skip_modes') && !compSrc.includes('skip_modes'),
1210
+ 'DF-SF11.10-7: nem o executor nem o compilador citam `skip_modes` — o comportamento novo é herdado, não codificado');
1211
+ }
1212
+
1213
+ // ---------------------------------------------------------------------------
1214
+ // SF12.2/ADR-028 `DF-SF12.2-2` — CONTROLE NEGATIVO DO LUGAR DAS PEÇAS.
1215
+ //
1216
+ // Este é o caso `pieces/` que este fixture modelava até a SF12.2. Ele NÃO foi apagado quando o
1217
+ // mock passou a obedecer a spec: foi INVERTIDO. Um teste que fica verde removendo o caso difícil
1218
+ // é pior que o teste vermelho — o harness tem que ganhar oráculo, nunca ponto cego (mesma régua
1219
+ // do `aux_files` no smoke, ADR-025 DF-SF11.11-6).
1220
+ //
1221
+ // O que se prova aqui, e que NENHUM outro teste prova: um agente que escreve as 7 peças fora do
1222
+ // lugar declarado pelo pod NÃO fecha a etapa — `assembly` fica `blocked` com a causa nomeada pelo
1223
+ // `render-png`, e `awaiting` NUNCA chega ao checkpoint do dono. E que o oráculo tem dentes: com a
1224
+ // assertion `render-png` REMOVIDA do wf, o mesmo cenário desobediente passa direto ao checkpoint.
1225
+ // ---------------------------------------------------------------------------
1226
+
1227
+ async function lugarDasPecasTest() {
1228
+ console.log('\n--- SF12.2/ADR-028: controle negativo do LUGAR das peças (o caso `pieces/`, invertido) ---');
1229
+
1230
+ const png1x1 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
1231
+
1232
+ // Roda o pod (bundle real, motor real, executor real) com o mock escrevendo as 7 peças em
1233
+ // `subdir` — `null` = raiz. `semRenderPng` compila uma variante do pod SEM a assertion.
1234
+ async function rodar({ subdir, semRenderPng, runId }) {
1235
+ const podDir = tmpDir(`pod-lugar-${runId}`);
1236
+ fs.cpSync(FIXTURE_POD, podDir, { recursive: true });
1237
+ const wfPath = path.join(podDir, 'workflows', 'wf-content-carousel.yaml');
1238
+ if (semRenderPng) {
1239
+ const linhas = fs.readFileSync(wfPath, 'utf8').split('\n');
1240
+ const restantes = linhas.filter((l) => !l.includes('command: "render-png.cjs"'));
1241
+ assert(restantes.length === linhas.length - 1, `pré-condição: removeu EXATAMENTE a linha da assertion render-png (${linhas.length - restantes.length})`);
1242
+ fs.writeFileSync(wfPath, restantes.join('\n'), 'utf8');
1243
+ }
1244
+ const bundleDir = tmpDir(`bundle-lugar-${runId}`);
1245
+ const lockPath = path.join(tmpDir(`lockout-lugar-${runId}`), 'export.lock.json');
1246
+ const { bundleFiles, lock } = await compiler.compile(podDir, { checkEndpoint: false });
1247
+ compiler.writeBundle(bundleDir, bundleFiles);
1248
+ fs.writeFileSync(lockPath, compiler.serializeLock(lock));
1249
+
1250
+ const turnByPhase = {};
1251
+ const callModel = async ({ messages }) => {
1252
+ const first = messages[0].content;
1253
+ const m = /fase (\w+)/.exec(typeof first === 'string' ? first : JSON.stringify(first));
1254
+ const phase = m ? m[1] : 'unknown';
1255
+ turnByPhase[phase] = (turnByPhase[phase] || 0) + 1;
1256
+ const t = turnByPhase[phase];
1257
+ const tuId = `${phase}-${t}`;
1258
+ const usage = { input_tokens: 10, output_tokens: 5 };
1259
+ if (phase === 'digest') {
1260
+ if (t === 1) return { stop_reason: 'tool_use', usage, content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: 'artifacts/digest.md', content: digestArtifact() } }] };
1261
+ return { stop_reason: 'end_turn', usage, content: [{ type: 'text', text: 'ok' }] };
1262
+ }
1263
+ if (phase === 'visuals') {
1264
+ if (t === 1) return { stop_reason: 'tool_use', usage, content: [{ type: 'tool_use', id: tuId, name: 'openrouter_image', input: { prompt: 'capa', out_path: 'assets/cover.png' } }] };
1265
+ if (t === 2) return { stop_reason: 'tool_use', usage, content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: 'artifacts/visuals.md', content: visualsArtifact() } }] };
1266
+ return { stop_reason: 'end_turn', usage, content: [{ type: 'text', text: 'ok' }] };
1267
+ }
1268
+ if (phase === 'assembly') {
1269
+ if (t <= 7) return { stop_reason: 'max_tokens', usage, content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: piecePath(t, subdir), content: pecaHtml(t) } }] };
1270
+ if (t === 8) return { stop_reason: 'tool_use', usage, content: [{ type: 'tool_use', id: tuId, name: 'write_file', input: { path: 'artifacts/assembly.md', content: assemblyArtifact() } }] };
1271
+ return { stop_reason: 'end_turn', usage, content: [{ type: 'text', text: 'ok' }] };
1272
+ }
1273
+ return { stop_reason: 'end_turn', usage, content: [{ type: 'text', text: 'noop' }] };
1274
+ };
1275
+ const imageTransport = async () => ({ choices: [{ message: { images: [{ image_url: { url: png1x1 } }] } }] });
1276
+
1277
+ const runsRoot = tmpDir(`runs-lugar-${runId}`);
1278
+ const payload = { pod: 'content-carousel', workflow: 'wf-content-carousel', fetch_allow_hosts: [], article_url: 'https://example.com/a' };
1279
+ const logs = [];
1280
+ const run = exec.setupRun({ bundleDir, lockOverride: lockPath, payload, deps: { callModel, imageTransport }, log: (m) => logs.push(m) });
1281
+ const memoryPodDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pex-mem-'));
1282
+ const { ctx } = motor.startRun({ workflowPath: path.join(bundleDir, 'workflow.yaml'), podRootOverride: bundleDir, pod: run.pod, workflow: run.workflow, runsRoot, runId, memoryPodDir });
1283
+ await exec.drive(ctx, run, {}); // 1ª parada: gate de texto em `digest`
1284
+ const ctx2 = motor.openRun({ statePath: motor.findStateByRunId(runsRoot, runId), workflowPath: path.join(bundleDir, 'workflow.yaml'), podRootOverride: bundleDir, memoryPodDir });
1285
+ motor.approve(ctx2);
1286
+ const r = await exec.drive(ctx2, run, {});
1287
+ return { r, ctx, logs, disk: motor.readState(ctx.statePath) };
1288
+ }
1289
+
1290
+ // (1) DESOBEDIENTE, com o veto no lugar: BLOQUEIA, e o dono nunca é convocado.
1291
+ const mau = await rodar({ subdir: 'pieces', semRenderPng: false, runId: 'lugar-mau' });
1292
+ assert(mau.r.awaiting == null,
1293
+ `ADR-028 AC27: peças em subdiretório ⇒ o checkpoint do dono NUNCA é alcançado (awaiting=${mau.r.awaiting})`);
1294
+ assert(mau.disk.steps.assembly.status === 'running' && mau.disk.steps.assembly.checkpoint !== 'approved',
1295
+ `ADR-028 AC27: a fase assembly NÃO fecha (status=${mau.disk.steps.assembly.status})`);
1296
+ assert(mau.r.blocked === 'assembly', `ADR-028 AC27: o drive devolve BLOQUEIO na fase assembly (blocked=${mau.r.blocked})`);
1297
+ // A causa é lida da LINHA do veto (não do log inteiro): oráculo frouxo passaria por ruído.
1298
+ const linhaVeto = mau.logs.find((l) => /\[assembly\] VETO DISPAROU/.test(l)) || '';
1299
+ assert(/render-png/.test(linhaVeto),
1300
+ `ADR-028 AC27: a linha do veto NOMEIA o render-png (linha: ${linhaVeto.slice(0, 200) || '(nenhuma linha VETO DISPAROU)'})`);
1301
+ assert(/\b7\b/.test(linhaVeto),
1302
+ 'ADR-028 AC27: a linha do veto cita a contagem esperada (--expect 7) — mensagem acionável, não genérica');
1303
+ assert(fs.existsSync(path.join(mau.ctx.runDir, 'pieces', PIECE_NAMES[0])),
1304
+ 'ADR-028 AC27: as peças EXISTEM (o agente escreveu 7) — o que reprova é o LUGAR, não a ausência de trabalho');
1305
+
1306
+ // (2) MESMO cenário desobediente, com a assertion REMOVIDA do wf: passa direto ao checkpoint.
1307
+ // É a prova de que o controle (1) tem dentes — ele mede o veto, não um acaso do harness.
1308
+ const semVeto = await rodar({ subdir: 'pieces', semRenderPng: true, runId: 'lugar-sem-veto' });
1309
+ assert(semVeto.r.awaiting === 'assembly',
1310
+ `ADR-028 AC27: sem a assertion render-png no wf, o MESMO cenário desobediente chega ao checkpoint (awaiting=${semVeto.r.awaiting}) — logo o bloqueio de (1) é do veto`);
1311
+ }
1312
+
1313
+ // ---------------------------------------------------------------------------
1314
+
1315
+ async function main() {
1316
+ console.log('POWER CLAUDE — suíte hermética do pod-export-executor (SF10.2)\n');
1317
+ reuseTests();
1318
+ coherenceTests();
1319
+ await toolsSecurityTests();
1320
+ await loopTests();
1321
+ await endToEndTest();
1322
+ await fixerOrderingTest();
1323
+ noNewLimitsTest();
1324
+ appendUserTurnTest();
1325
+ await guidedCorrectionTest();
1326
+ await resumeFallbackTest();
1327
+ provenanceUnitTests();
1328
+ provenanceWiringStaticTest();
1329
+ await provenanceHappyPathTest();
1330
+ await provenanceHaltRunTest();
1331
+ await provenanceHaltVariantsTest();
1332
+ await provenanceHaltGuidedTest();
1333
+ await provenanceHaltResumeTest();
1334
+ await exportHerdaGateDeTextoTest();
1335
+ await lugarDasPecasTest();
1336
+ if (failures > 0) { console.error(`\nSuíte FALHOU: ${failures} violação(ões).`); process.exit(1); }
1337
+ console.log('\nSuíte verde.');
1338
+ process.exit(0);
1339
+ }
1340
+
1341
+ main().catch((e) => { console.error('[ERRO NA SUÍTE]', e.stack || e.message); process.exit(1); });