devflow-engine 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (393) hide show
  1. devflow_engine/__init__.py +3 -0
  2. devflow_engine/agentic_prompts.py +100 -0
  3. devflow_engine/agentic_runtime.py +398 -0
  4. devflow_engine/api_key_flow_harness.py +539 -0
  5. devflow_engine/api_keys.py +357 -0
  6. devflow_engine/bootstrap/__init__.py +2 -0
  7. devflow_engine/bootstrap/provision_from_template.py +84 -0
  8. devflow_engine/cli/__init__.py +0 -0
  9. devflow_engine/cli/app.py +7270 -0
  10. devflow_engine/core/__init__.py +0 -0
  11. devflow_engine/core/config.py +86 -0
  12. devflow_engine/core/logging.py +29 -0
  13. devflow_engine/core/paths.py +45 -0
  14. devflow_engine/core/toml_kv.py +33 -0
  15. devflow_engine/devflow_event_worker.py +1292 -0
  16. devflow_engine/devflow_state.py +201 -0
  17. devflow_engine/devin2/__init__.py +9 -0
  18. devflow_engine/devin2/agent_definition.py +120 -0
  19. devflow_engine/devin2/pi_runner.py +204 -0
  20. devflow_engine/devin_orchestration.py +69 -0
  21. devflow_engine/docs/prompts/anti-patterns.md +42 -0
  22. devflow_engine/docs/prompts/devin-agent-prompt.md +55 -0
  23. devflow_engine/docs/prompts/devin2-agent-prompt.md +81 -0
  24. devflow_engine/docs/prompts/examples/devin-vapi-clone-reference-exchange.json +85 -0
  25. devflow_engine/doctor/__init__.py +2 -0
  26. devflow_engine/doctor/triage.py +140 -0
  27. devflow_engine/error/__init__.py +0 -0
  28. devflow_engine/error/remediation.py +21 -0
  29. devflow_engine/errors/error_solver_dag.py +522 -0
  30. devflow_engine/errors/runtime_observability.py +67 -0
  31. devflow_engine/idea/__init__.py +4 -0
  32. devflow_engine/idea/actors.py +481 -0
  33. devflow_engine/idea/agentic.py +465 -0
  34. devflow_engine/idea/analyze.py +93 -0
  35. devflow_engine/idea/devin_chat_dag.py +1 -0
  36. devflow_engine/idea/diff.py +99 -0
  37. devflow_engine/idea/drafts.py +446 -0
  38. devflow_engine/idea/idea_creation_dag.py +643 -0
  39. devflow_engine/idea/ideation_enrichment.py +355 -0
  40. devflow_engine/idea/ideation_enrichment_worker.py +19 -0
  41. devflow_engine/idea/paths.py +28 -0
  42. devflow_engine/idea/promote.py +53 -0
  43. devflow_engine/idea/redaction.py +27 -0
  44. devflow_engine/idea/repo_tools.py +1277 -0
  45. devflow_engine/idea/response_mode.py +30 -0
  46. devflow_engine/idea/story_pipeline.py +1585 -0
  47. devflow_engine/idea/sufficiency.py +376 -0
  48. devflow_engine/idea/traditional_stories.py +1257 -0
  49. devflow_engine/implementation/__init__.py +0 -0
  50. devflow_engine/implementation/alembic_preflight.py +700 -0
  51. devflow_engine/implementation/dag.py +8450 -0
  52. devflow_engine/implementation/green_gate.py +93 -0
  53. devflow_engine/implementation/prompts.py +108 -0
  54. devflow_engine/implementation/test_runtime.py +623 -0
  55. devflow_engine/integration/__init__.py +19 -0
  56. devflow_engine/integration/agentic.py +66 -0
  57. devflow_engine/integration/dag.py +3539 -0
  58. devflow_engine/integration/prompts.py +114 -0
  59. devflow_engine/integration/supabase_schema.sql +31 -0
  60. devflow_engine/integration/supabase_sync.py +177 -0
  61. devflow_engine/llm/__init__.py +1 -0
  62. devflow_engine/llm/cli_one_shot.py +84 -0
  63. devflow_engine/llm/cli_stream.py +371 -0
  64. devflow_engine/llm/execution_context.py +26 -0
  65. devflow_engine/llm/invoke.py +1322 -0
  66. devflow_engine/llm/provider_api.py +304 -0
  67. devflow_engine/llm/repo_knowledge.py +588 -0
  68. devflow_engine/llm_primitives.py +315 -0
  69. devflow_engine/orchestration.py +62 -0
  70. devflow_engine/planning/__init__.py +0 -0
  71. devflow_engine/planning/analyze_repo.py +92 -0
  72. devflow_engine/planning/render_drafts.py +133 -0
  73. devflow_engine/playground/__init__.py +0 -0
  74. devflow_engine/playground/hooks.py +26 -0
  75. devflow_engine/playwright_workflow/__init__.py +5 -0
  76. devflow_engine/playwright_workflow/dag.py +1317 -0
  77. devflow_engine/process/__init__.py +5 -0
  78. devflow_engine/process/dag.py +59 -0
  79. devflow_engine/project_registration/__init__.py +3 -0
  80. devflow_engine/project_registration/dag.py +1581 -0
  81. devflow_engine/project_registry.py +109 -0
  82. devflow_engine/prompts/devin/generic/prompt.md +6 -0
  83. devflow_engine/prompts/devin/ideation/prompt.md +263 -0
  84. devflow_engine/prompts/devin/ideation/scenarios.md +5 -0
  85. devflow_engine/prompts/devin/ideation_loop/prompt.md +6 -0
  86. devflow_engine/prompts/devin/insight/prompt.md +11 -0
  87. devflow_engine/prompts/devin/insight/scenarios.md +5 -0
  88. devflow_engine/prompts/devin/intake/prompt.md +15 -0
  89. devflow_engine/prompts/devin/iterate/prompt.md +12 -0
  90. devflow_engine/prompts/devin/shared/eval_doctrine.md +9 -0
  91. devflow_engine/prompts/devin/shared/principles.md +246 -0
  92. devflow_engine/prompts/devin_eval/assessment/prompt.md +18 -0
  93. devflow_engine/prompts/idea/api_ideation_agent/prompt.md +8 -0
  94. devflow_engine/prompts/idea/api_insight_agent/prompt.md +8 -0
  95. devflow_engine/prompts/idea/response_doctrine/prompt.md +18 -0
  96. devflow_engine/prompts/implementation/dependency_assessment/prompt.md +12 -0
  97. devflow_engine/prompts/implementation/green/green/prompt.md +11 -0
  98. devflow_engine/prompts/implementation/green/node_config/prompt.md +3 -0
  99. devflow_engine/prompts/implementation/green_review/outcome_review/prompt.md +5 -0
  100. devflow_engine/prompts/implementation/green_review/prior_run_review/prompt.md +5 -0
  101. devflow_engine/prompts/implementation/red/prompt.md +27 -0
  102. devflow_engine/prompts/implementation/redreview/prompt.md +23 -0
  103. devflow_engine/prompts/implementation/redreview_repair/prompt.md +16 -0
  104. devflow_engine/prompts/implementation/setupdoc/prompt.md +10 -0
  105. devflow_engine/prompts/implementation/story_planning/prompt.md +13 -0
  106. devflow_engine/prompts/implementation/test_design/prompt.md +27 -0
  107. devflow_engine/prompts/integration/README.md +185 -0
  108. devflow_engine/prompts/integration/green/example.md +67 -0
  109. devflow_engine/prompts/integration/green/green/prompt.md +10 -0
  110. devflow_engine/prompts/integration/green/node_config/prompt.md +42 -0
  111. devflow_engine/prompts/integration/green/past_prompts/20260417T212300/green/prompt.md +15 -0
  112. devflow_engine/prompts/integration/green/past_prompts/20260417T212300/node_config/prompt.md +42 -0
  113. devflow_engine/prompts/integration/green_enrich/example.md +79 -0
  114. devflow_engine/prompts/integration/green_enrich/green_enrich/prompt.md +9 -0
  115. devflow_engine/prompts/integration/green_enrich/node_config/prompt.md +41 -0
  116. devflow_engine/prompts/integration/green_enrich/past_prompts/20260417T212300/green_enrich/prompt.md +14 -0
  117. devflow_engine/prompts/integration/green_enrich/past_prompts/20260417T212300/node_config/prompt.md +41 -0
  118. devflow_engine/prompts/integration/red/code_repair/prompt.md +12 -0
  119. devflow_engine/prompts/integration/red/example.md +152 -0
  120. devflow_engine/prompts/integration/red/node_config/prompt.md +86 -0
  121. devflow_engine/prompts/integration/red/past_prompts/20260417T212300/code_repair/prompt.md +19 -0
  122. devflow_engine/prompts/integration/red/past_prompts/20260417T212300/node_config/prompt.md +84 -0
  123. devflow_engine/prompts/integration/red/past_prompts/20260417T212300/red/prompt.md +16 -0
  124. devflow_engine/prompts/integration/red/past_prompts/20260417T212300/red_repair/prompt.md +15 -0
  125. devflow_engine/prompts/integration/red/past_prompts/20260417T215032/code_repair/prompt.md +10 -0
  126. devflow_engine/prompts/integration/red/past_prompts/20260417T215032/node_config/prompt.md +84 -0
  127. devflow_engine/prompts/integration/red/past_prompts/20260417T215032/red_repair/prompt.md +11 -0
  128. devflow_engine/prompts/integration/red/red/prompt.md +11 -0
  129. devflow_engine/prompts/integration/red/red_repair/prompt.md +12 -0
  130. devflow_engine/prompts/integration/red_review/example.md +71 -0
  131. devflow_engine/prompts/integration/red_review/node_config/prompt.md +41 -0
  132. devflow_engine/prompts/integration/red_review/past_prompts/20260417T212300/node_config/prompt.md +41 -0
  133. devflow_engine/prompts/integration/red_review/past_prompts/20260417T212300/red_review/prompt.md +15 -0
  134. devflow_engine/prompts/integration/red_review/red_review/prompt.md +9 -0
  135. devflow_engine/prompts/integration/resolve/example.md +111 -0
  136. devflow_engine/prompts/integration/resolve/node_config/prompt.md +64 -0
  137. devflow_engine/prompts/integration/resolve/past_prompts/20260417T212300/node_config/prompt.md +64 -0
  138. devflow_engine/prompts/integration/resolve/past_prompts/20260417T212300/resolve_implicated_users/prompt.md +15 -0
  139. devflow_engine/prompts/integration/resolve/past_prompts/20260417T212300/resolve_side_effects/prompt.md +15 -0
  140. devflow_engine/prompts/integration/resolve/resolve_implicated_users/prompt.md +10 -0
  141. devflow_engine/prompts/integration/resolve/resolve_side_effects/prompt.md +10 -0
  142. devflow_engine/prompts/integration/validate/build_idea_acceptance_coverage/prompt.md +12 -0
  143. devflow_engine/prompts/integration/validate/code_repair/prompt.md +13 -0
  144. devflow_engine/prompts/integration/validate/example.md +143 -0
  145. devflow_engine/prompts/integration/validate/node_config/prompt.md +87 -0
  146. devflow_engine/prompts/integration/validate/past_prompts/20260417T212300/code_repair/prompt.md +19 -0
  147. devflow_engine/prompts/integration/validate/past_prompts/20260417T212300/node_config/prompt.md +67 -0
  148. devflow_engine/prompts/integration/validate/past_prompts/20260417T212300/validate_enrich_gate/prompt.md +17 -0
  149. devflow_engine/prompts/integration/validate/past_prompts/20260417T212300/validate_repair/prompt.md +16 -0
  150. devflow_engine/prompts/integration/validate/past_prompts/20260417T215032/code_repair/prompt.md +10 -0
  151. devflow_engine/prompts/integration/validate/past_prompts/20260417T215032/node_config/prompt.md +67 -0
  152. devflow_engine/prompts/integration/validate/past_prompts/20260417T215032/validate_repair/prompt.md +9 -0
  153. devflow_engine/prompts/integration/validate/validate_enrich_gate/prompt.md +10 -0
  154. devflow_engine/prompts/integration/validate/validate_repair/prompt.md +20 -0
  155. devflow_engine/prompts/integration/write_workflows/example.md +100 -0
  156. devflow_engine/prompts/integration/write_workflows/node_config/prompt.md +44 -0
  157. devflow_engine/prompts/integration/write_workflows/past_prompts/20260417T212300/node_config/prompt.md +44 -0
  158. devflow_engine/prompts/integration/write_workflows/past_prompts/20260417T212300/write_workflows/prompt.md +17 -0
  159. devflow_engine/prompts/integration/write_workflows/write_workflows/prompt.md +11 -0
  160. devflow_engine/prompts/iterate/README.md +7 -0
  161. devflow_engine/prompts/iterate/coder/prompt.md +11 -0
  162. devflow_engine/prompts/iterate/framer/prompt.md +11 -0
  163. devflow_engine/prompts/iterate/iterator/prompt.md +13 -0
  164. devflow_engine/prompts/iterate/observer/prompt.md +11 -0
  165. devflow_engine/prompts/recovery/diagnosis/prompt.md +7 -0
  166. devflow_engine/prompts/recovery/execution/prompt.md +8 -0
  167. devflow_engine/prompts/recovery/execution_verification/prompt.md +7 -0
  168. devflow_engine/prompts/recovery/failure_investigation/prompt.md +10 -0
  169. devflow_engine/prompts/recovery/preflight_health_repo_repair/prompt.md +8 -0
  170. devflow_engine/prompts/recovery/remediation_execution/prompt.md +11 -0
  171. devflow_engine/prompts/recovery/root_cause_investigation/prompt.md +12 -0
  172. devflow_engine/prompts/scope_idea/doctrine/prompt.md +7 -0
  173. devflow_engine/prompts/source_doc_eval/document/prompt.md +6 -0
  174. devflow_engine/prompts/source_doc_eval/targeted_mutation/prompt.md +9 -0
  175. devflow_engine/prompts/source_doc_mutation/domain_entities/prompt.md +6 -0
  176. devflow_engine/prompts/source_doc_mutation/product_brief/prompt.md +6 -0
  177. devflow_engine/prompts/source_doc_mutation/project_doc_coherence/prompt.md +7 -0
  178. devflow_engine/prompts/source_doc_mutation/project_doc_render/prompt.md +9 -0
  179. devflow_engine/prompts/source_doc_mutation/source_doc_coherence/prompt.md +5 -0
  180. devflow_engine/prompts/source_doc_mutation/source_doc_enrichment_coherence/prompt.md +6 -0
  181. devflow_engine/prompts/source_doc_mutation/user_workflows/prompt.md +6 -0
  182. devflow_engine/prompts/source_scope/doctrine/prompt.md +10 -0
  183. devflow_engine/prompts/ui_grounding/doctrine/prompt.md +7 -0
  184. devflow_engine/recovery/__init__.py +3 -0
  185. devflow_engine/recovery/dag.py +2609 -0
  186. devflow_engine/recovery/models.py +220 -0
  187. devflow_engine/refactor.py +93 -0
  188. devflow_engine/registry/__init__.py +1 -0
  189. devflow_engine/registry/cards.py +238 -0
  190. devflow_engine/registry/domain_normalize.py +60 -0
  191. devflow_engine/registry/effects.py +65 -0
  192. devflow_engine/registry/enforce_report.py +150 -0
  193. devflow_engine/registry/module_cards_classify.py +164 -0
  194. devflow_engine/registry/module_cards_draft.py +184 -0
  195. devflow_engine/registry/module_cards_gate.py +59 -0
  196. devflow_engine/registry/packages.py +347 -0
  197. devflow_engine/registry/pathways.py +323 -0
  198. devflow_engine/review/__init__.py +11 -0
  199. devflow_engine/review/dag.py +588 -0
  200. devflow_engine/review/review_story.py +67 -0
  201. devflow_engine/scope_idea/__init__.py +3 -0
  202. devflow_engine/scope_idea/agentic.py +39 -0
  203. devflow_engine/scope_idea/dag.py +1069 -0
  204. devflow_engine/scope_idea/models.py +175 -0
  205. devflow_engine/skills/builtins/devflow/queue_failure_investigation/SKILL.md +112 -0
  206. devflow_engine/skills/builtins/devflow/queue_idea_to_story/SKILL.md +120 -0
  207. devflow_engine/skills/builtins/devflow/queue_integration/SKILL.md +105 -0
  208. devflow_engine/skills/builtins/devflow/queue_recovery/SKILL.md +108 -0
  209. devflow_engine/skills/builtins/devflow/queue_runtime_core/SKILL.md +155 -0
  210. devflow_engine/skills/builtins/devflow/queue_story_implementation/SKILL.md +122 -0
  211. devflow_engine/skills/builtins/devin/idea_to_story_handoff/SKILL.md +120 -0
  212. devflow_engine/skills/builtins/devin/ideation/SKILL.md +168 -0
  213. devflow_engine/skills/builtins/devin/ideation/state-and-phrasing-reference.md +18 -0
  214. devflow_engine/skills/builtins/devin/insight/SKILL.md +22 -0
  215. devflow_engine/skills/registry.example.yaml +42 -0
  216. devflow_engine/source_doc_assumptions.py +291 -0
  217. devflow_engine/source_doc_mutation_dag.py +1606 -0
  218. devflow_engine/source_doc_mutation_eval.py +417 -0
  219. devflow_engine/source_doc_mutation_worker.py +25 -0
  220. devflow_engine/source_docs_schema.py +207 -0
  221. devflow_engine/source_docs_updater.py +309 -0
  222. devflow_engine/source_scope/__init__.py +15 -0
  223. devflow_engine/source_scope/agentic.py +45 -0
  224. devflow_engine/source_scope/dag.py +1626 -0
  225. devflow_engine/source_scope/models.py +177 -0
  226. devflow_engine/stores/__init__.py +0 -0
  227. devflow_engine/stores/execution_store.py +3534 -0
  228. devflow_engine/story/__init__.py +0 -0
  229. devflow_engine/story/contracts.py +160 -0
  230. devflow_engine/story/discovery.py +47 -0
  231. devflow_engine/story/evidence.py +118 -0
  232. devflow_engine/story/hashing.py +27 -0
  233. devflow_engine/story/implemented_queue_purge.py +148 -0
  234. devflow_engine/story/indexer.py +105 -0
  235. devflow_engine/story/io.py +20 -0
  236. devflow_engine/story/markdown_contracts.py +298 -0
  237. devflow_engine/story/reconciliation.py +408 -0
  238. devflow_engine/story/validate_stories.py +149 -0
  239. devflow_engine/story/validate_tests_story.py +512 -0
  240. devflow_engine/story/validation.py +133 -0
  241. devflow_engine/ui_grounding/__init__.py +11 -0
  242. devflow_engine/ui_grounding/agentic.py +31 -0
  243. devflow_engine/ui_grounding/dag.py +874 -0
  244. devflow_engine/ui_grounding/models.py +224 -0
  245. devflow_engine/ui_grounding/pencil_bridge.py +247 -0
  246. devflow_engine/vendor/__init__.py +0 -0
  247. devflow_engine/vendor/datalumina_genai/__init__.py +11 -0
  248. devflow_engine/vendor/datalumina_genai/core/__init__.py +0 -0
  249. devflow_engine/vendor/datalumina_genai/core/exceptions.py +9 -0
  250. devflow_engine/vendor/datalumina_genai/core/nodes/__init__.py +0 -0
  251. devflow_engine/vendor/datalumina_genai/core/nodes/agent.py +48 -0
  252. devflow_engine/vendor/datalumina_genai/core/nodes/agent_streaming_node.py +26 -0
  253. devflow_engine/vendor/datalumina_genai/core/nodes/base.py +89 -0
  254. devflow_engine/vendor/datalumina_genai/core/nodes/concurrent.py +30 -0
  255. devflow_engine/vendor/datalumina_genai/core/nodes/router.py +69 -0
  256. devflow_engine/vendor/datalumina_genai/core/schema.py +72 -0
  257. devflow_engine/vendor/datalumina_genai/core/task.py +52 -0
  258. devflow_engine/vendor/datalumina_genai/core/validate.py +139 -0
  259. devflow_engine/vendor/datalumina_genai/core/workflow.py +200 -0
  260. devflow_engine/worker.py +1086 -0
  261. devflow_engine/worker_guard.py +233 -0
  262. devflow_engine-1.0.0.dist-info/METADATA +235 -0
  263. devflow_engine-1.0.0.dist-info/RECORD +393 -0
  264. devflow_engine-1.0.0.dist-info/WHEEL +4 -0
  265. devflow_engine-1.0.0.dist-info/entry_points.txt +3 -0
  266. devin/__init__.py +6 -0
  267. devin/dag.py +58 -0
  268. devin/dag_two_arm.py +138 -0
  269. devin/devin_chat_scenario_catalog.json +588 -0
  270. devin/devin_eval.py +677 -0
  271. devin/nodes/__init__.py +0 -0
  272. devin/nodes/ideation/__init__.py +0 -0
  273. devin/nodes/ideation/node.py +195 -0
  274. devin/nodes/ideation/playground.py +267 -0
  275. devin/nodes/ideation/prompt.md +65 -0
  276. devin/nodes/ideation/scenarios/continue_refinement.py +13 -0
  277. devin/nodes/ideation/scenarios/continue_refinement_evals.py +18 -0
  278. devin/nodes/ideation/scenarios/idea_fits_existing_patterns.py +17 -0
  279. devin/nodes/ideation/scenarios/idea_fits_existing_patterns_evals.py +16 -0
  280. devin/nodes/ideation/scenarios/large_idea_split.py +4 -0
  281. devin/nodes/ideation/scenarios/large_idea_split_evals.py +17 -0
  282. devin/nodes/ideation/scenarios/source_documentation_added.py +4 -0
  283. devin/nodes/ideation/scenarios/source_documentation_added_evals.py +16 -0
  284. devin/nodes/ideation/scenarios/user_says_create_it.py +30 -0
  285. devin/nodes/ideation/scenarios/user_says_create_it_evals.py +23 -0
  286. devin/nodes/ideation/scenarios/vague_idea.py +16 -0
  287. devin/nodes/ideation/scenarios/vague_idea_evals.py +47 -0
  288. devin/nodes/ideation/tools.json +312 -0
  289. devin/nodes/insight/__init__.py +0 -0
  290. devin/nodes/insight/node.py +49 -0
  291. devin/nodes/insight/playground.py +154 -0
  292. devin/nodes/insight/prompt.md +61 -0
  293. devin/nodes/insight/scenarios/architecture_pattern_query.py +15 -0
  294. devin/nodes/insight/scenarios/architecture_pattern_query_evals.py +25 -0
  295. devin/nodes/insight/scenarios/codebase_exploration.py +15 -0
  296. devin/nodes/insight/scenarios/codebase_exploration_evals.py +23 -0
  297. devin/nodes/insight/scenarios/devin_ideation_routing.py +19 -0
  298. devin/nodes/insight/scenarios/devin_ideation_routing_evals.py +39 -0
  299. devin/nodes/insight/scenarios/devin_insight_routing.py +20 -0
  300. devin/nodes/insight/scenarios/devin_insight_routing_evals.py +40 -0
  301. devin/nodes/insight/scenarios/operational_debugging.py +15 -0
  302. devin/nodes/insight/scenarios/operational_debugging_evals.py +23 -0
  303. devin/nodes/insight/scenarios/operational_question.py +9 -0
  304. devin/nodes/insight/scenarios/operational_question_evals.py +8 -0
  305. devin/nodes/insight/scenarios/queue_status.py +15 -0
  306. devin/nodes/insight/scenarios/queue_status_evals.py +23 -0
  307. devin/nodes/insight/scenarios/source_doc_explanation.py +14 -0
  308. devin/nodes/insight/scenarios/source_doc_explanation_evals.py +21 -0
  309. devin/nodes/insight/scenarios/worker_state_check.py +15 -0
  310. devin/nodes/insight/scenarios/worker_state_check_evals.py +22 -0
  311. devin/nodes/insight/tools.json +126 -0
  312. devin/nodes/intake/__init__.py +0 -0
  313. devin/nodes/intake/node.py +27 -0
  314. devin/nodes/intake/playground.py +47 -0
  315. devin/nodes/intake/prompt.md +12 -0
  316. devin/nodes/intake/scenarios/ideation_routing.py +4 -0
  317. devin/nodes/intake/scenarios/ideation_routing_evals.py +5 -0
  318. devin/nodes/intake/scenarios/insight_routing.py +4 -0
  319. devin/nodes/intake/scenarios/insight_routing_evals.py +5 -0
  320. devin/nodes/iterate/README.md +44 -0
  321. devin/nodes/iterate/__init__.py +1 -0
  322. devin/nodes/iterate/_archived_design_stages/01-objectives-requirements.md +112 -0
  323. devin/nodes/iterate/_archived_design_stages/02-evals.md +131 -0
  324. devin/nodes/iterate/_archived_design_stages/03-tools-and-boundaries.md +110 -0
  325. devin/nodes/iterate/_archived_design_stages/04-harness-and-playground.md +32 -0
  326. devin/nodes/iterate/_archived_design_stages/05-prompt-deferred.md +11 -0
  327. devin/nodes/iterate/_archived_design_stages/coder_agent_design/01-objectives-requirements.md +20 -0
  328. devin/nodes/iterate/_archived_design_stages/coder_agent_design/02-evals.md +8 -0
  329. devin/nodes/iterate/_archived_design_stages/coder_agent_design/03-tools-and-boundaries.md +14 -0
  330. devin/nodes/iterate/_archived_design_stages/coder_agent_design/04-harness-and-playground.md +12 -0
  331. devin/nodes/iterate/_archived_design_stages/framer_agent_design/01-objectives-requirements.md +20 -0
  332. devin/nodes/iterate/_archived_design_stages/framer_agent_design/02-evals.md +8 -0
  333. devin/nodes/iterate/_archived_design_stages/framer_agent_design/03-tools-and-boundaries.md +13 -0
  334. devin/nodes/iterate/_archived_design_stages/framer_agent_design/04-harness-and-playground.md +12 -0
  335. devin/nodes/iterate/_archived_design_stages/iterator_agent_design/01-objectives-requirements.md +25 -0
  336. devin/nodes/iterate/_archived_design_stages/iterator_agent_design/02-evals.md +9 -0
  337. devin/nodes/iterate/_archived_design_stages/iterator_agent_design/03-tools-and-boundaries.md +14 -0
  338. devin/nodes/iterate/_archived_design_stages/iterator_agent_design/04-harness-and-playground.md +12 -0
  339. devin/nodes/iterate/_archived_design_stages/observer_agent_design/01-objectives-requirements.md +20 -0
  340. devin/nodes/iterate/_archived_design_stages/observer_agent_design/02-evals.md +8 -0
  341. devin/nodes/iterate/_archived_design_stages/observer_agent_design/03-tools-and-boundaries.md +14 -0
  342. devin/nodes/iterate/_archived_design_stages/observer_agent_design/04-harness-and-playground.md +13 -0
  343. devin/nodes/iterate/agent-roles.md +89 -0
  344. devin/nodes/iterate/agents/README.md +10 -0
  345. devin/nodes/iterate/artifacts.md +504 -0
  346. devin/nodes/iterate/contract.md +100 -0
  347. devin/nodes/iterate/eval-plan.md +74 -0
  348. devin/nodes/iterate/node.py +100 -0
  349. devin/nodes/iterate/pipeline/README.md +13 -0
  350. devin/nodes/iterate/playground-contract.md +76 -0
  351. devin/nodes/iterate/prompt.md +11 -0
  352. devin/nodes/iterate/scenarios/README.md +38 -0
  353. devin/nodes/iterate/scenarios/artifact-and-loop-scenarios.md +101 -0
  354. devin/nodes/iterate/scenarios/coder_artifact_alignment.py +32 -0
  355. devin/nodes/iterate/scenarios/coder_artifact_alignment_evals.py +45 -0
  356. devin/nodes/iterate/scenarios/coder_bounded_fix.py +27 -0
  357. devin/nodes/iterate/scenarios/coder_bounded_fix_evals.py +45 -0
  358. devin/nodes/iterate/scenarios/devin_iterate_routing.py +21 -0
  359. devin/nodes/iterate/scenarios/devin_iterate_routing_evals.py +36 -0
  360. devin/nodes/iterate/scenarios/framer_scope_boundary.py +25 -0
  361. devin/nodes/iterate/scenarios/framer_scope_boundary_evals.py +57 -0
  362. devin/nodes/iterate/scenarios/framer_task_framing.py +25 -0
  363. devin/nodes/iterate/scenarios/framer_task_framing_evals.py +58 -0
  364. devin/nodes/iterate/scenarios/iterate_error_fix.py +21 -0
  365. devin/nodes/iterate/scenarios/iterate_error_fix_evals.py +39 -0
  366. devin/nodes/iterate/scenarios/iterate_quick_change.py +21 -0
  367. devin/nodes/iterate/scenarios/iterate_quick_change_evals.py +35 -0
  368. devin/nodes/iterate/scenarios/iterate_to_idea_promotion.py +23 -0
  369. devin/nodes/iterate/scenarios/iterate_to_idea_promotion_evals.py +53 -0
  370. devin/nodes/iterate/scenarios/iterate_to_insight_reroute.py +23 -0
  371. devin/nodes/iterate/scenarios/iterate_to_insight_reroute_evals.py +53 -0
  372. devin/nodes/iterate/scenarios/observer_evidence_seam.py +28 -0
  373. devin/nodes/iterate/scenarios/observer_evidence_seam_evals.py +55 -0
  374. devin/nodes/iterate/scenarios/observer_repro_creation.py +28 -0
  375. devin/nodes/iterate/scenarios/observer_repro_creation_evals.py +45 -0
  376. devin/nodes/iterate/scenarios/routing-matrix.md +45 -0
  377. devin/nodes/shared/__init__.py +0 -0
  378. devin/nodes/shared/filemaker_expert.md +80 -0
  379. devin/nodes/shared/filemaker_expert.py +354 -0
  380. devin/nodes/shared/filemaker_expert_eval/runner.py +176 -0
  381. devin/nodes/shared/filemaker_expert_eval/scenarios.json +65 -0
  382. devin/nodes/shared/goldilocks_advisor_eval/runner.py +214 -0
  383. devin/nodes/shared/goldilocks_advisor_eval/scenarios.json +58 -0
  384. devin/nodes/shared/helpers.py +156 -0
  385. devin/nodes/shared/idea_compliance_advisor_eval/runner.py +252 -0
  386. devin/nodes/shared/idea_compliance_advisor_eval/scenarios.json +75 -0
  387. devin/nodes/shared/models.py +44 -0
  388. devin/nodes/shared/post.py +40 -0
  389. devin/nodes/shared/router.py +107 -0
  390. devin/nodes/shared/tools.py +191 -0
  391. devin/shared/devin-chat-rubric.md +237 -0
  392. devin/shared/devin-chat-scenario-suite.md +90 -0
  393. devin/shared/eval_doctrine.md +9 -0
