graphitect 0.2.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 (336) hide show
  1. graphify/__init__.py +30 -0
  2. graphify/__main__.py +757 -0
  3. graphify/_minhash.py +107 -0
  4. graphify/affected.py +318 -0
  5. graphify/always_on/agents-md.md +12 -0
  6. graphify/always_on/antigravity-rules.md +14 -0
  7. graphify/always_on/claude-md.md +9 -0
  8. graphify/always_on/gemini-md.md +9 -0
  9. graphify/always_on/kiro-steering.md +5 -0
  10. graphify/always_on/vscode-instructions.md +17 -0
  11. graphify/analyze.py +769 -0
  12. graphify/benchmark.py +152 -0
  13. graphify/build.py +2300 -0
  14. graphify/cache.py +1746 -0
  15. graphify/callflow_html.py +2051 -0
  16. graphify/cargo_introspect.py +109 -0
  17. graphify/cli.py +4745 -0
  18. graphify/cluster.py +409 -0
  19. graphify/command-kilo.md +15 -0
  20. graphify/cross_repo_calls.py +216 -0
  21. graphify/cross_repo_types.py +75 -0
  22. graphify/csharp_dispatch.py +154 -0
  23. graphify/dedup.py +1213 -0
  24. graphify/detect.py +2566 -0
  25. graphify/diagnostics.py +406 -0
  26. graphify/export.py +1349 -0
  27. graphify/exporters/__init__.py +1 -0
  28. graphify/exporters/base.py +14 -0
  29. graphify/exporters/graphdb.py +173 -0
  30. graphify/exporters/html.py +637 -0
  31. graphify/extract.py +7856 -0
  32. graphify/extractors/MIGRATION.md +107 -0
  33. graphify/extractors/__init__.py +66 -0
  34. graphify/extractors/apex.py +215 -0
  35. graphify/extractors/base.py +85 -0
  36. graphify/extractors/bash.py +579 -0
  37. graphify/extractors/blade.py +53 -0
  38. graphify/extractors/commonlisp.py +540 -0
  39. graphify/extractors/csharp.py +448 -0
  40. graphify/extractors/dart.py +564 -0
  41. graphify/extractors/dm.py +494 -0
  42. graphify/extractors/elixir.py +241 -0
  43. graphify/extractors/engine.py +6509 -0
  44. graphify/extractors/fortran.py +311 -0
  45. graphify/extractors/go.py +527 -0
  46. graphify/extractors/json_config.py +240 -0
  47. graphify/extractors/julia.py +289 -0
  48. graphify/extractors/markdown.py +408 -0
  49. graphify/extractors/models.py +131 -0
  50. graphify/extractors/objc.py +566 -0
  51. graphify/extractors/ocaml.py +289 -0
  52. graphify/extractors/pascal.py +688 -0
  53. graphify/extractors/pascal_forms.py +196 -0
  54. graphify/extractors/powershell.py +522 -0
  55. graphify/extractors/razor.py +192 -0
  56. graphify/extractors/resolution.py +3584 -0
  57. graphify/extractors/robot.py +296 -0
  58. graphify/extractors/rust.py +470 -0
  59. graphify/extractors/sln.py +92 -0
  60. graphify/extractors/sql.py +720 -0
  61. graphify/extractors/terraform.py +181 -0
  62. graphify/extractors/verilog.py +329 -0
  63. graphify/extractors/zig.py +181 -0
  64. graphify/file_slice.py +246 -0
  65. graphify/global_graph.py +194 -0
  66. graphify/google_workspace.py +237 -0
  67. graphify/hooks.py +933 -0
  68. graphify/ids.py +93 -0
  69. graphify/ingest.py +358 -0
  70. graphify/install.py +2366 -0
  71. graphify/llm.py +3544 -0
  72. graphify/manifest.py +4 -0
  73. graphify/manifest_ingest.py +311 -0
  74. graphify/mcp_ingest.py +386 -0
  75. graphify/multigraph_compat.py +212 -0
  76. graphify/pascal_resolution.py +129 -0
  77. graphify/paths.py +436 -0
  78. graphify/pg_introspect.py +165 -0
  79. graphify/prs.py +770 -0
  80. graphify/querylog.py +80 -0
  81. graphify/reflect.py +882 -0
  82. graphify/report.py +346 -0
  83. graphify/resolver_registry.py +85 -0
  84. graphify/ruby_resolution.py +242 -0
  85. graphify/scip_ingest.py +363 -0
  86. graphify/security.py +460 -0
  87. graphify/semantic_cleanup.py +336 -0
  88. graphify/serve.py +2608 -0
  89. graphify/skill-agents.md +710 -0
  90. graphify/skill-aider.md +1283 -0
  91. graphify/skill-amp.md +710 -0
  92. graphify/skill-claw.md +713 -0
  93. graphify/skill-codex.md +710 -0
  94. graphify/skill-copilot.md +713 -0
  95. graphify/skill-devin.md +1410 -0
  96. graphify/skill-droid.md +710 -0
  97. graphify/skill-kilo.md +722 -0
  98. graphify/skill-kiro.md +713 -0
  99. graphify/skill-opencode.md +705 -0
  100. graphify/skill-pi.md +713 -0
  101. graphify/skill-trae.md +711 -0
  102. graphify/skill-vscode.md +709 -0
  103. graphify/skill-windows.md +755 -0
  104. graphify/skill.md +713 -0
  105. graphify/skills/agents/references/add-watch.md +56 -0
  106. graphify/skills/agents/references/exports.md +87 -0
  107. graphify/skills/agents/references/extraction-spec.md +70 -0
  108. graphify/skills/agents/references/github-and-merge.md +46 -0
  109. graphify/skills/agents/references/hooks.md +33 -0
  110. graphify/skills/agents/references/query.md +311 -0
  111. graphify/skills/agents/references/transcribe.md +52 -0
  112. graphify/skills/agents/references/update.md +210 -0
  113. graphify/skills/amp/references/add-watch.md +56 -0
  114. graphify/skills/amp/references/exports.md +87 -0
  115. graphify/skills/amp/references/extraction-spec.md +70 -0
  116. graphify/skills/amp/references/github-and-merge.md +46 -0
  117. graphify/skills/amp/references/hooks.md +33 -0
  118. graphify/skills/amp/references/query.md +311 -0
  119. graphify/skills/amp/references/transcribe.md +52 -0
  120. graphify/skills/amp/references/update.md +210 -0
  121. graphify/skills/claude/references/add-watch.md +56 -0
  122. graphify/skills/claude/references/exports.md +87 -0
  123. graphify/skills/claude/references/extraction-spec.md +70 -0
  124. graphify/skills/claude/references/github-and-merge.md +46 -0
  125. graphify/skills/claude/references/hooks.md +33 -0
  126. graphify/skills/claude/references/query.md +311 -0
  127. graphify/skills/claude/references/transcribe.md +52 -0
  128. graphify/skills/claude/references/update.md +210 -0
  129. graphify/skills/claw/references/add-watch.md +56 -0
  130. graphify/skills/claw/references/exports.md +87 -0
  131. graphify/skills/claw/references/extraction-spec.md +31 -0
  132. graphify/skills/claw/references/github-and-merge.md +46 -0
  133. graphify/skills/claw/references/hooks.md +33 -0
  134. graphify/skills/claw/references/query.md +311 -0
  135. graphify/skills/claw/references/transcribe.md +52 -0
  136. graphify/skills/claw/references/update.md +210 -0
  137. graphify/skills/codex/references/add-watch.md +56 -0
  138. graphify/skills/codex/references/exports.md +87 -0
  139. graphify/skills/codex/references/extraction-spec.md +31 -0
  140. graphify/skills/codex/references/github-and-merge.md +46 -0
  141. graphify/skills/codex/references/hooks.md +33 -0
  142. graphify/skills/codex/references/query.md +311 -0
  143. graphify/skills/codex/references/transcribe.md +52 -0
  144. graphify/skills/codex/references/update.md +210 -0
  145. graphify/skills/copilot/references/add-watch.md +56 -0
  146. graphify/skills/copilot/references/exports.md +87 -0
  147. graphify/skills/copilot/references/extraction-spec.md +70 -0
  148. graphify/skills/copilot/references/github-and-merge.md +46 -0
  149. graphify/skills/copilot/references/hooks.md +33 -0
  150. graphify/skills/copilot/references/query.md +311 -0
  151. graphify/skills/copilot/references/transcribe.md +52 -0
  152. graphify/skills/copilot/references/update.md +210 -0
  153. graphify/skills/droid/references/add-watch.md +56 -0
  154. graphify/skills/droid/references/exports.md +87 -0
  155. graphify/skills/droid/references/extraction-spec.md +70 -0
  156. graphify/skills/droid/references/github-and-merge.md +46 -0
  157. graphify/skills/droid/references/hooks.md +33 -0
  158. graphify/skills/droid/references/query.md +311 -0
  159. graphify/skills/droid/references/transcribe.md +52 -0
  160. graphify/skills/droid/references/update.md +210 -0
  161. graphify/skills/kilo/references/add-watch.md +56 -0
  162. graphify/skills/kilo/references/exports.md +87 -0
  163. graphify/skills/kilo/references/extraction-spec.md +70 -0
  164. graphify/skills/kilo/references/github-and-merge.md +46 -0
  165. graphify/skills/kilo/references/hooks.md +33 -0
  166. graphify/skills/kilo/references/query.md +311 -0
  167. graphify/skills/kilo/references/transcribe.md +52 -0
  168. graphify/skills/kilo/references/update.md +210 -0
  169. graphify/skills/kiro/references/add-watch.md +56 -0
  170. graphify/skills/kiro/references/exports.md +87 -0
  171. graphify/skills/kiro/references/extraction-spec.md +31 -0
  172. graphify/skills/kiro/references/github-and-merge.md +46 -0
  173. graphify/skills/kiro/references/hooks.md +33 -0
  174. graphify/skills/kiro/references/query.md +311 -0
  175. graphify/skills/kiro/references/transcribe.md +52 -0
  176. graphify/skills/kiro/references/update.md +210 -0
  177. graphify/skills/opencode/references/add-watch.md +56 -0
  178. graphify/skills/opencode/references/exports.md +87 -0
  179. graphify/skills/opencode/references/extraction-spec.md +70 -0
  180. graphify/skills/opencode/references/github-and-merge.md +46 -0
  181. graphify/skills/opencode/references/hooks.md +33 -0
  182. graphify/skills/opencode/references/query.md +311 -0
  183. graphify/skills/opencode/references/transcribe.md +52 -0
  184. graphify/skills/opencode/references/update.md +210 -0
  185. graphify/skills/pi/references/add-watch.md +56 -0
  186. graphify/skills/pi/references/exports.md +87 -0
  187. graphify/skills/pi/references/extraction-spec.md +31 -0
  188. graphify/skills/pi/references/github-and-merge.md +46 -0
  189. graphify/skills/pi/references/hooks.md +33 -0
  190. graphify/skills/pi/references/query.md +311 -0
  191. graphify/skills/pi/references/transcribe.md +52 -0
  192. graphify/skills/pi/references/update.md +210 -0
  193. graphify/skills/trae/references/add-watch.md +56 -0
  194. graphify/skills/trae/references/exports.md +87 -0
  195. graphify/skills/trae/references/extraction-spec.md +70 -0
  196. graphify/skills/trae/references/github-and-merge.md +46 -0
  197. graphify/skills/trae/references/hooks.md +35 -0
  198. graphify/skills/trae/references/query.md +311 -0
  199. graphify/skills/trae/references/transcribe.md +52 -0
  200. graphify/skills/trae/references/update.md +210 -0
  201. graphify/skills/vscode/references/add-watch.md +56 -0
  202. graphify/skills/vscode/references/exports.md +87 -0
  203. graphify/skills/vscode/references/extraction-spec.md +70 -0
  204. graphify/skills/vscode/references/github-and-merge.md +46 -0
  205. graphify/skills/vscode/references/hooks.md +33 -0
  206. graphify/skills/vscode/references/query.md +311 -0
  207. graphify/skills/vscode/references/transcribe.md +52 -0
  208. graphify/skills/vscode/references/update.md +210 -0
  209. graphify/skills/windows/references/add-watch.md +56 -0
  210. graphify/skills/windows/references/exports.md +87 -0
  211. graphify/skills/windows/references/extraction-spec.md +70 -0
  212. graphify/skills/windows/references/github-and-merge.md +46 -0
  213. graphify/skills/windows/references/hooks.md +33 -0
  214. graphify/skills/windows/references/query.md +311 -0
  215. graphify/skills/windows/references/transcribe.md +52 -0
  216. graphify/skills/windows/references/update.md +210 -0
  217. graphify/symbol_resolution.py +556 -0
  218. graphify/transcribe.py +186 -0
  219. graphify/tree_html.py +603 -0
  220. graphify/validate.py +95 -0
  221. graphify/watch.py +2280 -0
  222. graphify/wiki.py +405 -0
  223. graphitect/__init__.py +28 -0
  224. graphitect/__main__.py +4 -0
  225. graphitect/_vendor/__init__.py +2 -0
  226. graphitect/_vendor/archify/LICENSE +22 -0
  227. graphitect/_vendor/archify/SKILL.md +137 -0
  228. graphitect/_vendor/archify/THIRD_PARTY_NOTICES.md +69 -0
  229. graphitect/_vendor/archify/assets/JetBrainsMono-OFL.txt +93 -0
  230. graphitect/_vendor/archify/assets/template.html +14935 -0
  231. graphitect/_vendor/archify/bin/archify.mjs +2091 -0
  232. graphitect/_vendor/archify/bin/open-artifact.mjs +86 -0
  233. graphitect/_vendor/archify/bin/preview.mjs +653 -0
  234. graphitect/_vendor/archify/bin/visual-check.mjs +829 -0
  235. graphitect/_vendor/archify/brand-marks/README.md +31 -0
  236. graphitect/_vendor/archify/brand-marks/catalog.json +131 -0
  237. graphitect/_vendor/archify/delta/architecture-delta.mjs +1221 -0
  238. graphitect/_vendor/archify/examples/agent-run.lifecycle.json +60 -0
  239. graphitect/_vendor/archify/examples/agent-tool-call.workflow.json +94 -0
  240. graphitect/_vendor/archify/examples/async-job-roundtrip.sequence.json +61 -0
  241. graphitect/_vendor/archify/examples/brand-aware-delivery.architecture.json +47 -0
  242. graphitect/_vendor/archify/examples/cache-miss-request.sequence.json +82 -0
  243. graphitect/_vendor/archify/examples/checkout-platform.base.architecture.json +31 -0
  244. graphitect/_vendor/archify/examples/checkout-platform.head.architecture.json +31 -0
  245. graphitect/_vendor/archify/examples/dataflow-product-analytics.html +15045 -0
  246. graphitect/_vendor/archify/examples/deployment-release.lifecycle.json +49 -0
  247. graphitect/_vendor/archify/examples/event-stream.dataflow.json +57 -0
  248. graphitect/_vendor/archify/examples/incident-response.workflow.json +64 -0
  249. graphitect/_vendor/archify/examples/lifecycle-agent-run.html +14980 -0
  250. graphitect/_vendor/archify/examples/product-analytics.dataflow.json +76 -0
  251. graphitect/_vendor/archify/examples/production-deployment.architecture.json +71 -0
  252. graphitect/_vendor/archify/examples/release-delivery.workflow.json +62 -0
  253. graphitect/_vendor/archify/examples/sequence-cache-miss-request.html +15060 -0
  254. graphitect/_vendor/archify/examples/web-app-rendered.html +15009 -0
  255. graphitect/_vendor/archify/examples/web-app.architecture.json +46 -0
  256. graphitect/_vendor/archify/examples/workflow-agent-tool-call-rendered.html +15051 -0
  257. graphitect/_vendor/archify/migrations/workflow-v2.mjs +279 -0
  258. graphitect/_vendor/archify/package-lock.json +149 -0
  259. graphitect/_vendor/archify/package.json +39 -0
  260. graphitect/_vendor/archify/recipes/scenarios.mjs +391 -0
  261. graphitect/_vendor/archify/references/authoring-contract.md +243 -0
  262. graphitect/_vendor/archify/references/brand-marks.md +65 -0
  263. graphitect/_vendor/archify/references/delivery-contract.md +120 -0
  264. graphitect/_vendor/archify/references/viewer-runtime.md +45 -0
  265. graphitect/_vendor/archify/renderers/architecture/grid.mjs +62 -0
  266. graphitect/_vendor/archify/renderers/architecture/render-architecture.mjs +1078 -0
  267. graphitect/_vendor/archify/renderers/dataflow/README.md +104 -0
  268. graphitect/_vendor/archify/renderers/dataflow/render-dataflow.mjs +483 -0
  269. graphitect/_vendor/archify/renderers/lifecycle/README.md +115 -0
  270. graphitect/_vendor/archify/renderers/lifecycle/render-lifecycle.mjs +561 -0
  271. graphitect/_vendor/archify/renderers/sequence/README.md +114 -0
  272. graphitect/_vendor/archify/renderers/sequence/render-sequence.mjs +464 -0
  273. graphitect/_vendor/archify/renderers/shared/brand-marks.mjs +563 -0
  274. graphitect/_vendor/archify/renderers/shared/cli.mjs +218 -0
  275. graphitect/_vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
  276. graphitect/_vendor/archify/renderers/shared/diagnostics.mjs +127 -0
  277. graphitect/_vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
  278. graphitect/_vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  279. graphitect/_vendor/archify/renderers/shared/generated-validators.mjs +13 -0
  280. graphitect/_vendor/archify/renderers/shared/geometry.mjs +1423 -0
  281. graphitect/_vendor/archify/renderers/shared/i18n.mjs +595 -0
  282. graphitect/_vendor/archify/renderers/shared/layout-report.mjs +40 -0
  283. graphitect/_vendor/archify/renderers/shared/legend.mjs +217 -0
  284. graphitect/_vendor/archify/renderers/shared/output-path.mjs +340 -0
  285. graphitect/_vendor/archify/renderers/shared/repository-evidence.mjs +238 -0
  286. graphitect/_vendor/archify/renderers/shared/repository-location.mjs +58 -0
  287. graphitect/_vendor/archify/renderers/shared/text-fit.mjs +49 -0
  288. graphitect/_vendor/archify/renderers/shared/utils.mjs +232 -0
  289. graphitect/_vendor/archify/renderers/shared/validator.mjs +86 -0
  290. graphitect/_vendor/archify/renderers/workflow/README.md +223 -0
  291. graphitect/_vendor/archify/renderers/workflow/render-workflow.mjs +35 -0
  292. graphitect/_vendor/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
  293. graphitect/_vendor/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  294. graphitect/_vendor/archify/schemas/README.md +211 -0
  295. graphitect/_vendor/archify/schemas/architecture.schema.json +178 -0
  296. graphitect/_vendor/archify/schemas/common.schema.json +115 -0
  297. graphitect/_vendor/archify/schemas/dataflow.schema.json +243 -0
  298. graphitect/_vendor/archify/schemas/lifecycle.schema.json +266 -0
  299. graphitect/_vendor/archify/schemas/sequence.schema.json +223 -0
  300. graphitect/_vendor/archify/schemas/workflow.schema.json +428 -0
  301. graphitect/_vendor/archify/scripts/check-render-output.mjs +836 -0
  302. graphitect/_vendor/archify/scripts/check-update.mjs +1667 -0
  303. graphitect/_vendor/archify/scripts/generate-brand-marks.mjs +141 -0
  304. graphitect/_vendor/archify/scripts/generate-validators.mjs +66 -0
  305. graphitect/_vendor/archify/scripts/render-examples.mjs +26 -0
  306. graphitect/_vendor/archify/scripts/update-contract.mjs +182 -0
  307. graphitect/_vendor/archify/skill-release.json +10 -0
  308. graphitect/cli.py +981 -0
  309. graphitect/deliver/__init__.py +5 -0
  310. graphitect/deliver/archify_adapter.py +1877 -0
  311. graphitect/deliver/archify_ir.py +160 -0
  312. graphitect/deliver/archify_repair.py +135 -0
  313. graphitect/deliver/doc_compiler.py +916 -0
  314. graphitect/ground/__init__.py +5 -0
  315. graphitect/ground/describe_source.py +27 -0
  316. graphitect/ground/fullread_source.py +56 -0
  317. graphitect/ground/graphify_source.py +107 -0
  318. graphitect/models.py +118 -0
  319. graphitect/skill/SKILL.md +80 -0
  320. graphitect/skill/agents/openai.yaml +4 -0
  321. graphitect/synthesize/__init__.py +5 -0
  322. graphitect/synthesize/engine.py +281 -0
  323. graphitect/synthesize/llm_backend.py +331 -0
  324. graphitect/synthesize/questions.py +139 -0
  325. graphitect/synthesize/rubric.py +104 -0
  326. graphitect-0.2.0.dist-info/METADATA +284 -0
  327. graphitect-0.2.0.dist-info/RECORD +336 -0
  328. graphitect-0.2.0.dist-info/WHEEL +5 -0
  329. graphitect-0.2.0.dist-info/entry_points.txt +2 -0
  330. graphitect-0.2.0.dist-info/licenses/LICENSE +21 -0
  331. graphitect-0.2.0.dist-info/licenses/LICENSE-ARCHIFY-MIT +22 -0
  332. graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-APACHE-2.0 +202 -0
  333. graphitect-0.2.0.dist-info/licenses/LICENSE-GRAPHIFY-MIT +21 -0
  334. graphitect-0.2.0.dist-info/licenses/NOTICE-ARCHIFY-THIRD-PARTY.md +69 -0
  335. graphitect-0.2.0.dist-info/licenses/NOTICE-GRAPHIFY +8 -0
  336. graphitect-0.2.0.dist-info/top_level.txt +2 -0
graphify/reflect.py ADDED
@@ -0,0 +1,882 @@
1
+ """Deterministic "work memory" reflection over graphify-out/memory/.
2
+
3
+ `graphify reflect` reads the Q&A memory docs that `graphify save-result` files back
4
+ into the graph, aggregates their outcome signals (useful / dead_end / corrected), and
5
+ writes a single lessons artifact an agent can load at the start of the next session:
6
+
7
+ - **Preferred sources** — nodes corroborated by multiple ``useful`` answers.
8
+ - **Tentative** — nodes seen useful only once (not yet corroborated).
9
+ - **Contested** — nodes with both positive and negative signals; recency decides.
10
+ - **Known dead ends** — questions/sources marked ``dead_end``; don't re-derive them.
11
+ - **Corrections** — answers the user corrected, and what the right answer was.
12
+
13
+ Source nodes are scored, not counted: each citation contributes a signed,
14
+ time-decayed value (``useful`` positive, ``dead_end``/``corrected`` negative, with a
15
+ half-life so a fresh dead end outweighs a months-old useful). A node is only promoted
16
+ to "preferred" once corroborated by enough distinct results; one save can't mint a
17
+ trusted lesson. When a graph is in hand, source nodes that no longer exist are dropped.
18
+
19
+ It is deterministic: no LLM, stable sort orders, byte-stable output for a given input
20
+ and a given ``now``. When a graph (`graph.json` + `.graphify_analysis.json`) is available
21
+ the lessons are also grouped by community label; without it they degrade to a single
22
+ flat section.
23
+
24
+ The artifact lands at ``graphify-out/reflections/LESSONS.md`` rather than inside the wiki
25
+ because ``graphify export wiki`` deletes every ``wiki/*.md`` on each run — a lessons file
26
+ written there would be clobbered on the next export.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import re
32
+ from collections import Counter
33
+ from datetime import datetime, timezone
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ from graphify.ingest import OUTCOMES
38
+ from graphify.paths import GRAPHIFY_OUT_NAME
39
+
40
+ _UNCATEGORIZED = "Uncategorized"
41
+
42
+ # Derived experiential layer written alongside graph.json (a SIDECAR, kept
43
+ # separate from the durable structural truth in graph.json — no learning_*
44
+ # fields are ever stamped into the graph itself). Read-surface annotations are
45
+ # merged in at display time from this file.
46
+ LEARNING_SIDECAR_NAME = ".graphify_learning.json"
47
+ _LEARNING_SCHEMA_VERSION = 1
48
+ _PROVENANCE_CAP = 5 # most-recent (question, date, outcome) entries per node
49
+
50
+ # Scoring defaults (both exposed as CLI flags).
51
+ _DEFAULT_HALF_LIFE_DAYS = 30.0 # a signal's weight halves every 30 days
52
+ _DEFAULT_MIN_CORROBORATION = 2 # distinct useful results needed to "prefer" a node
53
+
54
+ # Rounding for the signed score keeps sort order and the contested verdict stable
55
+ # across platforms (C pow can differ in the last ULP).
56
+ _SCORE_NDIGITS = 9
57
+
58
+
59
+ # --- frontmatter parsing -------------------------------------------------------
60
+ #
61
+ # save_query_result writes a tiny, hand-built YAML subset (no PyYAML dependency),
62
+ # so we parse the same subset by hand rather than adding a dependency: scalar
63
+ # `key: "value"` lines and a `source_nodes: ["a", "b"]` flow list. Anything we
64
+ # don't recognise is ignored, so foreign .md files in memory/ are skipped cleanly.
65
+
66
+ _SCALAR_RE = re.compile(r'^([A-Za-z_][\w-]*):\s*"(.*)"\s*$')
67
+ _LIST_RE = re.compile(r"^([A-Za-z_][\w-]*):\s*\[(.*)\]\s*$")
68
+ _DQ_ITEM_RE = re.compile(r'"((?:[^"\\]|\\.)*)"')
69
+
70
+
71
+ def _yaml_unescape(s: str) -> str:
72
+ """Reverse the double-quoted escaping that ingest._yaml_str applies."""
73
+ out: list[str] = []
74
+ i = 0
75
+ simple = {"n": "\n", "r": "\r", "t": "\t", "0": "\0", '"': '"', "\\": "\\",
76
+ "L": "\u2028", "P": "\u2029"} # YAML line/paragraph separators
77
+ while i < len(s):
78
+ ch = s[i]
79
+ if ch == "\\" and i + 1 < len(s):
80
+ nxt = s[i + 1]
81
+ if nxt in simple:
82
+ out.append(simple[nxt])
83
+ i += 2
84
+ continue
85
+ if nxt == "x" and i + 3 < len(s):
86
+ try:
87
+ out.append(chr(int(s[i + 2:i + 4], 16)))
88
+ i += 4
89
+ continue
90
+ except ValueError:
91
+ pass
92
+ if nxt == "u" and i + 5 < len(s):
93
+ try:
94
+ out.append(chr(int(s[i + 2:i + 6], 16)))
95
+ i += 6
96
+ continue
97
+ except ValueError:
98
+ pass
99
+ out.append(ch)
100
+ i += 1
101
+ return "".join(out)
102
+
103
+
104
+ def parse_memory_doc(text: str) -> dict[str, Any] | None:
105
+ """Parse the frontmatter of a memory doc into a dict, or None if it has none.
106
+
107
+ Returns the recognised fields (``type``, ``date``, ``question``, ``outcome``,
108
+ ``correction``, ``source_nodes``). ``source_nodes`` is always a list.
109
+ """
110
+ if not text.startswith("---"):
111
+ return None
112
+ lines = text.splitlines()
113
+ if not lines or lines[0].strip() != "---":
114
+ return None
115
+ fields: dict[str, Any] = {"source_nodes": []}
116
+ for line in lines[1:]:
117
+ if line.strip() == "---":
118
+ break
119
+ m = _LIST_RE.match(line)
120
+ if m and m.group(1) == "source_nodes":
121
+ fields["source_nodes"] = [
122
+ _yaml_unescape(item) for item in _DQ_ITEM_RE.findall(m.group(2))
123
+ ]
124
+ continue
125
+ m = _SCALAR_RE.match(line)
126
+ if m:
127
+ key, val = m.group(1), _yaml_unescape(m.group(2))
128
+ if key in ("type", "date", "question", "outcome", "correction", "contributor"):
129
+ fields[key] = val
130
+ return fields
131
+
132
+
133
+ def load_memory_docs(memory_dir: Path) -> list[dict[str, Any]]:
134
+ """Parse every memory doc under ``memory_dir``, sorted by date then filename.
135
+
136
+ Each record is the parsed frontmatter plus ``_path`` (the source file). Docs
137
+ without recognisable frontmatter (foreign .md files, the LESSONS.md artifact)
138
+ are skipped.
139
+ """
140
+ memory_dir = Path(memory_dir)
141
+ if not memory_dir.exists():
142
+ return []
143
+ docs: list[dict[str, Any]] = []
144
+ for path in sorted(memory_dir.glob("*.md")):
145
+ try:
146
+ text = path.read_text(encoding="utf-8")
147
+ except (OSError, UnicodeDecodeError):
148
+ continue
149
+ parsed = parse_memory_doc(text)
150
+ if parsed is None:
151
+ continue
152
+ parsed["_path"] = path.name
153
+ docs.append(parsed)
154
+ # Stable order: by (date, filename) so output is deterministic across runs.
155
+ docs.sort(key=lambda d: (d.get("date", ""), d["_path"]))
156
+ return docs
157
+
158
+
159
+ # --- graph / community lookup (optional) ---------------------------------------
160
+
161
+
162
+ def _load_node_community(graph_path: Path, analysis_path: Path,
163
+ labels_path: Path) -> dict[str, str] | None:
164
+ """Build a lookup from node id AND node label -> community label, or None if the
165
+ graph isn't available.
166
+
167
+ Mirrors how `graphify export wiki` reads graph.json + .graphify_analysis.json +
168
+ .graphify_labels.json. Community membership in the analysis sidecar is keyed by
169
+ node id, but `save-result` cites nodes by label, so both are mapped — otherwise a
170
+ cited ``build_from_json()`` never finds its community and every lesson collapses
171
+ into Uncategorized. Best-effort: any missing/unparseable artifact disables grouping.
172
+ """
173
+ if not graph_path.exists() or not analysis_path.exists():
174
+ return None
175
+ try:
176
+ analysis = json.loads(analysis_path.read_text(encoding="utf-8"))
177
+ except (OSError, ValueError):
178
+ return None
179
+ communities = analysis.get("communities", {})
180
+ if not communities:
181
+ return None
182
+ labels: dict[str, str] = {}
183
+ if labels_path.exists():
184
+ try:
185
+ labels = json.loads(labels_path.read_text(encoding="utf-8"))
186
+ except (OSError, ValueError):
187
+ labels = {}
188
+ # id -> label from the graph, so a label-form citation resolves to a community too.
189
+ id_to_label: dict[str, str] = {}
190
+ try:
191
+ gdata = json.loads(graph_path.read_text(encoding="utf-8"))
192
+ for n in gdata.get("nodes", []):
193
+ if isinstance(n, dict) and n.get("id") is not None and n.get("label") is not None:
194
+ id_to_label[str(n["id"])] = str(n["label"])
195
+ except (OSError, ValueError):
196
+ id_to_label = {}
197
+ # Sorted cid iteration + setdefault makes any label collision resolve
198
+ # deterministically (smallest community id wins).
199
+ node_community: dict[str, str] = {}
200
+ for cid in sorted(communities, key=str):
201
+ label = labels.get(str(cid)) or labels.get(cid) or f"Community {cid}"
202
+ for nid in communities[cid]:
203
+ nid = str(nid)
204
+ node_community.setdefault(nid, label)
205
+ nlabel = id_to_label.get(nid)
206
+ if nlabel is not None:
207
+ node_community.setdefault(nlabel, label)
208
+ return node_community
209
+
210
+
211
+ def _load_known_nodes(graph_path: Path) -> set[str] | None:
212
+ """The set of node ids AND labels in the current graph, or None if unavailable.
213
+
214
+ Used to drop source nodes from lessons once the code they pointed at is gone
215
+ (deleted/renamed) — a stale lesson shouldn't keep getting recommended. Both ids
216
+ and labels are collected because `save-result` records source nodes by their
217
+ human-readable label (what an agent cites, e.g. ``build_from_json()``), while
218
+ graph nodes are keyed by id (e.g. ``module_build_from_json``). Matching on either
219
+ keeps a still-present node and only drops one that survives under neither name —
220
+ indexing ids alone silently dropped every label-form citation (the common case).
221
+ """
222
+ try:
223
+ data = json.loads(Path(graph_path).read_text(encoding="utf-8"))
224
+ except (OSError, ValueError):
225
+ return None
226
+ nodes = data.get("nodes")
227
+ if not isinstance(nodes, list):
228
+ return None
229
+ known: set[str] = set()
230
+ for n in nodes:
231
+ if not isinstance(n, dict):
232
+ continue
233
+ if n.get("id") is not None:
234
+ known.add(str(n["id"]))
235
+ if n.get("label") is not None:
236
+ known.add(str(n["label"]))
237
+ return known or None
238
+
239
+
240
+ def _doc_community(nodes: list[str],
241
+ node_community: dict[str, str] | None) -> str:
242
+ """The community a doc belongs to: the plurality community of its source nodes.
243
+
244
+ Ties break to the lexicographically-smallest label, so the result is
245
+ deterministic regardless of source-node order. Docs with no resolvable
246
+ community (no source nodes, or no graph) fall into the Uncategorized bucket.
247
+ """
248
+ if not node_community:
249
+ return _UNCATEGORIZED
250
+ labels = [node_community[n] for n in nodes if n in node_community]
251
+ if not labels:
252
+ return _UNCATEGORIZED
253
+ counts = Counter(labels)
254
+ # Highest count wins; on a tie, the smaller label (most-negative count first,
255
+ # then ascending label) — a plain min() over (-count, label).
256
+ return min(counts.items(), key=lambda kv: (-kv[1], kv[0]))[0]
257
+
258
+
259
+ # --- scoring helpers -----------------------------------------------------------
260
+
261
+
262
+ def _parse_dt(date_str: str) -> datetime | None:
263
+ """Parse an ISO date/datetime to an aware UTC datetime, or None if unparseable."""
264
+ if not date_str:
265
+ return None
266
+ try:
267
+ dt = datetime.fromisoformat(date_str)
268
+ except ValueError:
269
+ return None
270
+ if dt.tzinfo is None:
271
+ dt = dt.replace(tzinfo=timezone.utc)
272
+ return dt
273
+
274
+
275
+ def _decay(date_str: str, now: datetime, half_life_days: float) -> float:
276
+ """Time-decay weight in (0, 1]: halves every ``half_life_days``.
277
+
278
+ Undated/unparseable signals keep full weight (1.0); future-dated ones are
279
+ clamped to age 0 (also 1.0).
280
+ """
281
+ dt = _parse_dt(date_str)
282
+ if dt is None or half_life_days <= 0:
283
+ return 1.0
284
+ age_days = max(0.0, (now - dt).total_seconds() / 86400.0)
285
+ return 0.5 ** (age_days / half_life_days)
286
+
287
+
288
+ # --- aggregation ---------------------------------------------------------------
289
+
290
+
291
+ def _empty_bucket() -> dict[str, Any]:
292
+ return {
293
+ "counts": {k: 0 for k in (*OUTCOMES, "unmarked")},
294
+ # node -> running signed, time-decayed score
295
+ "node_score": {},
296
+ # node -> distinct positive / negative result counts (for corroboration)
297
+ "node_pos": Counter(),
298
+ "node_neg": Counter(),
299
+ # node -> most recent event date seen (for the contested verdict line)
300
+ "node_last": {},
301
+ # node -> list of (date, question, outcome) for useful/corrected citations.
302
+ # Feeds the sidecar overlay's per-node provenance; never read by LESSONS.md,
303
+ # so it doesn't touch the aggregate's public shape.
304
+ "node_provenance": {},
305
+ "dead_ends": [],
306
+ "corrections": [],
307
+ }
308
+
309
+
310
+ def _record_node(bucket: dict[str, Any], node: str, sign: int,
311
+ weight: float, date: str, *, outcome: str | None = None,
312
+ question: str = "") -> None:
313
+ bucket["node_score"][node] = bucket["node_score"].get(node, 0.0) + sign * weight
314
+ if sign > 0:
315
+ bucket["node_pos"][node] += 1
316
+ elif sign < 0:
317
+ bucket["node_neg"][node] += 1
318
+ if date > bucket["node_last"].get(node, ""):
319
+ bucket["node_last"][node] = date
320
+ # Provenance: only useful/corrected events are recorded (the experiential
321
+ # trail an agent cares about — what cited this node, and how it turned out).
322
+ if outcome in ("useful", "corrected"):
323
+ bucket["node_provenance"].setdefault(node, []).append(
324
+ (date, question, outcome))
325
+
326
+
327
+ def _finalize_sources(bucket: dict[str, Any],
328
+ min_corroboration: int) -> dict[str, list]:
329
+ """Split a bucket's scored nodes into preferred / tentative / contested lists."""
330
+ preferred, tentative, contested = [], [], []
331
+ for node in bucket["node_score"]:
332
+ pos = bucket["node_pos"][node]
333
+ neg = bucket["node_neg"][node]
334
+ score = round(bucket["node_score"][node], _SCORE_NDIGITS)
335
+ if pos and neg:
336
+ verdict = "useful" if score > 0 else "dead end" if score < 0 else "even"
337
+ contested.append({"node": node, "pos": pos, "neg": neg,
338
+ "score": score, "verdict": verdict,
339
+ "last": bucket["node_last"].get(node, "")})
340
+ elif pos: # positive-only
341
+ entry = {"node": node, "n": pos, "score": score}
342
+ (preferred if pos >= min_corroboration else tentative).append(entry)
343
+ # negative-only nodes are surfaced via the dead-ends questions, not here.
344
+ preferred.sort(key=lambda e: (-e["score"], e["node"]))
345
+ tentative.sort(key=lambda e: (-e["score"], e["node"]))
346
+ contested.sort(key=lambda e: (-e["score"], e["node"]))
347
+ return {"preferred": preferred, "tentative": tentative, "contested": contested}
348
+
349
+
350
+ def _dedupe_by_question(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
351
+ """Collapse repeated questions to one entry. Docs are processed oldest-first, so
352
+ the last write per question wins (recency — e.g. the most recent correction text).
353
+ Output is deterministically ordered by (date, question). Without this, saving the
354
+ same Q&A twice duplicated lines in the dead-ends / corrections lists, even though
355
+ node scoring already dedups by node.
356
+ """
357
+ latest: dict[str, dict[str, Any]] = {}
358
+ for it in items:
359
+ latest[it.get("question", "")] = it
360
+ return sorted(latest.values(),
361
+ key=lambda it: (it.get("date", ""), it.get("question", "")))
362
+
363
+
364
+ def aggregate_lessons(docs: list[dict[str, Any]],
365
+ node_community: dict[str, str] | None = None,
366
+ *,
367
+ now: datetime | None = None,
368
+ half_life_days: float = _DEFAULT_HALF_LIFE_DAYS,
369
+ min_corroboration: int = _DEFAULT_MIN_CORROBORATION,
370
+ known_nodes: set[str] | None = None) -> dict[str, Any]:
371
+ """Aggregate parsed memory docs into a deterministic lessons structure.
372
+
373
+ ``now`` anchors the time-decay (pass it explicitly for byte-stable output).
374
+ ``known_nodes`` (when given) gates out source nodes no longer in the graph.
375
+ Returns ``{"total", "counts", "min_corroboration", "preferred", "tentative",
376
+ "contested", "dead_ends", "corrections", "by_community"}``; ``by_community`` is
377
+ empty unless a graph is supplied.
378
+ """
379
+ if now is None:
380
+ now = datetime.now(timezone.utc)
381
+ elif now.tzinfo is None:
382
+ now = now.replace(tzinfo=timezone.utc)
383
+
384
+ overall = _empty_bucket()
385
+ by_community: dict[str, dict[str, Any]] = {}
386
+
387
+ for doc in docs:
388
+ outcome = doc.get("outcome")
389
+ date = doc.get("date", "")
390
+ # One event per node per doc; drop nodes the graph no longer knows about.
391
+ raw = doc.get("source_nodes", [])
392
+ nodes = list(dict.fromkeys(
393
+ n for n in raw if known_nodes is None or n in known_nodes))
394
+ community = _doc_community(nodes, node_community)
395
+ bucket = by_community.setdefault(community, _empty_bucket())
396
+
397
+ sign = 1 if outcome == "useful" else -1 if outcome in ("dead_end", "corrected") else 0
398
+ weight = _decay(date, now, half_life_days) if sign else 0.0
399
+
400
+ for target in (overall, bucket):
401
+ target["counts"][outcome if outcome in OUTCOMES else "unmarked"] += 1
402
+ if sign:
403
+ for n in nodes:
404
+ _record_node(target, n, sign, weight, date,
405
+ outcome=outcome, question=doc.get("question", ""))
406
+ if outcome == "dead_end":
407
+ target["dead_ends"].append(
408
+ {"question": doc.get("question", ""), "nodes": nodes, "date": date})
409
+ elif outcome == "corrected":
410
+ target["corrections"].append(
411
+ {"question": doc.get("question", ""),
412
+ "correction": doc.get("correction", ""), "date": date})
413
+
414
+ # Only surface per-community grouping when a graph was actually supplied;
415
+ # without one every doc falls into Uncategorized and the section would just
416
+ # duplicate the flat "Lessons" block.
417
+ community_out: dict[str, dict[str, Any]] = {}
418
+ if node_community:
419
+ community_out = {
420
+ label: {"counts": b["counts"], **_finalize_sources(b, min_corroboration),
421
+ "dead_ends": _dedupe_by_question(b["dead_ends"]),
422
+ "corrections": _dedupe_by_question(b["corrections"])}
423
+ for label, b in by_community.items()
424
+ }
425
+
426
+ return {
427
+ "total": len(docs),
428
+ "counts": overall["counts"],
429
+ "min_corroboration": min_corroboration,
430
+ **_finalize_sources(overall, min_corroboration),
431
+ "dead_ends": _dedupe_by_question(overall["dead_ends"]),
432
+ "corrections": _dedupe_by_question(overall["corrections"]),
433
+ "by_community": community_out,
434
+ # Private: per-node (date, question, outcome) trail for the sidecar
435
+ # overlay's provenance. Underscore-prefixed and not rendered by
436
+ # render_lessons_md, so the public aggregate shape is unchanged.
437
+ "_node_provenance": overall["node_provenance"],
438
+ }
439
+
440
+
441
+ # --- rendering -----------------------------------------------------------------
442
+
443
+
444
+ def _render_bucket(out: list[str], data: dict[str, Any], k: int) -> None:
445
+ preferred = data["preferred"]
446
+ tentative = data["tentative"]
447
+ contested = data["contested"]
448
+ dead_ends = data["dead_ends"]
449
+ corrections = data["corrections"]
450
+
451
+ if preferred:
452
+ out += [f"**Preferred sources** — corroborated by ≥{k} useful results; "
453
+ "start here.", ""]
454
+ for e in preferred:
455
+ out.append(f"- `{e['node']}` ({e['n']}× useful)")
456
+ out.append("")
457
+ if tentative:
458
+ out += [f"**Tentative** — useful in fewer than {k} results; verify before "
459
+ "relying.", ""]
460
+ for e in tentative:
461
+ out.append(f"- `{e['node']}` ({e['n']}× useful)")
462
+ out.append("")
463
+ if contested:
464
+ out += ["**Contested** — mixed signals; recency decides.", ""]
465
+ for e in contested:
466
+ day = e["last"][:10]
467
+ verdict = ("evenly split" if e["verdict"] == "even"
468
+ else f"recency leans **{e['verdict']}**")
469
+ out.append(
470
+ f"- `{e['node']}` — {e['pos']}× useful, {e['neg']}× "
471
+ f"dead end/corrected → {verdict}"
472
+ + (f" (latest {day})" if day else ""))
473
+ out.append("")
474
+ if dead_ends:
475
+ out += ["**Known dead ends** — led nowhere; don't re-derive.", ""]
476
+ for d in dead_ends:
477
+ nodes = ", ".join(f"`{n}`" for n in d["nodes"])
478
+ out.append(f"- \"{d['question']}\"" + (f" — {nodes}" if nodes else ""))
479
+ out.append("")
480
+ if corrections:
481
+ out += ["**Corrections** — do these differently.", ""]
482
+ for c in corrections:
483
+ out.append(f"- \"{c['question']}\" → {c['correction']}")
484
+ out.append("")
485
+ if not (preferred or tentative or contested or dead_ends or corrections):
486
+ out += ["_No marked outcomes yet._", ""]
487
+
488
+
489
+ def render_lessons_md(agg: dict[str, Any]) -> str:
490
+ """Render the aggregate into the deterministic LESSONS.md markdown body."""
491
+ c = agg["counts"]
492
+ k = agg.get("min_corroboration", _DEFAULT_MIN_CORROBORATION)
493
+ out: list[str] = [
494
+ "# Lessons",
495
+ "",
496
+ f"_Auto-generated by `graphify reflect` from {agg['total']} session "
497
+ f"{'memory' if agg['total'] == 1 else 'memories'} in graphify-out/memory/. "
498
+ "Deterministic; no LLM. Use for orientation — verify before relying, and "
499
+ "revisit dead ends if the code has changed since._",
500
+ "",
501
+ "## Summary",
502
+ "",
503
+ f"- {c['useful']} useful · {c['dead_end']} dead ends · "
504
+ f"{c['corrected']} corrected · {c['unmarked']} unmarked",
505
+ "",
506
+ "## Lessons",
507
+ "",
508
+ ]
509
+ _render_bucket(out, agg, k)
510
+
511
+ if agg["by_community"]:
512
+ out += ["## By topic", ""]
513
+ # Uncategorized sorts last; everything else alphabetically.
514
+ def _topic_key(label: str) -> tuple[int, str]:
515
+ return (1 if label == _UNCATEGORIZED else 0, label)
516
+ for label in sorted(agg["by_community"], key=_topic_key):
517
+ out += [f"### {label}", ""]
518
+ _render_bucket(out, agg["by_community"][label], k)
519
+
520
+ # Single trailing newline, no trailing whitespace lines.
521
+ return "\n".join(out).rstrip("\n") + "\n"
522
+
523
+
524
+ # --- orchestrator --------------------------------------------------------------
525
+
526
+
527
+ def lessons_fresh(out_path: Path, memory_dir: Path,
528
+ graph_path: Path | None = None,
529
+ analysis_path: Path | None = None,
530
+ labels_path: Path | None = None) -> bool:
531
+ """True if ``out_path`` exists and is at least as new as every input that
532
+ feeds it (the memory docs, and the graph/sidecars when one is used).
533
+
534
+ Lets ``graphify reflect --if-stale`` skip a redundant run — e.g. when the git
535
+ post-commit hook just regenerated ``LESSONS.md`` and an agent then runs reflect
536
+ again at the start of a session. A missing output is never fresh (it must be
537
+ built). Mtime-based and best-effort; it only gates whether to *recompute*, not
538
+ what the recomputation produces (that stays deterministic).
539
+ """
540
+ out_path = Path(out_path)
541
+ try:
542
+ out_mtime = out_path.stat().st_mtime
543
+ except OSError:
544
+ return False # missing/unreadable -> must build
545
+ newest = 0.0
546
+ md = Path(memory_dir)
547
+ if md.is_dir():
548
+ for f in md.glob("*.md"):
549
+ try:
550
+ newest = max(newest, f.stat().st_mtime)
551
+ except OSError:
552
+ pass
553
+ for input_path in (graph_path, analysis_path, labels_path):
554
+ if input_path is None:
555
+ continue
556
+ gp = Path(input_path)
557
+ try:
558
+ newest = max(newest, gp.stat().st_mtime)
559
+ except OSError:
560
+ pass
561
+ return out_mtime >= newest
562
+
563
+
564
+ def reflect(memory_dir: Path, out_path: Path,
565
+ graph_path: Path | None = None,
566
+ analysis_path: Path | None = None,
567
+ labels_path: Path | None = None,
568
+ *,
569
+ now: datetime | None = None,
570
+ half_life_days: float = _DEFAULT_HALF_LIFE_DAYS,
571
+ min_corroboration: int = _DEFAULT_MIN_CORROBORATION,
572
+ ) -> tuple[Path, dict[str, Any]]:
573
+ """Scan ``memory_dir``, write the lessons doc to ``out_path``, return (path, agg).
574
+
575
+ If ``graph_path`` is given lessons are grouped by community and source nodes no
576
+ longer in the graph are dropped; otherwise the doc is a single flat section.
577
+ """
578
+ docs = load_memory_docs(memory_dir)
579
+
580
+ node_community = None
581
+ known_nodes = None
582
+ if graph_path is not None:
583
+ graph_path = Path(graph_path)
584
+ analysis_path = Path(analysis_path) if analysis_path else (
585
+ graph_path.parent / ".graphify_analysis.json")
586
+ labels_path = Path(labels_path) if labels_path else (
587
+ graph_path.parent / ".graphify_labels.json")
588
+ node_community = _load_node_community(graph_path, analysis_path, labels_path)
589
+ known_nodes = _load_known_nodes(graph_path)
590
+
591
+ if now is None:
592
+ now = datetime.now(timezone.utc)
593
+
594
+ agg = aggregate_lessons(docs, node_community, now=now,
595
+ half_life_days=half_life_days,
596
+ min_corroboration=min_corroboration,
597
+ known_nodes=known_nodes)
598
+ out_path = Path(out_path)
599
+ out_path.parent.mkdir(parents=True, exist_ok=True)
600
+ out_path.write_text(render_lessons_md(agg), encoding="utf-8")
601
+
602
+ # Also project a derived experiential sidecar next to graph.json when a graph
603
+ # is in hand. Best-effort: a sidecar failure must never break LESSONS.md.
604
+ if graph_path is not None:
605
+ try:
606
+ write_learning_sidecar(agg, Path(graph_path), now=now)
607
+ except Exception:
608
+ pass
609
+
610
+ return out_path, agg
611
+
612
+
613
+ # --- work-memory overlay sidecar ------------------------------------------------
614
+ #
615
+ # A derived, experiential projection of the reflect aggregate, written next to
616
+ # graph.json as ``.graphify_learning.json``. It carries which nodes have proven
617
+ # preferred/tentative/contested, a code fingerprint for staleness detection, and
618
+ # a short provenance trail. graph.json (durable structural truth) is never
619
+ # touched — read surfaces merge this overlay in only at display time.
620
+
621
+
622
+ def _build_id_label_maps(graph_path: Path) -> tuple[dict[str, str], dict[str, list[str]],
623
+ dict[str, dict[str, Any]]]:
624
+ """From graph.json build:
625
+
626
+ - ``id_set``: id -> id (every node id, so an id-form citation resolves to itself)
627
+ - ``label_to_ids``: label -> [ids] (so a label-form citation can be resolved,
628
+ and ambiguity — one label, many ids — can be detected and skipped)
629
+ - ``node_by_id``: id -> node dict (for source_file lookup)
630
+
631
+ Best-effort; an unreadable/garbage graph yields empty maps.
632
+ """
633
+ id_set: dict[str, str] = {}
634
+ label_to_ids: dict[str, list[str]] = {}
635
+ node_by_id: dict[str, dict[str, Any]] = {}
636
+ try:
637
+ data = json.loads(Path(graph_path).read_text(encoding="utf-8"))
638
+ except (OSError, ValueError):
639
+ return id_set, label_to_ids, node_by_id
640
+ for n in data.get("nodes", []):
641
+ if not isinstance(n, dict) or n.get("id") is None:
642
+ continue
643
+ nid = str(n["id"])
644
+ id_set[nid] = nid
645
+ node_by_id[nid] = n
646
+ label = n.get("label")
647
+ if label is not None:
648
+ label_to_ids.setdefault(str(label), []).append(nid)
649
+ return id_set, label_to_ids, node_by_id
650
+
651
+
652
+ def _resolve_canonical_id(cited: str, id_set: dict[str, str],
653
+ label_to_ids: dict[str, list[str]]) -> str | None:
654
+ """Resolve a cited node (a label OR an id) to a single canonical node id.
655
+
656
+ Returns None if the citation is unresolved (stale — gone from the graph) or
657
+ ambiguous (a label shared by >1 node id). Such citations can't be displayed
658
+ against a single node, so the caller skips them.
659
+ """
660
+ if cited in id_set:
661
+ return id_set[cited]
662
+ ids = label_to_ids.get(cited)
663
+ if ids and len(ids) == 1:
664
+ return ids[0]
665
+ return None
666
+
667
+
668
+ def _resolve_source_path(src: str, graph_path: Path) -> Path | None:
669
+ """Locate a node's ``source_file`` on disk, returning an existing file or None.
670
+
671
+ ``source_file`` is stored relative to the PROJECT root, but graph.json may
672
+ live in ``<root>/graphify-out/`` (so its own dir is not the root) or directly
673
+ at the root (``extract --out .``). Resolve the root in the most-likely order
674
+ and return the first candidate where the file actually exists, so a defeated
675
+ heuristic or a stale marker can never strand the file (every node would then
676
+ look "changed"). The same search runs at write and read time, so the writer
677
+ and reader resolve to the same file.
678
+
679
+ Order: the committed ``.graphify_root`` marker (#686/#1423 — authoritative for
680
+ an absolute/elsewhere ``GRAPHIFY_OUT`` override); then the layout-appropriate
681
+ root *first* — graph.json's parent's parent for the ``graphify-out`` layout,
682
+ or graph.json's own dir for a flat layout — which avoids matching a same-named
683
+ file one directory up; then the other of the two; then the cwd.
684
+ """
685
+ if not src:
686
+ return None
687
+ p = Path(src)
688
+ if p.is_absolute():
689
+ return p if p.is_file() else None
690
+ gp = Path(graph_path)
691
+ out_dir = gp.parent
692
+ candidates: list[Path] = []
693
+ try:
694
+ recorded = (out_dir / ".graphify_root").read_text(encoding="utf-8-sig").strip()
695
+ if recorded:
696
+ candidates.append(Path(recorded))
697
+ except (OSError, ValueError):
698
+ pass # unreadable/non-UTF-8 marker -> fall through (best-effort)
699
+ # Layout-appropriate root first (precision), then the other (robustness).
700
+ if out_dir.name == GRAPHIFY_OUT_NAME:
701
+ candidates += [out_dir.parent, out_dir]
702
+ else:
703
+ candidates += [out_dir, out_dir.parent]
704
+ candidates.append(Path("."))
705
+ seen: set[str] = set()
706
+ for base in candidates:
707
+ key = str(base)
708
+ if key in seen:
709
+ continue
710
+ seen.add(key)
711
+ cand = base / p
712
+ if cand.is_file():
713
+ return cand
714
+ return None
715
+
716
+
717
+ def _content_hash(path: Path) -> str:
718
+ """SHA256 of file CONTENT only (no path mixed in), so the fingerprint is
719
+ independent of which root resolved the file — write and read agree, and a
720
+ committed sidecar stays valid across machines/checkouts."""
721
+ import hashlib
722
+ try:
723
+ return hashlib.sha256(path.read_bytes()).hexdigest()
724
+ except OSError:
725
+ return ""
726
+
727
+
728
+ def _code_fingerprint(node: dict[str, Any] | None, graph_path: Path) -> str:
729
+ """Content hash of the node's ``source_file``, or '' if unavailable.
730
+
731
+ Coarse on purpose — a file-level hash over-flags (any edit to the file marks
732
+ every node in it stale) rather than under-flags, which is the safe direction
733
+ for a "re-verify" hint.
734
+ """
735
+ if not node:
736
+ return ""
737
+ sp = _resolve_source_path(node.get("source_file") or "", graph_path)
738
+ return _content_hash(sp) if sp is not None else ""
739
+
740
+
741
+ def _provenance_for(node: str, prov_map: dict[str, list],
742
+ fallback_outcome: str) -> list[dict[str, str]]:
743
+ """Most-recent-first, capped provenance entries for a node.
744
+
745
+ ``prov_map`` is the aggregate's private per-node (date, question, outcome)
746
+ trail. ``fallback_outcome`` covers an entry with no recorded trail (shouldn't
747
+ happen for preferred/tentative/contested, which all have ≥1 positive event).
748
+ """
749
+ events = prov_map.get(node, [])
750
+ # Sort recent-first; (date desc, then question for a stable tiebreak).
751
+ ordered = sorted(events, key=lambda e: (e[0], e[1]), reverse=True)
752
+ out: list[dict[str, str]] = []
753
+ for date, question, outcome in ordered[:_PROVENANCE_CAP]:
754
+ out.append({"q": question, "date": date, "outcome": outcome})
755
+ return out
756
+
757
+
758
+ def build_learning_overlay(agg: dict[str, Any], graph_path: Path,
759
+ *, now: datetime | None = None) -> dict[str, Any]:
760
+ """Project the reflect aggregate into the sidecar's ``{version, generated_at,
761
+ nodes}`` structure, keyed by canonical node id.
762
+
763
+ Built from preferred + tentative + contested (NOT dead_ends — those stay
764
+ query-scoped, surfaced only in the report). Citations that don't resolve to
765
+ exactly one node id are skipped.
766
+ """
767
+ if now is None:
768
+ now = datetime.now(timezone.utc)
769
+ elif now.tzinfo is None:
770
+ now = now.replace(tzinfo=timezone.utc)
771
+
772
+ graph_path = Path(graph_path)
773
+ id_set, label_to_ids, node_by_id = _build_id_label_maps(graph_path)
774
+ prov_map = agg.get("_node_provenance", {})
775
+
776
+ # id -> entry; a canonical id can be cited under both its id and label form,
777
+ # but the aggregate dedups per node string, so collisions here are benign and
778
+ # resolved deterministically by iteration order (preferred, tentative, contested).
779
+ nodes_out: dict[str, dict[str, Any]] = {}
780
+
781
+ def _add(entry_src: dict[str, Any], status: str) -> None:
782
+ cited = entry_src["node"]
783
+ cid = _resolve_canonical_id(cited, id_set, label_to_ids)
784
+ if cid is None:
785
+ return # ambiguous or stale — can't display against a single node
786
+ if cid in nodes_out:
787
+ return # first status wins (preferred > tentative > contested order)
788
+ node = node_by_id.get(cid)
789
+ out: dict[str, Any] = {
790
+ "status": status,
791
+ "score": entry_src["score"],
792
+ "uses": entry_src.get("n", entry_src.get("pos", 0)),
793
+ "last": entry_src.get("last", ""),
794
+ "label": str(node.get("label", cited)) if node else str(cited),
795
+ "source_file": str(node.get("source_file") or "") if node else "",
796
+ "code_fingerprint": _code_fingerprint(node, graph_path),
797
+ "provenance": _provenance_for(cited, prov_map, status),
798
+ }
799
+ if status == "contested":
800
+ out["verdict"] = entry_src.get("verdict", "even")
801
+ out["neg"] = entry_src.get("neg", 0)
802
+ else:
803
+ # preferred/tentative carry no contested verdict; derive `last` from
804
+ # provenance if the finalizer didn't (positive-only buckets do track it
805
+ # via node_last for contested only).
806
+ if not out["last"] and out["provenance"]:
807
+ out["last"] = out["provenance"][0]["date"]
808
+ nodes_out[cid] = out
809
+
810
+ for e in agg.get("preferred", []):
811
+ _add(e, "preferred")
812
+ for e in agg.get("tentative", []):
813
+ _add(e, "tentative")
814
+ for e in agg.get("contested", []):
815
+ _add(e, "contested")
816
+
817
+ return {
818
+ "version": _LEARNING_SCHEMA_VERSION,
819
+ "generated_at": now.isoformat(),
820
+ "nodes": nodes_out,
821
+ }
822
+
823
+
824
+ def write_learning_sidecar(agg: dict[str, Any], graph_path: Path,
825
+ *, now: datetime | None = None) -> Path:
826
+ """Write ``.graphify_learning.json`` next to ``graph_path`` deterministically.
827
+
828
+ Sorted keys + indent=2 so re-runs on identical input (and a fixed ``now``)
829
+ are byte-identical. Returns the sidecar path.
830
+ """
831
+ overlay = build_learning_overlay(agg, graph_path, now=now)
832
+ sidecar = Path(graph_path).parent / LEARNING_SIDECAR_NAME
833
+ sidecar.write_text(
834
+ json.dumps(overlay, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
835
+ encoding="utf-8",
836
+ )
837
+ return sidecar
838
+
839
+
840
+ def load_learning_overlay(graph_path: Path) -> dict[str, dict[str, Any]]:
841
+ """Load the sidecar next to ``graph_path`` and return ``{node_id -> entry}``
842
+ with a recomputed ``stale: bool`` per entry. Best-effort -> {} on any error.
843
+
844
+ Staleness: recompute ``file_hash(source_file)`` and compare to the entry's
845
+ stored ``code_fingerprint``. Matching fingerprints -> not stale. Differing,
846
+ missing-but-recomputable, or a vanished file -> stale (the safe, over-flagging
847
+ direction). An entry with no stored fingerprint AND no current file is not
848
+ marked stale (nothing to re-verify).
849
+ """
850
+ sidecar = Path(graph_path).parent / LEARNING_SIDECAR_NAME
851
+ try:
852
+ data = json.loads(sidecar.read_text(encoding="utf-8"))
853
+ except (OSError, ValueError):
854
+ return {}
855
+ nodes = data.get("nodes")
856
+ if not isinstance(nodes, dict):
857
+ return {}
858
+ out: dict[str, dict[str, Any]] = {}
859
+ for nid, entry in nodes.items():
860
+ if not isinstance(entry, dict):
861
+ continue
862
+ merged = dict(entry)
863
+ merged["stale"] = _is_stale(entry, graph_path)
864
+ out[str(nid)] = merged
865
+ return out
866
+
867
+
868
+ def _is_stale(entry: dict[str, Any], graph_path: Path) -> bool:
869
+ """True if the node's source file changed (or vanished) since the fingerprint
870
+ was taken. Uses the same file resolution + content hash as the writer, so a
871
+ freshly-written verdict on unchanged code is never spuriously stale."""
872
+ src = entry.get("source_file", "")
873
+ if not src:
874
+ # No file to track — nothing to re-verify.
875
+ return False
876
+ sp = _resolve_source_path(src, graph_path)
877
+ if sp is None:
878
+ return True # file gone / unfindable — re-verify
879
+ stored = entry.get("code_fingerprint", "")
880
+ if not stored:
881
+ return True # had a file but never fingerprinted it -> can't trust -> stale
882
+ return _content_hash(sp) != stored