@@ -0,0 +1,1606 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ import os
9
+ import subprocess
10
+ import sys
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel, field_validator
14
+
15
+ from .agentic_prompts import load_agentic_prompt_lines
16
+ from .agentic_runtime import run_agent_step
17
+ from .source_doc_assumptions import reconcile_assumptions_registry, register_assumption
18
+ from .source_docs_schema import source_doc_template_payloads
19
+ from .source_docs_updater import ensure_source_doc_scaffold
20
+ from .vendor.datalumina_genai.core.nodes.agent import AgentConfig, AgentNode
21
+ from .vendor.datalumina_genai.core.nodes.base import Node
22
+ from .vendor.datalumina_genai.core.nodes.concurrent import ConcurrentNode
23
+ from .vendor.datalumina_genai.core.schema import NodeConfig, WorkflowSchema
24
+ from .vendor.datalumina_genai.core.task import TaskContext
25
+ from .vendor.datalumina_genai.core.workflow import Workflow
26
+
27
+ DAG_ID = "source_doc_mutation_dag"
28
+ CANONICAL_SOURCE_DOCS = tuple(source_doc_template_payloads().keys())
29
+ PROJECT_DOC_TARGETS = (
30
+ "project_charter.md",
31
+ "prd.md",
32
+ "architecture.md",
33
+ "ux_design.md",
34
+ "delivery_plan.md",
35
+ )
36
+ PROJECT_DOC_REFERENCE_DOCS = {
37
+ "richness_contract": "docs/project-doc-richness-contract.md",
38
+ "agent_contracts": "docs/project-doc-agent-contracts.md",
39
+ "reference_map": "docs/source-to-project-doc-reference-map.md",
40
+ "cross_reference_rules": "docs/project-doc-cross-reference-rules.md",
41
+ }
42
+ SUPPORT_INDEX_DOCS = {
43
+ "internal_source": "docs/support/source_doc_mutation/internal_source.json",
44
+ "internal_project": "docs/support/source_doc_mutation/internal_project.json",
45
+ "cross_sourceXproject": "docs/support/source_doc_mutation/cross_sourceXproject.json",
46
+ }
47
+ PROJECT_DOC_PRIMARY_SOURCE_DOCS = {
48
+ "project_charter.md": ["product_brief.json", "assumptions_registry.json"],
49
+ "prd.md": ["product_brief.json", "user_workflows.json", "assumptions_registry.json"],
50
+ "architecture.md": ["domain_entities.json", "product_brief.json", "assumptions_registry.json"],
51
+ "ux_design.md": ["user_workflows.json", "product_brief.json", "assumptions_registry.json"],
52
+ "delivery_plan.md": ["product_brief.json", "user_workflows.json", "domain_entities.json", "assumptions_registry.json"],
53
+ }
54
+ PROJECT_DOC_SECTION_IMPACT_MAP = {
55
+ "product_brief.json": ["project_charter.md", "prd.md", "architecture.md", "ux_design.md", "delivery_plan.md"],
56
+ "user_workflows.json": ["prd.md", "architecture.md", "ux_design.md", "delivery_plan.md"],
57
+ "domain_entities.json": ["prd.md", "architecture.md", "ux_design.md", "delivery_plan.md"],
58
+ "assumptions_registry.json": list(PROJECT_DOC_TARGETS),
59
+ }
60
+ REPO_BOOTSTRAP_ALLOWED_SUFFIXES = {
61
+ ".md",
62
+ ".mdx",
63
+ ".txt",
64
+ ".rst",
65
+ ".py",
66
+ ".ts",
67
+ ".tsx",
68
+ ".js",
69
+ ".jsx",
70
+ ".json",
71
+ ".yaml",
72
+ ".yml",
73
+ ".toml",
74
+ }
75
+ REPO_BOOTSTRAP_IGNORE_DIRS = {
76
+ ".git",
77
+ ".hg",
78
+ ".svn",
79
+ ".venv",
80
+ "venv",
81
+ "node_modules",
82
+ "dist",
83
+ "build",
84
+ ".next",
85
+ ".turbo",
86
+ ".pytest_cache",
87
+ "__pycache__",
88
+ ".mypy_cache",
89
+ ".ruff_cache",
90
+ ".devflow",
91
+ }
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class SourceDocMutationDagResult:
96
+ run_id: str
97
+ pipeline_dir: Path
98
+ mutation_ref: dict[str, Any]
99
+ result: dict[str, Any]
100
+
101
+
102
+ class SourceDocMutationDagEvent(BaseModel):
103
+ project_id: str
104
+ idea_id: str
105
+ repo_root: str
106
+ raw_text: str = ""
107
+ grounded_raw_text: str = ""
108
+ mode: str | None = None
109
+ agent_runtime_mode: str | None = None
110
+ history_grounding_ref: str | None = None
111
+ existing_source_doc_refs: list[str] = []
112
+ explicit_source_doc_payload: dict[str, Any] | None = None
113
+ message_id: str | None = None
114
+ session_id: str | None = None
115
+ invoked_from: str = "idea_context_resolution"
116
+ pipeline_key: str | None = None
117
+
118
+
119
+ def _stable_hash(payload: Any) -> str:
120
+ return hashlib.sha256(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()
121
+
122
+
123
+ def _stable_id(prefix: str, payload: Any, *, size: int = 12) -> str:
124
+ return f"{prefix}{_stable_hash(payload)[:size]}"
125
+
126
+
127
+ def _clone(value: Any) -> Any:
128
+ return json.loads(json.dumps(value))
129
+
130
+
131
+ def _write_json(path: Path, payload: dict[str, Any]) -> None:
132
+ path.parent.mkdir(parents=True, exist_ok=True)
133
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
134
+
135
+
136
+ def _load_json_dict(path: Path, *, default: dict[str, Any]) -> dict[str, Any]:
137
+ if not path.exists():
138
+ return _clone(default)
139
+ try:
140
+ payload = json.loads(path.read_text(encoding="utf-8"))
141
+ except Exception:
142
+ return _clone(default)
143
+ return payload if isinstance(payload, dict) else _clone(default)
144
+
145
+
146
+ def _source_docs_dir(repo_root: Path) -> Path:
147
+ return repo_root / "ai_docs" / "context" / "source_docs"
148
+
149
+
150
+ def _v2_project_docs_dir(repo_root: Path) -> Path:
151
+ return repo_root / "ai_docs" / "context" / "v2" / "project_docs"
152
+
153
+
154
+ def _pipeline_root(repo_root: Path, *, idea_id: str, pipeline_key: str) -> Path:
155
+ safe_idea_id = idea_id or "bootstrap"
156
+ return repo_root / ".devflow" / "ideas" / safe_idea_id / "pipelines" / DAG_ID / pipeline_key
157
+
158
+
159
+ def _dedupe_str_list(values: list[Any]) -> list[str]:
160
+ out: list[str] = []
161
+ for item in values:
162
+ if not isinstance(item, str):
163
+ continue
164
+ text = item.strip()
165
+ if text and text not in out:
166
+ out.append(text)
167
+ return out
168
+
169
+
170
+ def _guess_doc_type_from_message(raw_text: str) -> str:
171
+ lowered = raw_text.lower()
172
+ for token, label in [
173
+ ("portal", "portal"),
174
+ ("dashboard", "dashboard"),
175
+ ("app", "app"),
176
+ ("api", "API"),
177
+ ("workflow", "workflow automation"),
178
+ ("system", "system"),
179
+ ("platform", "platform"),
180
+ ]:
181
+ if token in lowered:
182
+ return label
183
+ return "software product"
184
+
185
+
186
+ def _infer_users(raw_text: str) -> list[str]:
187
+ lowered = raw_text.lower()
188
+ tokens = {token.strip(".,:;!?()[]{}\"'`") for token in lowered.split()}
189
+ users: list[str] = []
190
+ if "leadership" in tokens or "executive" in tokens or "executives" in tokens:
191
+ users.append("leadership")
192
+ if "board" in tokens:
193
+ users.append("board members")
194
+ if "office" in lowered and "field" in lowered:
195
+ users.append("team members")
196
+ if "team" in lowered:
197
+ if "service team" in lowered:
198
+ users.append("service team")
199
+ elif "support team" in lowered:
200
+ users.append("support team")
201
+ else:
202
+ users.append("team members")
203
+ if "operator" in lowered:
204
+ users.append("operators")
205
+ return list(dict.fromkeys(users))
206
+
207
+
208
+ def _infer_scope_bullets(raw_text: str) -> list[str]:
209
+ lowered = raw_text.lower()
210
+ bullets: list[str] = []
211
+ for token, label in [
212
+ ("assignment", "task assignments"),
213
+ ("due date", "due dates"),
214
+ ("dashboard", "dashboard view"),
215
+ ("route", "routing"),
216
+ ("queue", "review queue"),
217
+ ("report", "report generation"),
218
+ ("trend", "trend analysis"),
219
+ ]:
220
+ if token in lowered:
221
+ bullets.append(label)
222
+ return list(dict.fromkeys(bullets))
223
+
224
+
225
+ def _infer_problem(raw_text: str) -> str | None:
226
+ lowered = raw_text.lower()
227
+ if "managing tasks" in lowered or "manage tasks" in lowered:
228
+ return "The team needs a simpler way to manage and track work."
229
+ if "assignments" in lowered or "due dates" in lowered:
230
+ return "The team lacks a clear system for tracking assignments and due dates."
231
+ return None
232
+
233
+
234
+ def _infer_goal(raw_text: str) -> str | None:
235
+ lowered = raw_text.lower()
236
+ if "dashboard" in lowered and ("assignment" in lowered or "due date" in lowered):
237
+ return "Give the team one place to track assignments, due dates, and task status."
238
+ if "managing tasks" in lowered or "manage tasks" in lowered:
239
+ return "Create a simple app for organizing team tasks."
240
+ return None
241
+
242
+
243
+ def extract_sufficient_idea(raw_text: str) -> dict[str, Any]:
244
+ users = _infer_users(raw_text)
245
+ problem = _infer_problem(raw_text) or ""
246
+ goal = _infer_goal(raw_text) or ""
247
+ scope = _infer_scope_bullets(raw_text)
248
+ summary = raw_text.strip().splitlines()[0][:240] if raw_text.strip() else ""
249
+ return {
250
+ "summary": summary,
251
+ "problem": problem,
252
+ "goal": goal,
253
+ "target_users": users,
254
+ "scope": scope,
255
+ "constraints": [],
256
+ "acceptance_criteria": [],
257
+ }
258
+
259
+
260
+ def _derive_product_summary(raw_text: str, sufficient_idea: dict[str, Any]) -> str:
261
+ summary = str(sufficient_idea.get("summary") or "").strip()
262
+ if summary:
263
+ return summary
264
+ users = _dedupe_str_list(list(sufficient_idea.get("target_users") or [])) or _infer_users(raw_text)
265
+ goal = str(sufficient_idea.get("goal") or _infer_goal(raw_text) or "").strip()
266
+ doc_type = _guess_doc_type_from_message(raw_text)
267
+ if users and goal:
268
+ lowered_goal = goal[0].lower() + goal[1:] if len(goal) > 1 else goal.lower()
269
+ return f"{doc_type.capitalize()} for {users[0]} so they can {lowered_goal}."
270
+ if users:
271
+ return f"{doc_type.capitalize()} for {users[0]}."
272
+ return raw_text.strip().splitlines()[0][:240] if raw_text.strip() else ""
273
+
274
+
275
+ def _load_source_doc_state(repo_root: Path) -> dict[str, dict[str, Any]]:
276
+ source_docs_dir = ensure_source_doc_scaffold(repo_root).source_docs_dir
277
+ return {
278
+ name: _load_json_dict(source_docs_dir / name, default=payload)
279
+ for name, payload in source_doc_template_payloads().items()
280
+ }
281
+
282
+
283
+ def _record_assumption(
284
+ *,
285
+ assumptions: list[dict[str, Any]],
286
+ doc_path: str,
287
+ section_key: str,
288
+ topic_key: str,
289
+ current_value: Any,
290
+ reasoning: str,
291
+ source: str,
292
+ message_id: str,
293
+ ) -> None:
294
+ registry = register_assumption(
295
+ {"assumptions": assumptions},
296
+ doc_path=doc_path,
297
+ section_key=section_key,
298
+ topic_key=topic_key,
299
+ current_value=current_value,
300
+ reasoning=reasoning,
301
+ source=source,
302
+ message_id=message_id,
303
+ )
304
+ assumptions[:] = [item for item in registry.get("assumptions") or [] if isinstance(item, dict)]
305
+
306
+
307
+ def _render_markdown(doc_name: str, payload: Any, *, shell_only: bool = False) -> str:
308
+ title = doc_name.replace("_", " ").replace(".json", "").title()
309
+ pretty_json = json.dumps(payload, indent=2, sort_keys=True)
310
+ summary_lines = [f"# {title}", "", f"Derived from `ai_docs/context/source_docs/{doc_name}`.", ""]
311
+ if shell_only:
312
+ summary_lines.extend([
313
+ "## Scaffold Status",
314
+ "",
315
+ "Minimal scaffold-only placeholder. Repo evidence was too sparse to ground this doc yet.",
316
+ "",
317
+ ])
318
+ if isinstance(payload, dict):
319
+ for key, value in payload.items():
320
+ summary_lines.append(f"## {str(key).replace('_', ' ').title()}")
321
+ if isinstance(value, list):
322
+ if value:
323
+ for item in value:
324
+ summary_lines.append(
325
+ f"- {json.dumps(item, sort_keys=True) if isinstance(item, (dict, list)) else item}"
326
+ )
327
+ else:
328
+ summary_lines.append("- _empty_")
329
+ elif isinstance(value, dict):
330
+ if value:
331
+ for sub_key, sub_value in value.items():
332
+ rendered = json.dumps(sub_value, sort_keys=True) if isinstance(sub_value, (dict, list)) else sub_value
333
+ summary_lines.append(f"- **{sub_key}**: {rendered}")
334
+ else:
335
+ summary_lines.append("- _empty_")
336
+ else:
337
+ summary_lines.append(str(value) if value not in (None, "") else "_empty_")
338
+ summary_lines.append("")
339
+ summary_lines.extend(["## Canonical JSON", "", "```json", pretty_json, "```", ""])
340
+ return "\n".join(summary_lines)
341
+
342
+
343
+ def _compute_assumptions_delta(before: list[dict[str, Any]], after: list[dict[str, Any]]) -> dict[str, Any]:
344
+ before_by_id = {str(item.get("assumption_id") or item.get("assumption_key") or ""): item for item in before if isinstance(item, dict)}
345
+ after_by_id = {str(item.get("assumption_id") or item.get("assumption_key") or ""): item for item in after if isinstance(item, dict)}
346
+ added = [item for key, item in after_by_id.items() if key and key not in before_by_id]
347
+ removed = [item for key, item in before_by_id.items() if key and key not in after_by_id]
348
+ status_changes: list[dict[str, Any]] = []
349
+ for key, item in after_by_id.items():
350
+ if key not in before_by_id:
351
+ continue
352
+ prior = before_by_id[key]
353
+ if str(prior.get("status") or "") != str(item.get("status") or ""):
354
+ status_changes.append(
355
+ {
356
+ "assumption_id": key,
357
+ "assumption_key": item.get("assumption_key"),
358
+ "from_status": prior.get("status"),
359
+ "to_status": item.get("status"),
360
+ "doc_path": item.get("doc_path"),
361
+ "section_key": item.get("section_key"),
362
+ "topic_key": item.get("topic_key"),
363
+ }
364
+ )
365
+ return {
366
+ "before_count": len(before_by_id),
367
+ "after_count": len(after_by_id),
368
+ "added": added,
369
+ "removed": removed,
370
+ "status_changes": status_changes,
371
+ }
372
+
373
+
374
+ def _sync_v2_project_docs(
375
+ *,
376
+ repo_root: Path,
377
+ changed_docs: list[str],
378
+ source_docs: dict[str, dict[str, Any]],
379
+ shell_only: bool = False,
380
+ sync_reason: str = "deterministic_markdown_render",
381
+ ) -> dict[str, Any]:
382
+ v2_root = _v2_project_docs_dir(repo_root)
383
+ source_doc_root = v2_root / "source_docs"
384
+ source_doc_root.mkdir(parents=True, exist_ok=True)
385
+ updated_outputs: list[str] = []
386
+ source_hashes: dict[str, str] = {}
387
+
388
+ for doc_name, payload in sorted(source_docs.items()):
389
+ source_hashes[doc_name] = _stable_hash(payload)
390
+ md_path = source_doc_root / doc_name.replace(".json", ".md")
391
+ md_path.write_text(_render_markdown(doc_name, payload, shell_only=shell_only), encoding="utf-8")
392
+ updated_outputs.append(str(md_path.relative_to(repo_root)))
393
+
394
+ index_title = "# Canonical Source Docs Sync Index"
395
+ index_intro = "This directory contains derived project docs generated from canonical source docs under `ai_docs/context/source_docs/`."
396
+ if shell_only:
397
+ index_intro = (
398
+ "This directory contains scaffold-only derived project doc shells. Repo evidence was too sparse to infer a grounded project shape."
399
+ )
400
+ index_lines = [index_title, "", index_intro, "", "## Source Docs", ""]
401
+ for doc_name in sorted(source_docs):
402
+ md_name = doc_name.replace(".json", ".md")
403
+ marker = "updated" if doc_name in changed_docs else "current"
404
+ index_lines.append(f"- `source_docs/{md_name}` ← `{doc_name}` ({marker})")
405
+ index_lines.extend(["", "## Source Hashes", ""])
406
+ for doc_name, digest in sorted(source_hashes.items()):
407
+ index_lines.append(f"- `{doc_name}`: `{digest}`")
408
+ index_path = source_doc_root / "index.md"
409
+ index_path.write_text("\n".join(index_lines) + "\n", encoding="utf-8")
410
+ updated_outputs.append(str(index_path.relative_to(repo_root)))
411
+
412
+ return {
413
+ "status": "completed",
414
+ "derived_project_docs_root": str(v2_root),
415
+ "derived_source_doc_root": str(source_doc_root),
416
+ "updated_outputs": updated_outputs,
417
+ "changed_docs": changed_docs,
418
+ "source_hashes": source_hashes,
419
+ "sync_mode": sync_reason,
420
+ "shell_only": shell_only,
421
+ }
422
+
423
+
424
+ def _list_repo_bootstrap_candidates(repo_root: Path) -> list[Path]:
425
+ candidates: list[Path] = []
426
+ for path in sorted(repo_root.rglob("*")):
427
+ if not path.is_file():
428
+ continue
429
+ rel = path.relative_to(repo_root).as_posix()
430
+ if any(part in REPO_BOOTSTRAP_IGNORE_DIRS for part in path.parts):
431
+ continue
432
+ if rel.startswith("ai_docs/context/source_docs/") or rel.startswith("ai_docs/context/v2/project_docs/") or rel.startswith("ai_docs/context/project_docs/"):
433
+ continue
434
+ if path.suffix.lower() not in REPO_BOOTSTRAP_ALLOWED_SUFFIXES:
435
+ continue
436
+ candidates.append(path)
437
+ return candidates
438
+
439
+
440
+ def _read_text_safely(path: Path, *, max_chars: int = 4000) -> str:
441
+ try:
442
+ text = path.read_text(encoding="utf-8")
443
+ except Exception:
444
+ return ""
445
+ return text[:max_chars]
446
+
447
+
448
+ def _repo_bootstrap_inventory(repo_root: Path) -> dict[str, Any]:
449
+ candidates = _list_repo_bootstrap_candidates(repo_root)
450
+ evidence_files: list[dict[str, Any]] = []
451
+ matched_signal_files = 0
452
+ aggregate_tokens: set[str] = set()
453
+ signal_hits = {
454
+ "readme": 0,
455
+ "docs": 0,
456
+ "product_terms": 0,
457
+ "workflow_terms": 0,
458
+ "entity_terms": 0,
459
+ "scope_terms": 0,
460
+ }
461
+ product_terms = ("dashboard", "portal", "platform", "workflow", "app", "api", "service", "report")
462
+ workflow_terms = ("user", "users", "actor", "workflow", "journey", "task", "queue", "assign")
463
+ entity_terms = ("entity", "entities", "model", "models", "schema", "record", "task", "report")
464
+ scope_terms = ("feature", "features", "mvp", "scope", "requirements", "acceptance", "goal")
465
+
466
+ for path in candidates[:24]:
467
+ rel = path.relative_to(repo_root).as_posix()
468
+ text = _read_text_safely(path)
469
+ lowered = text.lower()
470
+ hit_count = 0
471
+ if path.name.lower().startswith("readme"):
472
+ signal_hits["readme"] += 1
473
+ hit_count += 1
474
+ if "docs/" in rel or rel.startswith("docs/") or "ai_docs/" in rel or rel.startswith("ai_docs/"):
475
+ signal_hits["docs"] += 1
476
+ hit_count += 1
477
+ for key, tokens in (
478
+ ("product_terms", product_terms),
479
+ ("workflow_terms", workflow_terms),
480
+ ("entity_terms", entity_terms),
481
+ ("scope_terms", scope_terms),
482
+ ):
483
+ token_hits = [token for token in tokens if token in lowered]
484
+ if token_hits:
485
+ signal_hits[key] += 1
486
+ hit_count += 1
487
+ aggregate_tokens.update(token_hits)
488
+ if hit_count:
489
+ matched_signal_files += 1
490
+ evidence_files.append({"path": rel, "matched_signal_count": hit_count, "size_bytes": path.stat().st_size})
491
+
492
+ has_genuine_signal = (
493
+ signal_hits["readme"] >= 1
494
+ and matched_signal_files >= 2
495
+ and len(aggregate_tokens) >= 3
496
+ and (signal_hits["product_terms"] + signal_hits["workflow_terms"] + signal_hits["entity_terms"] + signal_hits["scope_terms"]) >= 3
497
+ )
498
+
499
+ return {
500
+ "candidate_file_count": len(candidates),
501
+ "inspected_file_count": min(len(candidates), 24),
502
+ "matched_signal_files": matched_signal_files,
503
+ "signal_hits": signal_hits,
504
+ "aggregate_tokens": sorted(aggregate_tokens),
505
+ "evidence_files": evidence_files,
506
+ "has_genuine_signal": has_genuine_signal,
507
+ "decision_rule": {
508
+ "requires_readme": True,
509
+ "minimum_signal_files": 2,
510
+ "minimum_distinct_tokens": 3,
511
+ "minimum_signal_buckets": 3,
512
+ },
513
+ }
514
+
515
+
516
+ def _pick_repo_grounding_text(repo_root: Path) -> tuple[str, list[str]]:
517
+ preferred_names = ("README.md", "README.mdx", "README.txt")
518
+ snippets: list[str] = []
519
+ evidence_paths: list[str] = []
520
+ for name in preferred_names:
521
+ path = repo_root / name
522
+ if path.exists() and path.is_file():
523
+ text = _read_text_safely(path, max_chars=2500).strip()
524
+ if text:
525
+ snippets.append(text)
526
+ evidence_paths.append(path.relative_to(repo_root).as_posix())
527
+ break
528
+ for path in _list_repo_bootstrap_candidates(repo_root):
529
+ rel = path.relative_to(repo_root).as_posix()
530
+ if rel in evidence_paths:
531
+ continue
532
+ if not (rel.startswith("docs/") or rel.startswith("ai_docs/")):
533
+ continue
534
+ text = _read_text_safely(path, max_chars=1800).strip()
535
+ if not text:
536
+ continue
537
+ snippets.append(text)
538
+ evidence_paths.append(rel)
539
+ if len(snippets) >= 3:
540
+ break
541
+ return "\n\n".join(snippets).strip(), evidence_paths
542
+
543
+
544
+ def _has_meaningful_value(value: Any) -> bool:
545
+ if isinstance(value, str):
546
+ return bool(value.strip())
547
+ if isinstance(value, (list, dict, tuple, set)):
548
+ return bool(value)
549
+ return value is not None
550
+
551
+
552
+ def _load_reference_doc(repo_root: Path, relative_path: str) -> str:
553
+ path = repo_root / relative_path
554
+ if not path.exists():
555
+ return ""
556
+ return path.read_text(encoding="utf-8")
557
+
558
+
559
+
560
+
561
+ def _project_doc_reference_payload(repo_root: Path) -> dict[str, Any]:
562
+ references: dict[str, Any] = {}
563
+ for key, rel_path in PROJECT_DOC_REFERENCE_DOCS.items():
564
+ content = _load_reference_doc(repo_root, rel_path)
565
+ references[key] = {
566
+ "path": rel_path,
567
+ "content": content,
568
+ "loaded": bool(content.strip()),
569
+ }
570
+ return references
571
+
572
+
573
+ def _load_support_index(repo_root: Path, relative_path: str, *, expected_name: str) -> dict[str, Any]:
574
+ path = repo_root / relative_path
575
+ if not path.exists():
576
+ bundled_path = Path(__file__).resolve().parents[2] / relative_path
577
+ if bundled_path.exists():
578
+ path = bundled_path
579
+ payload = _load_json_dict(path, default={})
580
+ return {
581
+ "path": relative_path,
582
+ "loaded": path.exists() and bool(payload),
583
+ "classification": str(payload.get("classification") or "unknown"),
584
+ "contract": str(payload.get("contract") or ""),
585
+ "index_name": str(payload.get("index_name") or expected_name),
586
+ "payload": payload,
587
+ }
588
+
589
+
590
+ def _support_indexes_payload(repo_root: Path) -> dict[str, Any]:
591
+ indexes = {name: _load_support_index(repo_root, rel_path, expected_name=name) for name, rel_path in SUPPORT_INDEX_DOCS.items()}
592
+ return {
593
+ "classification": "support_docs",
594
+ "indexes": indexes,
595
+ }
596
+
597
+
598
+ def _compute_changed_sections(*, before_docs: dict[str, dict[str, Any]], after_docs: dict[str, dict[str, Any]], changed_docs: list[str]) -> list[str]:
599
+ changed_sections: list[str] = []
600
+ for doc_name in changed_docs:
601
+ before = before_docs.get(doc_name) if isinstance(before_docs.get(doc_name), dict) else {}
602
+ after = after_docs.get(doc_name) if isinstance(after_docs.get(doc_name), dict) else {}
603
+ section_keys = sorted(set(before.keys()) | set(after.keys()))
604
+ for section_key in section_keys:
605
+ if json.dumps(before.get(section_key), sort_keys=True) != json.dumps(after.get(section_key), sort_keys=True):
606
+ changed_sections.append(f"{doc_name}:{section_key}")
607
+ return changed_sections
608
+
609
+
610
+ def _project_doc_target_payloads(*, repo_root: Path, docs: dict[str, dict[str, Any]], changed_docs: list[str], changed_sections: list[str], support_indexes: dict[str, Any]) -> list[dict[str, Any]]:
611
+ cross_routes = (((support_indexes.get("indexes") or {}).get("cross_sourceXproject") or {}).get("payload") or {}).get("section_routes") or {}
612
+ project_entries = (((support_indexes.get("indexes") or {}).get("internal_project") or {}).get("payload") or {}).get("project_docs") or []
613
+ current_project_docs_root = _v2_project_docs_dir(repo_root) / "source_docs"
614
+ targets: list[dict[str, Any]] = []
615
+ for project_entry in project_entries:
616
+ if not isinstance(project_entry, dict):
617
+ continue
618
+ project_doc_name = str(project_entry.get("project_doc_name") or "").strip()
619
+ if not project_doc_name:
620
+ continue
621
+ primary_source_docs = _dedupe_str_list(list(project_entry.get("primary_source_docs") or []))
622
+ routed_sections = [section_ref for section_ref in changed_sections if project_doc_name in list(cross_routes.get(section_ref) or [])]
623
+ directly_impacted_by_changed_docs = _dedupe_str_list([section_ref.split(":", 1)[0] for section_ref in routed_sections])
624
+ supporting_source_docs = list(dict.fromkeys(primary_source_docs + directly_impacted_by_changed_docs))
625
+ existing_project_doc_path = current_project_docs_root / project_doc_name
626
+ existing_project_doc_content = existing_project_doc_path.read_text(encoding="utf-8") if existing_project_doc_path.exists() else ""
627
+ targets.append(
628
+ {
629
+ "project_doc_name": project_doc_name,
630
+ "primary_source_docs": primary_source_docs,
631
+ "supporting_source_docs": supporting_source_docs,
632
+ "directly_impacted_by_changed_docs": directly_impacted_by_changed_docs,
633
+ "routed_changed_sections": routed_sections,
634
+ "canonical_source_docs": {name: _clone(docs.get(name, {})) for name in supporting_source_docs},
635
+ "existing_project_doc": {
636
+ "path": str(existing_project_doc_path.relative_to(repo_root)) if existing_project_doc_path.exists() else str(existing_project_doc_path.relative_to(repo_root)),
637
+ "content": existing_project_doc_content,
638
+ "loaded": bool(existing_project_doc_content.strip()),
639
+ },
640
+ }
641
+ )
642
+ if not targets:
643
+ return []
644
+ fallback_targets: list[dict[str, Any]] = []
645
+ for item in targets:
646
+ if item["routed_changed_sections"]:
647
+ fallback_targets.append(item)
648
+ continue
649
+ if any(doc_name in changed_docs for doc_name in item["primary_source_docs"]):
650
+ copied = dict(item)
651
+ copied["directly_impacted_by_changed_docs"] = [doc_name for doc_name in item["primary_source_docs"] if doc_name in changed_docs]
652
+ fallback_targets.append(copied)
653
+ return fallback_targets
654
+
655
+
656
+ def _apply_explicit_source_doc_payload(
657
+ *, docs: dict[str, dict[str, Any]], explicit_payload: dict[str, Any], changed_docs: list[str]
658
+ ) -> None:
659
+ for doc_name in CANONICAL_SOURCE_DOCS:
660
+ incoming = explicit_payload.get(doc_name)
661
+ if not isinstance(incoming, dict):
662
+ continue
663
+ merged = dict(docs[doc_name])
664
+ for key, value in incoming.items():
665
+ merged[key] = value
666
+ if json.dumps(merged, sort_keys=True) != json.dumps(docs[doc_name], sort_keys=True):
667
+ docs[doc_name] = merged
668
+ if doc_name not in changed_docs:
669
+ changed_docs.append(doc_name)
670
+
671
+
672
+ def build_source_doc_mutation_request(
673
+ *,
674
+ project_id: str,
675
+ idea_id: str,
676
+ repo_root: Path,
677
+ raw_text: str,
678
+ grounded_raw_text: str,
679
+ mode: str | None = None,
680
+ agent_runtime_mode: str | None = None,
681
+ history_grounding_ref: str | None = None,
682
+ existing_source_doc_refs: list[str] | None = None,
683
+ explicit_source_doc_payload: dict[str, Any] | None = None,
684
+ message_id: str | None = None,
685
+ session_id: str | None = None,
686
+ invoked_from: str = "idea_context_resolution",
687
+ pipeline_key: str | None = None,
688
+ ) -> dict[str, Any]:
689
+ effective_mode = mode
690
+ if effective_mode is None:
691
+ effective_mode = "ideation_mutation" if (raw_text or grounded_raw_text or explicit_source_doc_payload) else "repo_bootstrap"
692
+ event = SourceDocMutationDagEvent(
693
+ project_id=project_id,
694
+ idea_id=idea_id,
695
+ repo_root=str(repo_root),
696
+ raw_text=raw_text,
697
+ grounded_raw_text=grounded_raw_text,
698
+ mode=effective_mode,
699
+ agent_runtime_mode=agent_runtime_mode,
700
+ history_grounding_ref=history_grounding_ref,
701
+ existing_source_doc_refs=existing_source_doc_refs or [],
702
+ explicit_source_doc_payload=explicit_source_doc_payload,
703
+ message_id=message_id,
704
+ session_id=session_id,
705
+ invoked_from=invoked_from,
706
+ pipeline_key=pipeline_key,
707
+ )
708
+ payload = event.model_dump()
709
+ if not payload.get("message_id"):
710
+ payload["message_id"] = _stable_id(
711
+ "msg_",
712
+ {"idea_id": idea_id, "raw_text": raw_text, "mode": payload.get("mode")},
713
+ size=16,
714
+ )
715
+ if not payload.get("pipeline_key"):
716
+ payload["pipeline_key"] = _stable_id(
717
+ "sdm_",
718
+ {
719
+ "idea_id": idea_id,
720
+ "project_id": project_id,
721
+ "raw_text": raw_text,
722
+ "grounded_raw_text": grounded_raw_text,
723
+ "mode": payload.get("mode"),
724
+ },
725
+ size=16,
726
+ )
727
+ return payload
728
+
729
+
730
+ class SourceDocSectionArtifact(BaseModel):
731
+ explicit_facts: list[str] = []
732
+ assumed_facts: list[str] = []
733
+ rationale: list[str] = []
734
+ confidence_notes: list[str] = []
735
+ section_payload: dict[str, Any]
736
+ assumption_entries: list[dict[str, Any]] = []
737
+ cross_section_dependencies: list[str] = []
738
+ mode: str = "unknown"
739
+
740
+
741
+ class SourceDocCoherenceArtifact(BaseModel):
742
+ docs: dict[str, dict[str, Any]]
743
+ assumptions: list[dict[str, Any]]
744
+ notes: list[str] = []
745
+ mode: str = "unknown"
746
+
747
+
748
+ class ProjectDocCoherenceArtifact(BaseModel):
749
+ notes: list[str] = []
750
+ mode: str = "unknown"
751
+
752
+ @field_validator("notes", mode="before")
753
+ @classmethod
754
+ def _normalize_notes(cls, value: Any) -> list[str]:
755
+ if value is None:
756
+ return []
757
+ if not isinstance(value, list):
758
+ value = [value]
759
+ out: list[str] = []
760
+ for item in value:
761
+ if isinstance(item, str):
762
+ text = item.strip()
763
+ elif isinstance(item, dict):
764
+ text = str(item.get("note") or item.get("summary") or item.get("detail") or json.dumps(item, sort_keys=True))
765
+ else:
766
+ text = str(item)
767
+ text = text.strip()
768
+ if text:
769
+ out.append(text)
770
+ return out
771
+
772
+
773
+ def _resolve_agent_runtime_mode(payload: dict[str, Any]) -> str:
774
+ requested = str(payload.get("agent_runtime_mode") or os.environ.get("DEVFLOW_SOURCE_DOC_AGENT_MODE") or "").strip().lower()
775
+ if requested:
776
+ if requested not in {"real", "stub"}:
777
+ raise RuntimeError(f"Unsupported source doc agent runtime mode: {requested!r}. Use 'real' or 'stub'.")
778
+ return requested
779
+ if os.environ.get("PYTEST_CURRENT_TEST"):
780
+ return "stub"
781
+ return "real"
782
+
783
+
784
+ def _record_agent_run(*, task_context: TaskContext, node_name: str, stage_name: str, runtime_mode: str, response_mode: str, prompt: dict[str, Any] | None = None, response: dict[str, Any] | None = None, raw_stdout: str = "", raw_stderr: str = "", error: str | None = None) -> None:
785
+ runs = task_context.metadata.setdefault("agent_runs", [])
786
+ runs.append({
787
+ "node_name": node_name,
788
+ "stage_name": stage_name,
789
+ "runtime_mode": runtime_mode,
790
+ "response_mode": response_mode,
791
+ "prompt": prompt,
792
+ "response": response,
793
+ "raw_stdout": raw_stdout,
794
+ "raw_stderr": raw_stderr,
795
+ "error": error,
796
+ })
797
+
798
+
799
+ def _mark_changed(changed_docs: list[str], name: str) -> None:
800
+ if name not in changed_docs:
801
+ changed_docs.append(name)
802
+
803
+
804
+ def _contains_any(text: str, tokens: tuple[str, ...]) -> bool:
805
+ lowered = text.lower()
806
+ return any(token in lowered for token in tokens)
807
+
808
+
809
+ def _infer_out_of_scope(raw_text: str) -> list[str]:
810
+ lowered = raw_text.lower()
811
+ exclusions: list[str] = []
812
+ if "task" in lowered:
813
+ exclusions.append("advanced workforce planning beyond task assignment and tracking")
814
+ if "dashboard" in lowered or "app" in lowered or "platform" in lowered:
815
+ exclusions.append("fully custom reporting outside the core workflow views")
816
+ return list(dict.fromkeys(exclusions))
817
+
818
+
819
+ def _infer_non_goals(raw_text: str) -> list[str]:
820
+ lowered = raw_text.lower()
821
+ non_goals: list[str] = []
822
+ if "task" in lowered:
823
+ non_goals.append("replace every surrounding back-office process in the first release")
824
+ if "staff" in lowered or "team" in lowered or "operator" in lowered:
825
+ non_goals.append("optimize for external customer self-service in the first release")
826
+ return list(dict.fromkeys(non_goals))
827
+
828
+
829
+ def _infer_constraints(raw_text: str) -> list[str]:
830
+ lowered = raw_text.lower()
831
+ constraints: list[str] = []
832
+ if "staff" in lowered or "team" in lowered or "operator" in lowered:
833
+ constraints.append("Permissions should keep assignment and status changes visible only to the relevant internal team roles.")
834
+ if "dashboard" in lowered:
835
+ constraints.append("The initial experience should fit a lightweight dashboard-first workflow rather than a complex multi-surface suite.")
836
+ if "task" in lowered:
837
+ constraints.append("The first release should keep the data model simple enough for dependable task creation, assignment, and status tracking.")
838
+ return list(dict.fromkeys(constraints))
839
+
840
+
841
+ def _infer_success_criteria(raw_text: str) -> list[str]:
842
+ lowered = raw_text.lower()
843
+ criteria: list[str] = []
844
+ if "task" in lowered:
845
+ criteria.append("Staff can create a task, assign an owner, and update status without leaving the product.")
846
+ if "assign" in lowered:
847
+ criteria.append("Managers can see who owns each task and which work is unassigned or overdue.")
848
+ if "dashboard" in lowered or "app" in lowered or "platform" in lowered:
849
+ criteria.append("The team can review the current work queue from a shared dashboard view.")
850
+ return list(dict.fromkeys(criteria))
851
+
852
+
853
+ def _derive_primary_actor(users: list[str], raw_text: str) -> str:
854
+ if users:
855
+ return users[0]
856
+ lowered = raw_text.lower()
857
+ if "staff" in lowered:
858
+ return "staff"
859
+ return "internal operators"
860
+
861
+
862
+ def _infer_primary_workflows(raw_text: str, users: list[str]) -> list[dict[str, Any]]:
863
+ lowered = raw_text.lower()
864
+ actor = _derive_primary_actor(users, raw_text)
865
+ workflows: list[dict[str, Any]] = []
866
+ if "task" in lowered:
867
+ workflows.append({
868
+ "workflow_key": "wf_capture_task",
869
+ "actor": actor,
870
+ "summary": "Capture a new task with enough context for the team to act on it.",
871
+ "trigger": "New work or follow-up request is identified.",
872
+ "steps": [
873
+ "Open the task app and create a new task record.",
874
+ "Add the core task details, priority, and any due-date context.",
875
+ "Save the task so it becomes visible to the team queue.",
876
+ ],
877
+ "result": "A trackable task exists in the shared system.",
878
+ "status": "draft",
879
+ })
880
+ if ("task" in lowered or "assignment" in lowered or "assign" in lowered) and _contains_any(lowered, ("assign", "staff", "team", "operator")):
881
+ workflows.append({
882
+ "workflow_key": "wf_assign_and_track_task",
883
+ "actor": actor,
884
+ "summary": "Assign a task to the right staff member and track progress to completion.",
885
+ "trigger": "A task needs an owner or updated status.",
886
+ "steps": [
887
+ "Review the open task queue or dashboard.",
888
+ "Assign the task to a staff owner based on responsibility or availability.",
889
+ "Update task status as work starts, pauses, or completes.",
890
+ "Review overdue or blocked work and reassign if needed.",
891
+ ],
892
+ "result": "Each active task has a visible owner and current status.",
893
+ "status": "draft",
894
+ })
895
+ return workflows
896
+
897
+
898
+ def _infer_alternate_flows(raw_text: str, users: list[str]) -> list[dict[str, Any]]:
899
+ lowered = raw_text.lower()
900
+ actor = _derive_primary_actor(users, raw_text)
901
+ alternates: list[dict[str, Any]] = []
902
+ if "task" in lowered and _contains_any(lowered, ("assign", "staff", "team", "operator")):
903
+ alternates.append({
904
+ "workflow_key": "wf_reassign_task",
905
+ "actor": actor,
906
+ "summary": "Reassign work when the original owner is unavailable or the task was routed incorrectly.",
907
+ "status": "draft",
908
+ })
909
+ if "due date" in lowered or "deadline" in lowered or "task" in lowered:
910
+ alternates.append({
911
+ "workflow_key": "wf_handle_overdue_task",
912
+ "actor": actor,
913
+ "summary": "Escalate or reprioritize overdue tasks so they do not disappear from the team queue.",
914
+ "status": "draft",
915
+ })
916
+ return alternates
917
+
918
+
919
+ def _infer_edge_cases(raw_text: str) -> list[str]:
920
+ lowered = raw_text.lower()
921
+ edge_cases: list[str] = []
922
+ if "task" in lowered:
923
+ edge_cases.extend([
924
+ "Tasks may be created without enough detail to assign confidently on the first pass.",
925
+ "Tasks can become blocked or overdue and need an escalation path.",
926
+ ])
927
+ if "assign" in lowered or "staff" in lowered or "team" in lowered:
928
+ edge_cases.append("A task may need reassignment when the initial owner is unavailable or the wrong role picked it up.")
929
+ return list(dict.fromkeys(edge_cases))
930
+
931
+
932
+ def _infer_entity_bundle(raw_text: str, users: list[str]) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[str]]:
933
+ lowered = raw_text.lower()
934
+ entities: list[dict[str, Any]] = []
935
+ relationships: list[dict[str, Any]] = []
936
+ ownership: list[str] = []
937
+ primary_actor = _derive_primary_actor(users, raw_text)
938
+ if "task" in lowered:
939
+ entities.append({
940
+ "entity_key": "task",
941
+ "label": "Task",
942
+ "purpose": "Represents a unit of work the team needs to capture, assign, and track through completion.",
943
+ "attributes": [
944
+ {"name": "title", "type": "string", "required": True, "notes": "Short operator-facing summary."},
945
+ {"name": "details", "type": "text", "required": False, "notes": "Working description or handoff context."},
946
+ {"name": "status", "type": "enum", "required": True, "notes": "Compact lifecycle state for visibility and reporting."},
947
+ {"name": "priority", "type": "enum", "required": False, "notes": "Simple urgency cue for triage."},
948
+ {"name": "due_date", "type": "datetime", "required": False, "notes": "Deadline used for queue ordering and overdue handling."},
949
+ ],
950
+ "integrity_rules": [
951
+ "A task must have a title before it can be saved.",
952
+ "A completed task should retain a terminal status until intentionally reopened.",
953
+ ],
954
+ "business_rules": [
955
+ "Each active task has at most one current assignee in the first release.",
956
+ "Tasks may exist unassigned immediately after capture.",
957
+ ],
958
+ "authority_rules": [
959
+ "Task content and assignment changes are managed by internal team members, not external users.",
960
+ ],
961
+ "lifecycle": ["created", "assigned", "in_progress", "completed"],
962
+ "workflow_links": ["wf_capture_task", "wf_assign_and_track_task"],
963
+ "unresolved_questions": [
964
+ "Whether blocked/cancelled states are needed beyond the compact default lifecycle.",
965
+ ],
966
+ "status": "draft",
967
+ })
968
+ if ("task" in lowered or "assignment" in lowered or "assign" in lowered) and _contains_any(lowered, ("assign", "staff", "team", "operator")):
969
+ entities.append({
970
+ "entity_key": "staff_member",
971
+ "label": "Staff Member",
972
+ "purpose": "Represents an internal person who can own work and update task progress.",
973
+ "attributes": [
974
+ {"name": "display_name", "type": "string", "required": True, "notes": "Human-readable owner name."},
975
+ {"name": "role", "type": "string", "required": False, "notes": "Informational role/persona label until permissions are clarified."},
976
+ {"name": "availability_state", "type": "enum", "required": False, "notes": "Optional signal for assignment decisions."},
977
+ ],
978
+ "integrity_rules": [
979
+ "An assignee reference should resolve to one active internal staff record.",
980
+ ],
981
+ "business_rules": [
982
+ "Staff members can own many tasks over time.",
983
+ ],
984
+ "authority_rules": [
985
+ "Staff identity data is maintained by the internal operating team or its delegated manager.",
986
+ ],
987
+ "workflow_links": ["wf_assign_and_track_task"],
988
+ "unresolved_questions": [
989
+ "Whether roles remain informational or become permission-bearing in v1.",
990
+ ],
991
+ "status": "draft",
992
+ })
993
+ ownership.append(f"{primary_actor} or a related team lead is assumed to control assignment decisions until role permissions are clarified.")
994
+ if "dashboard" in lowered or "queue" in lowered:
995
+ entities.append({
996
+ "entity_key": "task_queue_view",
997
+ "label": "Task Queue View",
998
+ "purpose": "Represents the shared operational view of open and in-progress work rather than a separate business record.",
999
+ "entity_kind": "derived_view",
1000
+ "attributes": [
1001
+ {"name": "filters", "type": "object", "required": False, "notes": "Simple filtering for status, owner, or urgency."},
1002
+ {"name": "sort_order", "type": "enum", "required": False, "notes": "Queue ordering such as due date or priority."},
1003
+ {"name": "status_groups", "type": "list", "required": False, "notes": "Compact groupings for dashboard visibility."},
1004
+ ],
1005
+ "integrity_rules": [
1006
+ "The queue view is derived from task records and should not become the source of truth for task state.",
1007
+ ],
1008
+ "business_rules": [
1009
+ "The initial release assumes one shared queue view is sufficient for the team.",
1010
+ ],
1011
+ "authority_rules": [
1012
+ "View configuration may be adjustable, but task records remain authoritative for workflow state.",
1013
+ ],
1014
+ "workflow_links": ["wf_assign_and_track_task"],
1015
+ "unresolved_questions": [
1016
+ "Whether multiple team-specific queue views are needed after the first release.",
1017
+ ],
1018
+ "status": "draft",
1019
+ })
1020
+ entity_keys = {item["entity_key"] for item in entities}
1021
+ if {"task", "staff_member"}.issubset(entity_keys):
1022
+ relationships.append({
1023
+ "relationship_key": "task_current_assignee",
1024
+ "relationship_type": "assignment",
1025
+ "from_entity": "task",
1026
+ "to_entity": "staff_member",
1027
+ "cardinality": "many_to_one",
1028
+ "summary": "Each active task may reference one current owner, while a staff member can own many tasks.",
1029
+ "integrity_rules": [
1030
+ "A task cannot reference more than one current assignee at the same time.",
1031
+ ],
1032
+ "business_rules": [
1033
+ "Assignment may change over time without creating a new task record.",
1034
+ ],
1035
+ "authority_rules": [
1036
+ "Assignment changes are controlled by internal operators or managers until a stricter RBAC model is confirmed.",
1037
+ ],
1038
+ "unresolved_questions": [
1039
+ "Whether reassignment history needs its own event log in the first release.",
1040
+ ],
1041
+ "status": "draft",
1042
+ })
1043
+ if {"task", "task_queue_view"}.issubset(entity_keys):
1044
+ relationships.append({
1045
+ "relationship_key": "task_visible_in_queue_view",
1046
+ "relationship_type": "projection",
1047
+ "from_entity": "task",
1048
+ "to_entity": "task_queue_view",
1049
+ "cardinality": "many_to_many",
1050
+ "summary": "Open and in-progress tasks appear in the shared queue/dashboard used for triage and tracking.",
1051
+ "integrity_rules": [
1052
+ "Queue membership is derived from task state rather than stored as an independent authoritative record.",
1053
+ ],
1054
+ "business_rules": [
1055
+ "Closed tasks may drop out of the default queue while remaining searchable elsewhere.",
1056
+ ],
1057
+ "authority_rules": [
1058
+ "Queue visibility rules should follow task visibility and team permissions.",
1059
+ ],
1060
+ "unresolved_questions": [
1061
+ "Whether completed work stays visible by default or moves to a historical view.",
1062
+ ],
1063
+ "status": "draft",
1064
+ })
1065
+ if not ownership and entities:
1066
+ ownership.append("One internal team is assumed to own task and staff assignment records until a fuller authority model is confirmed.")
1067
+ return entities, relationships, ownership
1068
+
1069
+
1070
+ def _enrich_sparse_source_docs(*, grounded_text: str, docs: dict[str, dict[str, Any]], assumptions: list[dict[str, Any]], payload: dict[str, Any], requested_mode: str) -> tuple[dict[str, dict[str, Any]], list[dict[str, Any]], list[str]]:
1071
+ if requested_mode != "ideation_mutation":
1072
+ return docs, assumptions, []
1073
+ notes: list[str] = []
1074
+ product = docs["product_brief.json"]
1075
+ workflows = docs["user_workflows.json"]
1076
+ entities = docs["domain_entities.json"]
1077
+ if not isinstance(product.get("scope"), dict):
1078
+ product["scope"] = {}
1079
+ users = _dedupe_str_list(list(product.get("target_users") or []) + list(workflows.get("actors") or []) + _infer_users(grounded_text))
1080
+ if not product.get("target_users") and users:
1081
+ product["target_users"] = users
1082
+ notes.append("Aligned target users from workflow actors/inferred sparse intake roles.")
1083
+ if not workflows.get("actors") and users:
1084
+ workflows["actors"] = users
1085
+ notes.append("Filled workflow actors from the reconciled target-user set.")
1086
+ product["scope"]["out_of_scope"] = _dedupe_str_list(list((product.get("scope") or {}).get("out_of_scope") or []) + _infer_out_of_scope(grounded_text))
1087
+ product["non_goals"] = _dedupe_str_list(list(product.get("non_goals") or []) + _infer_non_goals(grounded_text))
1088
+ product["constraints"] = _dedupe_str_list(list(product.get("constraints") or []) + _infer_constraints(grounded_text))
1089
+ product["success_criteria"] = _dedupe_str_list(list(product.get("success_criteria") or []) + _infer_success_criteria(grounded_text))
1090
+ primary_workflows = list(workflows.get("primary_workflows") or [])
1091
+ if not primary_workflows:
1092
+ workflows["primary_workflows"] = _infer_primary_workflows(grounded_text, users)
1093
+ if workflows["primary_workflows"]:
1094
+ notes.append("Added compact primary workflows so sparse source docs still preserve trigger/steps/result shape.")
1095
+ workflows["alternate_flows"] = list(workflows.get("alternate_flows") or [])
1096
+ if not workflows["alternate_flows"]:
1097
+ workflows["alternate_flows"] = _infer_alternate_flows(grounded_text, users)
1098
+ workflows["edge_cases"] = _dedupe_str_list(list(workflows.get("edge_cases") or []) + _infer_edge_cases(grounded_text))
1099
+ workflow_assumptions = _dedupe_str_list(list(workflows.get("workflow_assumptions") or []))
1100
+ if not workflow_assumptions and workflows["primary_workflows"] and _contains_any(grounded_text, ("task", "assign", "workflow", "queue")):
1101
+ workflow_assumptions.append("The initial workflow model assumes one primary work-capture path with only the most likely operational branches filled in.")
1102
+ _record_assumption(assumptions=assumptions, doc_path="ai_docs/context/source_docs/user_workflows.json", section_key="workflow_assumptions", topic_key="default_primary_workflow", current_value=workflow_assumptions[0], reasoning="Sparse intake still implies at least one end-to-end operational path.", source="grounded_inference", message_id=str(payload["message_id"]))
1103
+ if workflows["alternate_flows"] and workflow_assumptions and len(workflow_assumptions) == 1:
1104
+ workflow_assumptions.append("Alternate flows stay limited to reassignment and overdue handling until more workflow policy is known.")
1105
+ _record_assumption(assumptions=assumptions, doc_path="ai_docs/context/source_docs/user_workflows.json", section_key="workflow_assumptions", topic_key="sparse_workflow_policy", current_value="Alternate flows are limited to reassignment and overdue handling until clarified.", reasoning="The request implies task operations but not a full exception catalog.", source="grounded_inference", message_id=str(payload["message_id"]))
1106
+ workflows["workflow_assumptions"] = workflow_assumptions
1107
+ inferred_entities, inferred_relationships, inferred_ownership = _infer_entity_bundle(grounded_text, users)
1108
+ entities_were_sparse = not bool(list(entities.get("entities") or []))
1109
+ if entities_were_sparse:
1110
+ entities["entities"] = inferred_entities
1111
+ if inferred_entities:
1112
+ notes.append("Inferred compact domain entities from sparse workflow verbs and actor roles.")
1113
+ if not entities.get("relationships"):
1114
+ entities["relationships"] = inferred_relationships
1115
+ if inferred_relationships:
1116
+ notes.append("Added entity relationships needed to keep workflows and architecture grounding coherent.")
1117
+ entities["ownership_assumptions"] = _dedupe_str_list(list(entities.get("ownership_assumptions") or []) + inferred_ownership)
1118
+ if inferred_entities and entities_were_sparse:
1119
+ _record_assumption(assumptions=assumptions, doc_path="ai_docs/context/source_docs/domain_entities.json", section_key="ownership_assumptions", topic_key="entity_authority", current_value=entities["ownership_assumptions"][0] if entities["ownership_assumptions"] else "One internal team maintains task and assignment state until record authority is clarified.", reasoning="Sparse intake implies ownership for task and assignment data, but not the final authority model.", source="grounded_inference", message_id=str(payload["message_id"]))
1120
+ return docs, assumptions, notes
1121
+
1122
+
1123
+ def _heuristic_section_payloads(*, grounded_text: str, docs: dict[str, dict[str, Any]], assumptions: list[dict[str, Any]], payload: dict[str, Any], requested_mode: str, effective_mode: str) -> dict[str, SourceDocSectionArtifact]:
1124
+ base_product = dict(docs["product_brief.json"])
1125
+ base_workflows = dict(docs["user_workflows.json"])
1126
+ base_entities = dict(docs["domain_entities.json"])
1127
+ product_assumptions = _dedupe_str_list(list(base_product.get("assumptions") or []))
1128
+ workflow_assumptions = _dedupe_str_list(list(base_workflows.get("workflow_assumptions") or []))
1129
+ ownership_assumptions = _dedupe_str_list(list(base_entities.get("ownership_assumptions") or []))
1130
+ if effective_mode != "scaffold_only":
1131
+ sufficient_idea = extract_sufficient_idea(grounded_text)
1132
+ users = _dedupe_str_list(list(base_product.get("target_users") or []) + list(sufficient_idea.get("target_users") or []) + _infer_users(grounded_text))
1133
+ product_summary = _derive_product_summary(grounded_text, sufficient_idea)
1134
+ if product_summary:
1135
+ base_product["product_summary"] = product_summary
1136
+ problem = str(sufficient_idea.get("problem") or _infer_problem(grounded_text) or base_product.get("problem_statement") or "").strip()
1137
+ if problem:
1138
+ base_product["problem_statement"] = problem
1139
+ elif requested_mode == "ideation_mutation":
1140
+ inferred_problem = "The team is handling work through an unclear or manual process, making ownership and progress hard to track."
1141
+ base_product["problem_statement"] = inferred_problem
1142
+ product_assumptions.append("The current workflow likely relies on manual tracking or fragmented tools.")
1143
+ _record_assumption(assumptions=assumptions, doc_path="ai_docs/context/source_docs/product_brief.json", section_key="problem_statement", topic_key="primary_problem", current_value=inferred_problem, reasoning="The intake asks for software support but does not spell out the current-state pain in detail.", source="grounded_inference", message_id=str(payload["message_id"]))
1144
+ base_product["target_users"] = users
1145
+ base_workflows["actors"] = _dedupe_str_list(list(base_workflows.get("actors") or []) + users)
1146
+ goals = _dedupe_str_list(list(base_product.get("goals") or []) + [sufficient_idea.get("goal") or _infer_goal(grounded_text)])
1147
+ if not goals and requested_mode == "ideation_mutation" and _contains_any(grounded_text, ("task", "assign", "workflow", "queue", "dashboard")):
1148
+ goals = ["Create a lightweight shared workflow so the team can capture, assign, and complete work reliably."]
1149
+ product_assumptions.append("The first release should improve day-to-day operational visibility more than advanced analytics.")
1150
+ _record_assumption(assumptions=assumptions, doc_path="ai_docs/context/source_docs/product_brief.json", section_key="goals", topic_key="primary_goal", current_value=goals[0], reasoning="Sparse software requests still imply an operational outcome even when the target metric is unstated.", source="grounded_inference", message_id=str(payload["message_id"]))
1151
+ base_product["goals"] = goals
1152
+ scope = dict(base_product.get("scope") or {"in_scope": [], "out_of_scope": []})
1153
+ scope["in_scope"] = _dedupe_str_list(list(scope.get("in_scope") or []) + list(sufficient_idea.get("scope") or []) + _infer_scope_bullets(grounded_text))
1154
+ base_product["scope"] = scope
1155
+ if requested_mode == "ideation_mutation":
1156
+ if not users:
1157
+ _record_assumption(assumptions=assumptions, doc_path="ai_docs/context/source_docs/product_brief.json", section_key="target_users", topic_key="primary_users", current_value=["internal operators"], reasoning="Software-delivery intake typically targets an operator or requester cohort even when the exact role is missing.", source="grounded_inference", message_id=str(payload["message_id"]))
1158
+ product_assumptions.append("Primary users are likely internal operators until clarified.")
1159
+ if not scope["in_scope"]:
1160
+ _record_assumption(assumptions=assumptions, doc_path="ai_docs/context/source_docs/product_brief.json", section_key="scope", topic_key="initial_scope", current_value=["first build should focus on the smallest workflow that resolves the request"], reasoning="The message asks for software work but does not yet define a narrow first release scope.", source="grounded_inference", message_id=str(payload["message_id"]))
1161
+ product_assumptions.append("Initial scope should stay tightly limited until the first release boundaries are confirmed.")
1162
+ base_product["scope"]["out_of_scope"] = _dedupe_str_list(list(scope.get("out_of_scope") or []) + _infer_out_of_scope(grounded_text))
1163
+ base_product["non_goals"] = _dedupe_str_list(list(base_product.get("non_goals") or []) + _infer_non_goals(grounded_text))
1164
+ base_product["constraints"] = _dedupe_str_list(list(base_product.get("constraints") or []) + list(sufficient_idea.get("constraints") or []) + _infer_constraints(grounded_text))
1165
+ base_product["success_criteria"] = _dedupe_str_list(list(base_product.get("success_criteria") or []) + list(sufficient_idea.get("acceptance_criteria") or []) + _infer_success_criteria(grounded_text))
1166
+ base_product["assumptions"] = _dedupe_str_list(product_assumptions)
1167
+
1168
+ inferred_primary_workflows = _infer_primary_workflows(grounded_text, users)
1169
+ inferred_alternates = _infer_alternate_flows(grounded_text, users)
1170
+ had_primary_workflows = bool(list(base_workflows.get("primary_workflows") or []))
1171
+ base_workflows["primary_workflows"] = list(base_workflows.get("primary_workflows") or []) or inferred_primary_workflows
1172
+ base_workflows["alternate_flows"] = list(base_workflows.get("alternate_flows") or []) or inferred_alternates
1173
+ base_workflows["edge_cases"] = _dedupe_str_list(list(base_workflows.get("edge_cases") or []) + _infer_edge_cases(grounded_text))
1174
+ if (not had_primary_workflows) and inferred_primary_workflows and not workflow_assumptions and requested_mode == "ideation_mutation":
1175
+ workflow_assumptions.append("The first release centers on one capture-and-assignment workflow with only the most important exception paths filled in.")
1176
+ _record_assumption(assumptions=assumptions, doc_path="ai_docs/context/source_docs/user_workflows.json", section_key="workflow_assumptions", topic_key="sparse_workflow_model", current_value=workflow_assumptions[0], reasoning="Sparse intake names the problem shape but not the complete operating playbook.", source="grounded_inference", message_id=str(payload["message_id"]))
1177
+ base_workflows["workflow_assumptions"] = _dedupe_str_list(workflow_assumptions)
1178
+
1179
+ inferred_entities, inferred_relationships, inferred_ownership = _infer_entity_bundle(grounded_text, users)
1180
+ base_entities["entities"] = list(base_entities.get("entities") or []) or inferred_entities
1181
+ base_entities["relationships"] = list(base_entities.get("relationships") or []) or inferred_relationships
1182
+ ownership_assumptions = _dedupe_str_list(ownership_assumptions + inferred_ownership)
1183
+ base_entities["ownership_assumptions"] = ownership_assumptions
1184
+ if inferred_entities and requested_mode == "ideation_mutation":
1185
+ _record_assumption(assumptions=assumptions, doc_path="ai_docs/context/source_docs/domain_entities.json", section_key="ownership_assumptions", topic_key="entity_authority", current_value=ownership_assumptions[0] if ownership_assumptions else "One internal team maintains task and assignment state until record authority is clarified.", reasoning="Sparse intake implies a compact operating model but does not define final data authority boundaries.", source="grounded_inference", message_id=str(payload["message_id"]))
1186
+ return {
1187
+ "product_brief": SourceDocSectionArtifact(section_payload=base_product, explicit_facts=[f for f in [base_product.get("product_summary"), base_product.get("problem_statement")] if f], assumed_facts=list(base_product.get("assumptions") or []), rationale=["Populate the product brief to a canonically sufficient planning shape even when intake is sparse."], confidence_notes=["Sparse-input heuristics keep the brief compact and assumption-aware rather than pseudo-PRD level."], mode="heuristic"),
1188
+ "user_workflows": SourceDocSectionArtifact(section_payload=base_workflows, explicit_facts=list(base_workflows.get("actors") or []), assumed_facts=list(base_workflows.get("workflow_assumptions") or []), rationale=["Preserve at least one end-to-end workflow shape so downstream PRD and UX docs have grounded flow structure."], confidence_notes=["Workflow depth is intentionally compact and should not expand into full story decomposition here."], mode="heuristic"),
1189
+ "domain_entities": SourceDocSectionArtifact(section_payload=base_entities, explicit_facts=[str(item.get("label") or item.get("entity_key") or "") for item in list(base_entities.get("entities") or []) if isinstance(item, dict)], assumed_facts=list(base_entities.get("ownership_assumptions") or []), rationale=["Infer only the minimum business objects and relationships required to keep sparse workflows coherent."], confidence_notes=["Entity inference stays compact and avoids turning source docs into pseudo-architecture documents."], mode="heuristic"),
1190
+ }
1191
+
1192
+
1193
+ def _run_source_doc_agent(*, repo_root: Path, node_name: str, stage_name: str, output_model: type[BaseModel], context_payload: dict[str, Any], guidance: list[str], fallback: BaseModel, runtime_mode: str, task_context: TaskContext) -> tuple[BaseModel, str]:
1194
+ if runtime_mode == "stub":
1195
+ _record_agent_run(task_context=task_context, node_name=node_name, stage_name=stage_name, runtime_mode=runtime_mode, response_mode="test_stub", response=fallback.model_dump() if hasattr(fallback, "model_dump") else None)
1196
+ return fallback, "test_stub"
1197
+ model, envelope = run_agent_step(repo_root=repo_root, stage_name=f"source_doc_mutation_{stage_name}", output_model=output_model, context_payload=context_payload, guidance=guidance)
1198
+ _record_agent_run(task_context=task_context, node_name=node_name, stage_name=stage_name, runtime_mode=runtime_mode, response_mode="agent", prompt=envelope.prompt, response=envelope.response, raw_stdout=envelope.raw_stdout, raw_stderr=envelope.raw_stderr)
1199
+ return model, "agent"
1200
+
1201
+
1202
+ def _normalize_text_tokens(text: str) -> list[str]:
1203
+ return re.findall(r"[a-z0-9_]+", text.lower())
1204
+
1205
+
1206
+ _STOPWORDS = {
1207
+ "the", "and", "for", "with", "that", "this", "from", "into", "your", "their", "then", "than", "when", "where", "while", "must", "should", "would", "could", "have", "has", "had", "was", "were", "are", "our", "out", "use", "using", "used", "one", "two", "three", "same", "each", "only", "also", "does", "did", "done", "not", "but", "can", "all", "any", "per", "via", "its", "it's", "it", "a", "an", "of", "to", "in", "on", "by", "or", "as", "at", "be", "is",
1208
+ }
1209
+
1210
+
1211
+ def _content_keyword_set(value: Any) -> set[str]:
1212
+ if isinstance(value, dict):
1213
+ out: set[str] = set()
1214
+ for key, item in value.items():
1215
+ out.update(_content_keyword_set(key))
1216
+ out.update(_content_keyword_set(item))
1217
+ return out
1218
+ if isinstance(value, list):
1219
+ out: set[str] = set()
1220
+ for item in value:
1221
+ out.update(_content_keyword_set(item))
1222
+ return out
1223
+ if not isinstance(value, str):
1224
+ return set()
1225
+ return {token for token in _normalize_text_tokens(value) if len(token) >= 4 and token not in _STOPWORDS}
1226
+
1227
+
1228
+ def _project_doc_grounding_issues(*, project_doc_name: str, content: str, target_payload: dict[str, Any]) -> list[str]:
1229
+ if project_doc_name != "prd.md":
1230
+ return []
1231
+ canonical_source_docs = dict(target_payload.get("canonical_source_docs") or {})
1232
+ existing_project_doc = dict(target_payload.get("existing_project_doc") or {})
1233
+ output_keywords = _content_keyword_set(content)
1234
+ source_keywords = _content_keyword_set(canonical_source_docs)
1235
+ baseline_keywords = _content_keyword_set(str(existing_project_doc.get("content") or ""))
1236
+ actor_keywords = _content_keyword_set((canonical_source_docs.get("user_workflows.json") or {}).get("actors") or [])
1237
+ entity_keywords = _content_keyword_set((canonical_source_docs.get("domain_entities.json") or {}).get("entities") or [])
1238
+ workflow_keywords = _content_keyword_set((canonical_source_docs.get("user_workflows.json") or {}).get("primary_workflows") or [])
1239
+ required_keywords = {token for token in actor_keywords | entity_keywords | workflow_keywords if len(token) >= 4}
1240
+ issues: list[str] = []
1241
+ if required_keywords and not (output_keywords & required_keywords):
1242
+ issues.append("PRD lost all grounded actor/entity/workflow anchors from the canonical source docs.")
1243
+ baseline_anchor_keywords = {token for token in baseline_keywords if token in source_keywords and len(token) >= 4}
1244
+ if baseline_anchor_keywords and len(output_keywords & baseline_anchor_keywords) < min(3, len(baseline_anchor_keywords)):
1245
+ issues.append("PRD does not preserve enough baseline product identity anchors from the existing PRD/source-doc overlap.")
1246
+ product_summary_keywords = _content_keyword_set(str((canonical_source_docs.get("product_brief.json") or {}).get("product_summary") or ""))
1247
+ if product_summary_keywords and not (output_keywords & product_summary_keywords):
1248
+ issues.append("PRD is not recognizably grounded in the current product summary.")
1249
+ return issues
1250
+
1251
+
1252
+ def _run_project_doc_subagent_cli(*, repo_root: str, output_path: str, project_doc_name: str, target_payload: dict[str, Any], shell_only: bool, runtime_mode: str) -> None:
1253
+ path = Path(output_path)
1254
+ canonical_source_docs = dict(target_payload.get("canonical_source_docs") or {})
1255
+ supporting_source_docs = list(target_payload.get("supporting_source_docs") or [])
1256
+ primary_source_docs = list(target_payload.get("primary_source_docs") or [])
1257
+ reference_docs = dict(target_payload.get("reference_docs") or {})
1258
+ direct_impacts = list(target_payload.get("directly_impacted_by_changed_docs") or [])
1259
+ existing_project_doc = dict(target_payload.get("existing_project_doc") or {})
1260
+ if runtime_mode == "stub":
1261
+ rendered = "\n".join([
1262
+ f"# {project_doc_name.replace('_', ' ').replace('.md', '').title()}",
1263
+ "",
1264
+ f"Grounded in: {', '.join(supporting_source_docs) or 'none'}.",
1265
+ f"Primary sources: {', '.join(primary_source_docs) or 'none'}.",
1266
+ f"Direct impacts: {', '.join(direct_impacts) or 'none'}.",
1267
+ f"Existing baseline doc loaded: {'yes' if existing_project_doc.get('loaded') else 'no'}.",
1268
+ "",
1269
+ "## Grounding Notes",
1270
+ "",
1271
+ "- Project-doc richness and cross-reference contracts were supplied.",
1272
+ "- This stub output stays thin by design; richer prose requires the real agent runtime.",
1273
+ "",
1274
+ "## Canonical Source Snapshot",
1275
+ "",
1276
+ "```json",
1277
+ json.dumps(canonical_source_docs, indent=2, sort_keys=True),
1278
+ "```",
1279
+ "",
1280
+ ])
1281
+ _write_json(path, {"project_doc_name": project_doc_name, "status": "completed", "mode": "test_stub_subagent", "runtime_mode": runtime_mode, "content": rendered, "source_doc_dependencies": supporting_source_docs, "primary_source_docs": primary_source_docs, "impact_rationale": direct_impacts, "reference_docs_supplied": {key: value.get('path') for key, value in reference_docs.items() if isinstance(value, dict)}, "confidence_notes": ["Stub runtime cannot demonstrate full richness/depth."]})
1282
+ return
1283
+ class _ProjectDocArtifact(BaseModel):
1284
+ content: str
1285
+ source_doc_dependencies: list[str] = []
1286
+ assumptions_used: list[str] = []
1287
+ impact_rationale: list[str] = []
1288
+ confidence_notes: list[str] = []
1289
+
1290
+ base_guidance = load_agentic_prompt_lines("source_doc_mutation_project_doc_render")
1291
+ attempts: list[dict[str, Any]] = []
1292
+ last_model = None
1293
+ last_envelope = None
1294
+ for attempt in range(2):
1295
+ guidance = list(base_guidance)
1296
+ if attempt == 1 and attempts:
1297
+ guidance.append("Your previous draft failed grounding validation. Repair it instead of changing products.")
1298
+ guidance.extend([f"Validation failure: {issue}" for issue in attempts[-1].get("issues") or []])
1299
+ model, envelope = run_agent_step(repo_root=Path(repo_root), stage_name=f"project_doc_mutation_{project_doc_name.replace('.', '_')}", output_model=_ProjectDocArtifact, context_payload={"project_doc_name": project_doc_name, "target_payload": target_payload, "shell_only": shell_only}, guidance=guidance)
1300
+ issues = _project_doc_grounding_issues(project_doc_name=project_doc_name, content=model.content, target_payload=target_payload)
1301
+ attempts.append({"attempt": attempt + 1, "issues": issues, "prompt": envelope.prompt, "response": envelope.response})
1302
+ last_model = model
1303
+ last_envelope = envelope
1304
+ if not issues:
1305
+ break
1306
+ final_issues = _project_doc_grounding_issues(project_doc_name=project_doc_name, content=last_model.content if last_model else "", target_payload=target_payload)
1307
+ status = "completed" if not final_issues else "failed_validation"
1308
+ _write_json(path, {"project_doc_name": project_doc_name, "status": status, "mode": "agent_subprocess", "runtime_mode": runtime_mode, "content": last_model.content if last_model else "", "source_doc_dependencies": last_model.source_doc_dependencies if last_model else [], "assumptions_used": last_model.assumptions_used if last_model else [], "impact_rationale": last_model.impact_rationale if last_model else [], "confidence_notes": (last_model.confidence_notes if last_model else []) + ([f"Grounding validation failed: {issue}" for issue in final_issues] if final_issues else []), "primary_source_docs": primary_source_docs, "reference_docs_supplied": {key: value.get('path') for key, value in reference_docs.items() if isinstance(value, dict)}, "existing_project_doc_supplied": bool(existing_project_doc.get("loaded")), "validation_issues": final_issues, "attempts": attempts, "prompt": last_envelope.prompt if last_envelope else {}, "response": last_envelope.response if last_envelope else {}, "raw_stdout": last_envelope.raw_stdout if last_envelope else "", "raw_stderr": last_envelope.raw_stderr if last_envelope else ""})
1309
+
1310
+
1311
+ class NormalizeAndGatherSupportContextNode(Node):
1312
+ async def process(self, task_context: TaskContext) -> TaskContext:
1313
+ payload = build_source_doc_mutation_request(project_id=str(task_context.event.project_id or ""), idea_id=str(task_context.event.idea_id or ""), repo_root=Path(str(task_context.event.repo_root or "")), raw_text=str(task_context.event.raw_text or ""), grounded_raw_text=str(task_context.event.grounded_raw_text or task_context.event.raw_text or ""), mode=str(task_context.event.mode or "").strip() or None, history_grounding_ref=task_context.event.history_grounding_ref, existing_source_doc_refs=list(task_context.event.existing_source_doc_refs or []), explicit_source_doc_payload=task_context.event.explicit_source_doc_payload, message_id=task_context.event.message_id, session_id=task_context.event.session_id, invoked_from=str(task_context.event.invoked_from or "idea_context_resolution"), pipeline_key=task_context.event.pipeline_key)
1314
+ repo_root = Path(payload["repo_root"]).expanduser().resolve()
1315
+ pipeline_key = str(payload["pipeline_key"])
1316
+ pipeline_dir = _pipeline_root(repo_root, idea_id=str(payload["idea_id"]), pipeline_key=pipeline_key)
1317
+ pipeline_dir.mkdir(parents=True, exist_ok=True)
1318
+ run_id = f"{DAG_ID}:{pipeline_key}"
1319
+ docs = _load_source_doc_state(repo_root)
1320
+ source_docs_before = _clone(docs)
1321
+ registry_before = [item for item in list((docs.get("assumptions_registry.json") or {}).get("assumptions") or []) if isinstance(item, dict)]
1322
+ repo_inventory = _repo_bootstrap_inventory(repo_root)
1323
+ requested_mode = str(payload.get("mode") or "ideation_mutation")
1324
+ agent_runtime_mode = _resolve_agent_runtime_mode(payload)
1325
+ grounded_text = str(payload.get("grounded_raw_text") or payload.get("raw_text") or "")
1326
+ repo_grounding_text, repo_grounding_refs = ("", [])
1327
+ effective_mode = requested_mode
1328
+ trust_level = "source_docs_mutated"
1329
+ mutation_origin = "message_grounded"
1330
+ plan_notes: list[str] = []
1331
+ shell_only = False
1332
+ if requested_mode == "repo_bootstrap":
1333
+ repo_grounding_text, repo_grounding_refs = _pick_repo_grounding_text(repo_root)
1334
+ if repo_inventory["has_genuine_signal"] and repo_grounding_text:
1335
+ grounded_text = repo_grounding_text
1336
+ mutation_origin = "repo_grounded_bootstrap"
1337
+ plan_notes.append("Repo evidence cleared bootstrap threshold; grounding source docs from repo files.")
1338
+ else:
1339
+ effective_mode = "scaffold_only"
1340
+ trust_level = "no_source_doc_changes"
1341
+ mutation_origin = "scaffold_only_fallback"
1342
+ shell_only = True
1343
+ plan_notes.append("Repo evidence too sparse for grounded bootstrap; emitting scaffold-only docs and derived shells.")
1344
+ inventory = {"requested_mode": requested_mode, "repo_root": str(repo_root), "canonical_source_docs_root": str(_source_docs_dir(repo_root)), "derived_project_docs_root": str(_v2_project_docs_dir(repo_root)), "existing_source_doc_refs": list(payload.get("existing_source_doc_refs") or []), "source_docs_before": source_docs_before, "preexisting_nonempty_docs": sorted([name for name, doc in source_docs_before.items() if isinstance(doc, dict) and any(_has_meaningful_value(v) for v in doc.values())]), "repo_inventory": repo_inventory}
1345
+ changed_docs: list[str] = []
1346
+ _apply_explicit_source_doc_payload(docs=docs, explicit_payload=payload.get("explicit_source_doc_payload") if isinstance(payload.get("explicit_source_doc_payload"), dict) else {}, changed_docs=changed_docs)
1347
+ project_doc_reference_payload = _project_doc_reference_payload(repo_root)
1348
+ support_indexes = _support_indexes_payload(repo_root)
1349
+ _write_json(pipeline_dir / "source_doc_mutation_request.json", payload)
1350
+ task_context.metadata.update({"payload": payload, "repo_root": repo_root, "pipeline_key": pipeline_key, "pipeline_dir": pipeline_dir, "run_id": run_id, "docs": docs, "source_docs_before": source_docs_before, "registry_before": registry_before, "assumptions": [item for item in registry_before if isinstance(item, dict)], "changed_docs": changed_docs, "inventory": inventory, "repo_inventory": repo_inventory, "requested_mode": requested_mode, "agent_runtime_mode": agent_runtime_mode, "grounded_text": grounded_text, "raw_text": str(payload.get("raw_text") or ""), "effective_mode": effective_mode, "repo_grounding_refs": repo_grounding_refs, "trust_level": trust_level, "plan_notes": plan_notes, "mutation_origin": mutation_origin, "shell_only": shell_only, "agent_runs": [], "project_doc_reference_payload": project_doc_reference_payload, "support_indexes": support_indexes})
1351
+ task_context.update_node(self.node_name, node_type="deterministic", run_id=run_id, effective_mode=effective_mode, agent_runtime_mode=agent_runtime_mode, pipeline_dir=str(pipeline_dir))
1352
+ return task_context
1353
+
1354
+
1355
+ class ProductBriefSectionAgentNode(AgentNode):
1356
+ def get_agent_config(self) -> AgentConfig:
1357
+ return AgentConfig(instructions="Mutate the product brief section from grounded evidence.", output_type=SourceDocSectionArtifact)
1358
+ async def process(self, task_context: TaskContext) -> TaskContext:
1359
+ repo_root = task_context.metadata["repo_root"]
1360
+ fallback = _heuristic_section_payloads(grounded_text=task_context.metadata["grounded_text"], docs=_clone(task_context.metadata["docs"]), assumptions=list(task_context.metadata["assumptions"]), payload=task_context.metadata["payload"], requested_mode=task_context.metadata["requested_mode"], effective_mode=task_context.metadata["effective_mode"])["product_brief"]
1361
+ artifact, mode = _run_source_doc_agent(repo_root=repo_root, node_name=self.node_name, stage_name="product_brief", output_model=SourceDocSectionArtifact, context_payload={"grounded_text": task_context.metadata["grounded_text"], "current_doc": task_context.metadata["docs"]["product_brief.json"], "deterministic_hint": fallback.model_dump()}, guidance=load_agentic_prompt_lines("source_doc_mutation_product_brief"), fallback=fallback, runtime_mode=task_context.metadata["agent_runtime_mode"], task_context=task_context)
1362
+ task_context.metadata["product_brief_section"] = SourceDocSectionArtifact.model_validate(artifact.model_dump() if hasattr(artifact, 'model_dump') else artifact)
1363
+ task_context.update_node(self.node_name, node_type="agentic", response_mode=mode)
1364
+ return task_context
1365
+
1366
+
1367
+ class UserWorkflowsSectionAgentNode(AgentNode):
1368
+ def get_agent_config(self) -> AgentConfig:
1369
+ return AgentConfig(instructions="Mutate the user workflows section from grounded evidence.", output_type=SourceDocSectionArtifact)
1370
+ async def process(self, task_context: TaskContext) -> TaskContext:
1371
+ repo_root = task_context.metadata["repo_root"]
1372
+ fallback = _heuristic_section_payloads(grounded_text=task_context.metadata["grounded_text"], docs=_clone(task_context.metadata["docs"]), assumptions=list(task_context.metadata["assumptions"]), payload=task_context.metadata["payload"], requested_mode=task_context.metadata["requested_mode"], effective_mode=task_context.metadata["effective_mode"])["user_workflows"]
1373
+ artifact, mode = _run_source_doc_agent(repo_root=repo_root, node_name=self.node_name, stage_name="user_workflows", output_model=SourceDocSectionArtifact, context_payload={"grounded_text": task_context.metadata["grounded_text"], "current_doc": task_context.metadata["docs"]["user_workflows.json"], "deterministic_hint": fallback.model_dump()}, guidance=load_agentic_prompt_lines("source_doc_mutation_user_workflows"), fallback=fallback, runtime_mode=task_context.metadata["agent_runtime_mode"], task_context=task_context)
1374
+ task_context.metadata["user_workflows_section"] = SourceDocSectionArtifact.model_validate(artifact.model_dump() if hasattr(artifact, 'model_dump') else artifact)
1375
+ task_context.update_node(self.node_name, node_type="agentic", response_mode=mode)
1376
+ return task_context
1377
+
1378
+
1379
+ class DomainEntitiesSectionAgentNode(AgentNode):
1380
+ def get_agent_config(self) -> AgentConfig:
1381
+ return AgentConfig(instructions="Mutate the domain entities section from grounded evidence.", output_type=SourceDocSectionArtifact)
1382
+ async def process(self, task_context: TaskContext) -> TaskContext:
1383
+ repo_root = task_context.metadata["repo_root"]
1384
+ fallback = _heuristic_section_payloads(grounded_text=task_context.metadata["grounded_text"], docs=_clone(task_context.metadata["docs"]), assumptions=list(task_context.metadata["assumptions"]), payload=task_context.metadata["payload"], requested_mode=task_context.metadata["requested_mode"], effective_mode=task_context.metadata["effective_mode"])["domain_entities"]
1385
+ artifact, mode = _run_source_doc_agent(repo_root=repo_root, node_name=self.node_name, stage_name="domain_entities", output_model=SourceDocSectionArtifact, context_payload={"grounded_text": task_context.metadata["grounded_text"], "current_doc": task_context.metadata["docs"]["domain_entities.json"], "deterministic_hint": fallback.model_dump()}, guidance=load_agentic_prompt_lines("source_doc_mutation_domain_entities"), fallback=fallback, runtime_mode=task_context.metadata["agent_runtime_mode"], task_context=task_context)
1386
+ task_context.metadata["domain_entities_section"] = SourceDocSectionArtifact.model_validate(artifact.model_dump() if hasattr(artifact, 'model_dump') else artifact)
1387
+ task_context.update_node(self.node_name, node_type="agentic", response_mode=mode)
1388
+ return task_context
1389
+
1390
+
1391
+ class SourceSectionConcurrentNode(ConcurrentNode):
1392
+ async def process(self, task_context: TaskContext) -> TaskContext:
1393
+ await self.execute_nodes_concurrently(task_context)
1394
+ task_context.update_node(self.node_name, node_type="concurrent", children=["ProductBriefSectionAgentNode", "UserWorkflowsSectionAgentNode", "DomainEntitiesSectionAgentNode"])
1395
+ return task_context
1396
+
1397
+
1398
+ class SourceDocCoherenceNode(AgentNode):
1399
+ def get_agent_config(self) -> AgentConfig:
1400
+ return AgentConfig(instructions="Reconcile section outputs into a coherent source-doc set.", output_type=SourceDocCoherenceArtifact)
1401
+ async def process(self, task_context: TaskContext) -> TaskContext:
1402
+ docs = task_context.metadata["docs"]
1403
+ changed_docs = task_context.metadata["changed_docs"]
1404
+ assumptions = list(task_context.metadata["assumptions"])
1405
+ payload = task_context.metadata["payload"]
1406
+ for key, doc_name in [("product_brief_section", "product_brief.json"), ("user_workflows_section", "user_workflows.json"), ("domain_entities_section", "domain_entities.json")]:
1407
+ section = task_context.metadata[key]
1408
+ if section.section_payload != docs[doc_name]:
1409
+ docs[doc_name] = section.section_payload
1410
+ _mark_changed(changed_docs, doc_name)
1411
+ assumptions.extend([item for item in section.assumption_entries if isinstance(item, dict)])
1412
+ if task_context.metadata["requested_mode"] == "ideation_mutation":
1413
+ product = docs["product_brief.json"]
1414
+ if not str(product.get("problem_statement") or "").strip():
1415
+ _record_assumption(assumptions=assumptions, doc_path="ai_docs/context/source_docs/product_brief.json", section_key="problem_statement", topic_key="primary_problem", current_value="User needs a better software-supported workflow, but the exact pain point is still unconfirmed.", reasoning="The intake requests software work but does not yet name the concrete pain clearly.", source="grounded_inference", message_id=str(payload["message_id"]))
1416
+ if not list((product.get("scope") or {}).get("in_scope") or []):
1417
+ _record_assumption(assumptions=assumptions, doc_path="ai_docs/context/source_docs/product_brief.json", section_key="scope", topic_key="initial_scope", current_value=["first build should focus on the smallest workflow that resolves the request"], reasoning="The message asks for software work but does not yet define a narrow first release scope.", source="grounded_inference", message_id=str(payload["message_id"]))
1418
+ docs["assumptions_registry.json"] = {"assumptions": assumptions}
1419
+ coherence_fallback = SourceDocCoherenceArtifact(docs={name: _clone(docs[name]) for name in CANONICAL_SOURCE_DOCS}, assumptions=[item for item in assumptions if isinstance(item, dict)], notes=["Deterministic source-doc coherence reconciliation."], mode="heuristic")
1420
+ coherence_artifact, mode = _run_source_doc_agent(repo_root=task_context.metadata["repo_root"], node_name=self.node_name, stage_name="source_doc_coherence", output_model=SourceDocCoherenceArtifact, context_payload={"grounded_text": task_context.metadata["grounded_text"], "docs": {name: docs[name] for name in CANONICAL_SOURCE_DOCS}, "assumptions": assumptions, "changed_docs": changed_docs}, guidance=load_agentic_prompt_lines("source_doc_mutation_source_doc_coherence"), fallback=coherence_fallback, runtime_mode=task_context.metadata["agent_runtime_mode"], task_context=task_context)
1421
+ docs.update({name: value for name, value in coherence_artifact.docs.items() if name in CANONICAL_SOURCE_DOCS and isinstance(value, dict)})
1422
+ docs["assumptions_registry.json"] = {"assumptions": [item for item in coherence_artifact.assumptions if isinstance(item, dict)]}
1423
+ reconciled_registry, registry_changed = reconcile_assumptions_registry(registry_payload=docs["assumptions_registry.json"], source_docs_by_name=docs, changed_docs=changed_docs)
1424
+ docs["assumptions_registry.json"] = reconciled_registry
1425
+ task_context.metadata["assumptions"] = [item for item in list(reconciled_registry.get("assumptions") or []) if isinstance(item, dict)]
1426
+ if registry_changed:
1427
+ _mark_changed(changed_docs, "assumptions_registry.json")
1428
+ task_context.update_node(self.node_name, node_type="agentic", response_mode=mode, changed_docs=list(changed_docs), assumptions_total=len(task_context.metadata["assumptions"]))
1429
+ return task_context
1430
+
1431
+
1432
+ class SourceDocEnrichmentCoherenceNode(AgentNode):
1433
+ def get_agent_config(self) -> AgentConfig:
1434
+ return AgentConfig(instructions="Perform a final sparse-safe source-doc enrichment and coherence pass before project-doc sync.", output_type=SourceDocCoherenceArtifact)
1435
+ async def process(self, task_context: TaskContext) -> TaskContext:
1436
+ docs = task_context.metadata["docs"]
1437
+ assumptions = list(task_context.metadata["assumptions"])
1438
+ changed_docs = task_context.metadata["changed_docs"]
1439
+ enriched_docs, enriched_assumptions, enrichment_notes = _enrich_sparse_source_docs(grounded_text=task_context.metadata["grounded_text"], docs={name: _clone(docs[name]) for name in CANONICAL_SOURCE_DOCS}, assumptions=assumptions, payload=task_context.metadata["payload"], requested_mode=task_context.metadata["requested_mode"])
1440
+ fallback = SourceDocCoherenceArtifact(docs=enriched_docs, assumptions=[item for item in enriched_assumptions if isinstance(item, dict)], notes=enrichment_notes + ["Deterministic pre-sync source-doc enrichment/coherence pass."], mode="heuristic")
1441
+ artifact, mode = _run_source_doc_agent(repo_root=task_context.metadata["repo_root"], node_name=self.node_name, stage_name="source_doc_enrichment_coherence", output_model=SourceDocCoherenceArtifact, context_payload={"grounded_text": task_context.metadata["grounded_text"], "docs": {name: docs[name] for name in CANONICAL_SOURCE_DOCS}, "assumptions": assumptions, "support_indexes": task_context.metadata.get("support_indexes") or {}, "changed_docs": changed_docs}, guidance=load_agentic_prompt_lines("source_doc_mutation_source_doc_enrichment_coherence"), fallback=fallback, runtime_mode=task_context.metadata["agent_runtime_mode"], task_context=task_context)
1442
+ notes = list(getattr(artifact, "notes", []) or [])
1443
+ before_docs = {name: json.dumps(docs.get(name, {}), sort_keys=True) for name in CANONICAL_SOURCE_DOCS}
1444
+ docs.update({name: value for name, value in artifact.docs.items() if name in CANONICAL_SOURCE_DOCS and isinstance(value, dict)})
1445
+ docs["assumptions_registry.json"] = {"assumptions": [item for item in artifact.assumptions if isinstance(item, dict)]}
1446
+ task_context.metadata["assumptions"] = [item for item in list(docs["assumptions_registry.json"].get("assumptions") or []) if isinstance(item, dict)]
1447
+ for name in CANONICAL_SOURCE_DOCS:
1448
+ if before_docs[name] != json.dumps(docs.get(name, {}), sort_keys=True):
1449
+ _mark_changed(changed_docs, name)
1450
+ task_context.metadata["source_doc_enrichment_notes"] = notes
1451
+ task_context.update_node(self.node_name, node_type="agentic", response_mode=mode, notes=notes, changed_docs=list(changed_docs))
1452
+ return task_context
1453
+
1454
+
1455
+ class PersistSourceDocsAndAssumptionsNode(Node):
1456
+ async def process(self, task_context: TaskContext) -> TaskContext:
1457
+ repo_root = task_context.metadata["repo_root"]
1458
+ docs = task_context.metadata["docs"]
1459
+ changed_docs = task_context.metadata["changed_docs"]
1460
+ source_docs_dir = _source_docs_dir(repo_root)
1461
+ source_docs_dir.mkdir(parents=True, exist_ok=True)
1462
+ docs_to_write = list(CANONICAL_SOURCE_DOCS) if task_context.metadata["shell_only"] else list(dict.fromkeys(changed_docs + ["assumptions_registry.json"]))
1463
+ for name in docs_to_write:
1464
+ _write_json(source_docs_dir / name, docs[name])
1465
+ _mark_changed(changed_docs, "assumptions_registry.json")
1466
+ assumptions_delta = _compute_assumptions_delta(task_context.metadata["registry_before"], task_context.metadata["assumptions"])
1467
+ changed_sections = _compute_changed_sections(before_docs=task_context.metadata["source_docs_before"], after_docs=docs, changed_docs=changed_docs)
1468
+ task_context.metadata.update({"source_docs_dir": source_docs_dir, "assumptions_delta": assumptions_delta, "changed_sections": changed_sections})
1469
+ task_context.update_node(self.node_name, node_type="deterministic", docs_to_write=docs_to_write)
1470
+ return task_context
1471
+
1472
+
1473
+ class ProjectDocImpactRoutingNode(Node):
1474
+ async def process(self, task_context: TaskContext) -> TaskContext:
1475
+ changed_docs = list(task_context.metadata["changed_docs"])
1476
+ changed_sections = list(task_context.metadata.get("changed_sections") or [])
1477
+ support_indexes = task_context.metadata.get("support_indexes") or {}
1478
+ routing_targets = _project_doc_target_payloads(repo_root=task_context.metadata["repo_root"], docs=task_context.metadata["docs"], changed_docs=changed_docs, changed_sections=changed_sections, support_indexes=support_indexes)
1479
+ impacted = [item["project_doc_name"] for item in routing_targets if item["directly_impacted_by_changed_docs"]]
1480
+ routing_payload = {
1481
+ "classification": "support_docs",
1482
+ "reference_map_doc": PROJECT_DOC_REFERENCE_DOCS["reference_map"],
1483
+ "support_indexes": {name: {"path": value.get("path"), "classification": value.get("classification"), "loaded": value.get("loaded"), "contract": value.get("contract")} for name, value in (support_indexes.get("indexes") or {}).items()},
1484
+ "changed_docs": changed_docs,
1485
+ "changed_sections": changed_sections,
1486
+ "project_doc_targets": [
1487
+ {
1488
+ "project_doc_name": item["project_doc_name"],
1489
+ "primary_source_docs": item["primary_source_docs"],
1490
+ "supporting_source_docs": item["supporting_source_docs"],
1491
+ "directly_impacted_by_changed_docs": item["directly_impacted_by_changed_docs"],
1492
+ "routed_changed_sections": item["routed_changed_sections"],
1493
+ }
1494
+ for item in routing_targets
1495
+ ],
1496
+ }
1497
+ task_context.metadata["project_doc_target_payloads"] = routing_targets
1498
+ task_context.metadata["project_doc_impact_routing"] = routing_payload
1499
+ task_context.metadata["impacted_project_docs"] = impacted
1500
+ task_context.update_node(self.node_name, node_type="deterministic", impacted_project_docs=impacted, routing_targets=[item["project_doc_name"] for item in routing_targets])
1501
+ return task_context
1502
+
1503
+
1504
+ class SpawnProjectDocMutationSubagentsNode(Node):
1505
+ async def process(self, task_context: TaskContext) -> TaskContext:
1506
+ repo_root = task_context.metadata["repo_root"]
1507
+ pipeline_dir = task_context.metadata["pipeline_dir"]
1508
+ shell_only = task_context.metadata["shell_only"]
1509
+ reference_payload = task_context.metadata.get("project_doc_reference_payload") or {}
1510
+ subagents: list[dict[str, Any]] = []
1511
+ for item in task_context.metadata.get("project_doc_target_payloads") or []:
1512
+ md_name = item["project_doc_name"]
1513
+ output_path = pipeline_dir / f"subagent_{md_name.replace('.', '_')}.json"
1514
+ target_payload = dict(item)
1515
+ target_payload["reference_docs"] = reference_payload
1516
+ target_payload["support_indexes"] = task_context.metadata.get("support_indexes") or {}
1517
+ cmd = [sys.executable, "-c", f"from devflow_engine.source_doc_mutation_dag import _run_project_doc_subagent_cli; _run_project_doc_subagent_cli(repo_root={str(repo_root)!r}, output_path={str(output_path)!r}, project_doc_name={md_name!r}, target_payload={target_payload!r}, shell_only={shell_only!r}, runtime_mode={task_context.metadata['agent_runtime_mode']!r})"]
1518
+ proc = subprocess.Popen(cmd, cwd=str(repo_root))
1519
+ subagents.append({"project_doc_name": md_name, "pid": proc.pid, "output_path": str(output_path), "primary_source_docs": item.get("primary_source_docs") or [], "supporting_source_docs": item.get("supporting_source_docs") or [], "directly_impacted_by_changed_docs": item.get("directly_impacted_by_changed_docs") or [], "reference_docs_supplied": [value.get("path") for value in reference_payload.values() if isinstance(value, dict) and value.get("path")]})
1520
+ task_context.metadata["project_doc_subagents"] = subagents
1521
+ task_context.update_node(self.node_name, node_type="deterministic", spawned_subagents=subagents)
1522
+ return task_context
1523
+
1524
+
1525
+ class AwaitProjectDocMutationSubagentsNode(Node):
1526
+ async def process(self, task_context: TaskContext) -> TaskContext:
1527
+ results: list[dict[str, Any]] = []
1528
+ for item in task_context.metadata.get("project_doc_subagents") or []:
1529
+ pid = int(item["pid"])
1530
+ try:
1531
+ os.waitpid(pid, 0)
1532
+ except ChildProcessError:
1533
+ pass
1534
+ payload = _load_json_dict(Path(item["output_path"]), default={"project_doc_name": item["project_doc_name"], "status": "missing"})
1535
+ results.append(payload)
1536
+ task_context.metadata["project_doc_subagent_results"] = results
1537
+ task_context.update_node(self.node_name, node_type="deterministic", awaited=len(results))
1538
+ return task_context
1539
+
1540
+
1541
+ class ProjectDocCoherenceNode(AgentNode):
1542
+ def get_agent_config(self) -> AgentConfig:
1543
+ return AgentConfig(instructions="Review the full project-doc family for coherence against canonical source docs and project-doc support contracts.", output_type=ProjectDocCoherenceArtifact)
1544
+ async def process(self, task_context: TaskContext) -> TaskContext:
1545
+ results = task_context.metadata.get("project_doc_subagent_results") or []
1546
+ notes = [f"{item.get('project_doc_name')}: {item.get('mode')}" for item in results]
1547
+ fallback = ProjectDocCoherenceArtifact(notes=notes + ["Fallback coherence output is thin; richer coherence findings require the real agent runtime."], mode="heuristic")
1548
+ artifact, mode = _run_source_doc_agent(repo_root=task_context.metadata["repo_root"], node_name=self.node_name, stage_name="project_doc_coherence", output_model=ProjectDocCoherenceArtifact, context_payload={"canonical_source_docs": {name: task_context.metadata["docs"].get(name, {}) for name in CANONICAL_SOURCE_DOCS}, "project_doc_results": results, "project_doc_impact_routing": task_context.metadata.get("project_doc_impact_routing") or {}, "project_doc_reference_docs": task_context.metadata.get("project_doc_reference_payload") or {}}, guidance=load_agentic_prompt_lines("source_doc_mutation_project_doc_coherence"), fallback=fallback, runtime_mode=task_context.metadata["agent_runtime_mode"], task_context=task_context)
1549
+ task_context.metadata["project_doc_coherence"] = artifact.model_dump() if hasattr(artifact, "model_dump") else artifact
1550
+ task_context.update_node(self.node_name, node_type="agentic", response_mode=mode, notes=artifact.notes)
1551
+ return task_context
1552
+
1553
+
1554
+ class PersistProjectDocsNode(Node):
1555
+ async def process(self, task_context: TaskContext) -> TaskContext:
1556
+ payload = task_context.metadata["payload"]
1557
+ repo_root = task_context.metadata["repo_root"]
1558
+ pipeline_dir = task_context.metadata["pipeline_dir"]
1559
+ run_id = task_context.metadata["run_id"]
1560
+ source_docs_dir = task_context.metadata["source_docs_dir"]
1561
+ project_docs_root = _v2_project_docs_dir(repo_root) / "source_docs"
1562
+ project_docs_root.mkdir(parents=True, exist_ok=True)
1563
+ updated_outputs: list[str] = []
1564
+ for item in task_context.metadata.get("project_doc_subagent_results") or []:
1565
+ md_name = str(item.get("project_doc_name") or "")
1566
+ if not md_name:
1567
+ continue
1568
+ path = project_docs_root / md_name
1569
+ path.write_text(str(item.get("content") or _render_markdown(md_name.replace('.md', '.json'), task_context.metadata["docs"].get(md_name.replace('.md', '.json'), {}), shell_only=task_context.metadata["shell_only"])), encoding="utf-8")
1570
+ updated_outputs.append(str(path.relative_to(repo_root)))
1571
+ index_path = project_docs_root / "index.md"
1572
+ index_intro = "Explicit project-doc subagent outputs derived from canonical source docs."
1573
+ if task_context.metadata["shell_only"]:
1574
+ index_intro = "This directory contains scaffold-only derived project doc shells. Repo evidence was too sparse to infer a grounded project shape."
1575
+ index_lines = ["# Project Doc Family Index", "", index_intro, ""]
1576
+ for md_name in PROJECT_DOC_TARGETS:
1577
+ index_lines.append(f"- `source_docs/{md_name}`")
1578
+ index_path.write_text("\n".join(index_lines) + "\n", encoding="utf-8")
1579
+ updated_outputs.append(str(index_path.relative_to(repo_root)))
1580
+ project_doc_sync_result = {"status": "completed", "derived_project_docs_root": str(_v2_project_docs_dir(repo_root)), "derived_source_doc_root": str(project_docs_root), "updated_outputs": updated_outputs, "changed_docs": task_context.metadata["changed_docs"], "impacted_project_docs": task_context.metadata.get("impacted_project_docs") or [], "sync_mode": "subagent_project_doc_render", "shell_only": task_context.metadata["shell_only"], "agent_runtime_mode": task_context.metadata["agent_runtime_mode"], "project_doc_coherence": task_context.metadata.get("project_doc_coherence") or {}, "source_doc_enrichment_notes": task_context.metadata.get("source_doc_enrichment_notes") or [], "project_doc_impact_routing": task_context.metadata.get("project_doc_impact_routing") or {}, "reference_docs_supplied": {key: value.get("path") for key, value in (task_context.metadata.get("project_doc_reference_payload") or {}).items() if isinstance(value, dict)}, "support_indexes": {name: {"path": value.get("path"), "classification": value.get("classification"), "loaded": value.get("loaded"), "contract": value.get("contract")} for name, value in ((task_context.metadata.get("support_indexes") or {}).get("indexes") or {}).items()}, "subagent_results": task_context.metadata.get("project_doc_subagent_results") or []}
1581
+ assumptions = task_context.metadata["assumptions"]
1582
+ changed_docs = task_context.metadata["changed_docs"]
1583
+ mutation_plan = {"requested_mode": task_context.metadata["requested_mode"], "effective_mode": task_context.metadata["effective_mode"], "agent_runtime_mode": task_context.metadata["agent_runtime_mode"], "mutation_origin": task_context.metadata["mutation_origin"], "trust_level": task_context.metadata["trust_level"], "repo_grounding_refs": task_context.metadata["repo_grounding_refs"], "plan_notes": task_context.metadata["plan_notes"], "change_intent": {name: ("no_op_shell" if task_context.metadata["shell_only"] else "mutate" if name in changed_docs else "no_op") for name in CANONICAL_SOURCE_DOCS}, "decision_rule": task_context.metadata["repo_inventory"].get("decision_rule"), "project_doc_reference_docs": {key: value.get("path") for key, value in (task_context.metadata.get("project_doc_reference_payload") or {}).items() if isinstance(value, dict)}, "support_indexes": {name: value.get("path") for name, value in ((task_context.metadata.get("support_indexes") or {}).get("indexes") or {}).items()}, "project_doc_target_family": list(PROJECT_DOC_TARGETS), "project_doc_impact_routing": task_context.metadata.get("project_doc_impact_routing") or {}, "node_contracts": {"NormalizeAndGatherSupportContextNode": "deterministic request normalization, inventory, and support context assembly", "ProductBriefSectionAgentNode": "agentic source section mutation", "UserWorkflowsSectionAgentNode": "agentic source section mutation", "DomainEntitiesSectionAgentNode": "agentic source section mutation", "SourceDocCoherenceNode": "agentic coherence pass over section outputs", "SourceDocEnrichmentCoherenceNode": "agentic final sparse-safe source-doc enrichment/coherence pass before project-doc sync", "PersistSourceDocsAndAssumptionsNode": "deterministic canonical source-doc persistence", "ProjectDocImpactRoutingNode": "deterministic project-doc routing using source-to-project-doc reference contracts", "SpawnProjectDocMutationSubagentsNode": "deterministic orchestration that spawns project-doc mutation subagents with explicit reference-doc grounding", "AwaitProjectDocMutationSubagentsNode": "deterministic join waiting for all project-doc mutation subagents", "ProjectDocCoherenceNode": "agentic coherence pass over project-doc outputs using project-doc support contracts", "PersistProjectDocsNode": "deterministic artifact packaging and project-doc persistence"}}
1584
+ source_doc_change_set = {"canonical_source_docs_root": str(source_docs_dir), "changed_docs": changed_docs, "before": {name: task_context.metadata["source_docs_before"].get(name, {}) for name in changed_docs}, "after": {name: task_context.metadata["docs"].get(name, {}) for name in changed_docs}, "requested_mode": task_context.metadata["requested_mode"], "effective_mode": task_context.metadata["effective_mode"], "agent_runtime_mode": task_context.metadata["agent_runtime_mode"], "mutation_mode": "agentic_scaffold_only" if task_context.metadata["shell_only"] else "agentic_source_doc_mutation", "source_doc_enrichment_notes": task_context.metadata.get("source_doc_enrichment_notes") or [], "repo_grounding_refs": task_context.metadata["repo_grounding_refs"], "changed_sections": task_context.metadata.get("changed_sections") or []}
1585
+ result_payload = {"contract": DAG_ID, "run_id": run_id, "status": "completed", "artifact_root": str(pipeline_dir), "canonical_source_docs_root": str(source_docs_dir), "derived_project_docs_root": str(_v2_project_docs_dir(repo_root)), "result_artifact": "source_doc_mutation_result.json", "inventory_artifact": "source_doc_inventory.json", "plan_artifact": "source_doc_mutation_plan.json", "change_set_artifact": "source_doc_change_set.json", "assumptions_delta_artifact": "assumptions_delta.json", "project_doc_sync_artifact": "project_doc_sync_result.json", "agent_runs_artifact": "agent_runs.json", "changed_docs": changed_docs, "requested_mode": task_context.metadata["requested_mode"], "effective_mode": task_context.metadata["effective_mode"], "agent_runtime_mode": task_context.metadata["agent_runtime_mode"], "trust_level": task_context.metadata["trust_level"], "project_doc_target_family": list(PROJECT_DOC_TARGETS), "project_doc_reference_docs": {key: value.get("path") for key, value in (task_context.metadata.get("project_doc_reference_payload") or {}).items() if isinstance(value, dict)}, "support_indexes": {name: {"path": value.get("path"), "classification": value.get("classification"), "loaded": value.get("loaded"), "contract": value.get("contract")} for name, value in ((task_context.metadata.get("support_indexes") or {}).get("indexes") or {}).items()}, "assumption_status": {"open_count": len([item for item in assumptions if str(item.get("status") or "open") == "open"]), "total_count": len(assumptions)}, "dag_shape": {"workflow_schema": DAG_ID, "start_node": "NormalizeAndGatherSupportContextNode", "node_sequence": ["NormalizeAndGatherSupportContextNode", "SourceSectionConcurrentNode", "ProductBriefSectionAgentNode", "UserWorkflowsSectionAgentNode", "DomainEntitiesSectionAgentNode", "SourceDocCoherenceNode", "SourceDocEnrichmentCoherenceNode", "PersistSourceDocsAndAssumptionsNode", "ProjectDocImpactRoutingNode", "SpawnProjectDocMutationSubagentsNode", "AwaitProjectDocMutationSubagentsNode", "ProjectDocCoherenceNode", "PersistProjectDocsNode"], "deterministic_nodes": ["NormalizeAndGatherSupportContextNode", "PersistSourceDocsAndAssumptionsNode", "ProjectDocImpactRoutingNode", "SpawnProjectDocMutationSubagentsNode", "AwaitProjectDocMutationSubagentsNode", "PersistProjectDocsNode"], "agent_nodes": ["ProductBriefSectionAgentNode", "UserWorkflowsSectionAgentNode", "DomainEntitiesSectionAgentNode", "SourceDocCoherenceNode", "SourceDocEnrichmentCoherenceNode", "ProjectDocCoherenceNode"], "concurrent_section_nodes": ["ProductBriefSectionAgentNode", "UserWorkflowsSectionAgentNode", "DomainEntitiesSectionAgentNode"], "parallel_source_section_nodes": ["ProductBriefSectionAgentNode", "UserWorkflowsSectionAgentNode", "DomainEntitiesSectionAgentNode"], "project_doc_subagent_barrier": {"spawn_node": "SpawnProjectDocMutationSubagentsNode", "await_node": "AwaitProjectDocMutationSubagentsNode", "all_spawned_subagents_must_complete_before": "ProjectDocCoherenceNode"}, "future_agent_nodes": ["SourceDocSemanticMutationAgentNode"]}, "derivation": {"mutation_mode": "deterministic_scaffold_only" if task_context.metadata["shell_only"] else "deterministic_bootstrap_patch", "actual_runtime_mode": "agentic_scaffold_only" if task_context.metadata["shell_only"] else "agentic_source_doc_mutation", "writes_canonical_source_docs": True, "writes_derived_project_docs": True, "pretend_agentic": False, "repo_grounded": task_context.metadata["mutation_origin"] == "repo_grounded_bootstrap", "shell_only": task_context.metadata["shell_only"], "used_real_llm_runtime": task_context.metadata["agent_runtime_mode"] == "real" and not task_context.metadata["shell_only"]}, "repo_bootstrap_decision": {"requested_mode": task_context.metadata["requested_mode"], "effective_mode": task_context.metadata["effective_mode"], "repo_grounding_refs": task_context.metadata["repo_grounding_refs"], "signal_summary": task_context.metadata["repo_inventory"]}, "agent_runtime_summary": {"requested_mode": task_context.metadata["agent_runtime_mode"], "agent_run_count": len(task_context.metadata.get("agent_runs") or []), "response_modes": [item.get("response_mode") for item in task_context.metadata.get("agent_runs") or []]}, "gaps": ["SourceDocSemanticMutationAgentNode no longer owns the happy path; it has been replaced by explicit ProductBriefSectionAgentNode/UserWorkflowsSectionAgentNode/DomainEntitiesSectionAgentNode plus SourceDocCoherenceNode.", "Contradiction-specific routing/gates/artifacts are still not implemented in this DAG.", "planning summary/open questions/scope readiness artifacts are still not emitted by this DAG.", "Project-doc richness still depends on upstream source-doc breadth and the real LLM runtime; stub runs remain intentionally thin."]}
1586
+ _write_json(pipeline_dir / "source_doc_inventory.json", task_context.metadata["inventory"])
1587
+ _write_json(pipeline_dir / "source_doc_mutation_plan.json", mutation_plan)
1588
+ _write_json(pipeline_dir / "source_doc_change_set.json", source_doc_change_set)
1589
+ _write_json(pipeline_dir / "assumptions_delta.json", task_context.metadata["assumptions_delta"])
1590
+ _write_json(pipeline_dir / "project_doc_sync_result.json", project_doc_sync_result)
1591
+ _write_json(pipeline_dir / "agent_runs.json", {"agent_runtime_mode": task_context.metadata["agent_runtime_mode"], "runs": task_context.metadata.get("agent_runs") or []})
1592
+ _write_json(pipeline_dir / "source_doc_mutation_result.json", result_payload)
1593
+ mutation_ref = {"contract": "source_doc_mutation_dag.ref.v1", "dag_id": DAG_ID, "run_id": run_id, "status": result_payload["status"], "artifact_root": str(pipeline_dir), "result_artifact": result_payload["result_artifact"], "change_set_artifact": result_payload["change_set_artifact"], "assumptions_delta_artifact": result_payload["assumptions_delta_artifact"], "project_doc_sync_artifact": result_payload["project_doc_sync_artifact"], "agent_runs_artifact": result_payload["agent_runs_artifact"], "changed_docs": changed_docs, "trust_level": task_context.metadata["trust_level"], "agent_runtime_mode": task_context.metadata["agent_runtime_mode"]}
1594
+ task_context.metadata["final_result"] = SourceDocMutationDagResult(run_id=run_id, pipeline_dir=pipeline_dir, mutation_ref=mutation_ref, result=result_payload)
1595
+ task_context.update_node(self.node_name, node_type="deterministic", status="completed")
1596
+ return task_context
1597
+
1598
+
1599
+ class SourceDocMutationDagWorkflow(Workflow):
1600
+ workflow_schema = WorkflowSchema(description="Current source_doc_mutation_dag aligned to explicit source-section concurrency and project-doc subagent orchestration.", event_schema=SourceDocMutationDagEvent, start=NormalizeAndGatherSupportContextNode, nodes=[NodeConfig(node=NormalizeAndGatherSupportContextNode, connections=[SourceSectionConcurrentNode]), NodeConfig(node=SourceSectionConcurrentNode, connections=[SourceDocCoherenceNode], concurrent_nodes=[ProductBriefSectionAgentNode, UserWorkflowsSectionAgentNode, DomainEntitiesSectionAgentNode]), NodeConfig(node=SourceDocCoherenceNode, connections=[SourceDocEnrichmentCoherenceNode]), NodeConfig(node=SourceDocEnrichmentCoherenceNode, connections=[PersistSourceDocsAndAssumptionsNode]), NodeConfig(node=PersistSourceDocsAndAssumptionsNode, connections=[ProjectDocImpactRoutingNode]), NodeConfig(node=ProjectDocImpactRoutingNode, connections=[SpawnProjectDocMutationSubagentsNode]), NodeConfig(node=SpawnProjectDocMutationSubagentsNode, connections=[AwaitProjectDocMutationSubagentsNode]), NodeConfig(node=AwaitProjectDocMutationSubagentsNode, connections=[ProjectDocCoherenceNode]), NodeConfig(node=ProjectDocCoherenceNode, connections=[PersistProjectDocsNode]), NodeConfig(node=PersistProjectDocsNode, connections=[]), ])
1601
+
1602
+
1603
+ def run_source_doc_mutation_dag(*, request: dict[str, Any]) -> SourceDocMutationDagResult:
1604
+ workflow = SourceDocMutationDagWorkflow()
1605
+ task_context = workflow.run(request)
1606
+ return task_context.metadata["final_result"]