nodedex 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (469) hide show
  1. package/adapters/claude-code-watcher.mjs +336 -0
  2. package/adapters/hermes-statedb-watcher.mjs +234 -0
  3. package/adapters/nodedex-capture-core.mjs +129 -0
  4. package/adapters/nodedex-capture.mjs +169 -0
  5. package/dist/agent-protocol.d.ts +7 -0
  6. package/dist/agent-protocol.d.ts.map +1 -0
  7. package/dist/agent-protocol.js +38 -0
  8. package/dist/agent-protocol.js.map +1 -0
  9. package/dist/api-server.d.ts +5 -0
  10. package/dist/api-server.d.ts.map +1 -0
  11. package/dist/api-server.js +351 -0
  12. package/dist/api-server.js.map +1 -0
  13. package/dist/boot-env.d.ts +2 -0
  14. package/dist/boot-env.d.ts.map +1 -0
  15. package/dist/boot-env.js +12 -0
  16. package/dist/boot-env.js.map +1 -0
  17. package/dist/engine/__tests__/search-core.test.d.ts +2 -0
  18. package/dist/engine/__tests__/search-core.test.d.ts.map +1 -0
  19. package/dist/engine/__tests__/search-core.test.js +139 -0
  20. package/dist/engine/__tests__/search-core.test.js.map +1 -0
  21. package/dist/engine/ai-provider.d.ts +45 -0
  22. package/dist/engine/ai-provider.d.ts.map +1 -0
  23. package/dist/engine/ai-provider.js +5 -0
  24. package/dist/engine/ai-provider.js.map +1 -0
  25. package/dist/engine/embeddings.d.ts +51 -0
  26. package/dist/engine/embeddings.d.ts.map +1 -0
  27. package/dist/engine/embeddings.js +89 -0
  28. package/dist/engine/embeddings.js.map +1 -0
  29. package/dist/engine/providers/__tests__/failure-policy.test.d.ts +2 -0
  30. package/dist/engine/providers/__tests__/failure-policy.test.d.ts.map +1 -0
  31. package/dist/engine/providers/__tests__/failure-policy.test.js +134 -0
  32. package/dist/engine/providers/__tests__/failure-policy.test.js.map +1 -0
  33. package/dist/engine/providers/__tests__/model-caps.test.d.ts +2 -0
  34. package/dist/engine/providers/__tests__/model-caps.test.d.ts.map +1 -0
  35. package/dist/engine/providers/__tests__/model-caps.test.js +38 -0
  36. package/dist/engine/providers/__tests__/model-caps.test.js.map +1 -0
  37. package/dist/engine/providers/__tests__/openai-structured.test.d.ts +2 -0
  38. package/dist/engine/providers/__tests__/openai-structured.test.d.ts.map +1 -0
  39. package/dist/engine/providers/__tests__/openai-structured.test.js +73 -0
  40. package/dist/engine/providers/__tests__/openai-structured.test.js.map +1 -0
  41. package/dist/engine/providers/__tests__/usage-ledger.test.d.ts +2 -0
  42. package/dist/engine/providers/__tests__/usage-ledger.test.d.ts.map +1 -0
  43. package/dist/engine/providers/__tests__/usage-ledger.test.js +108 -0
  44. package/dist/engine/providers/__tests__/usage-ledger.test.js.map +1 -0
  45. package/dist/engine/providers/anthropic.d.ts +17 -0
  46. package/dist/engine/providers/anthropic.d.ts.map +1 -0
  47. package/dist/engine/providers/anthropic.js +125 -0
  48. package/dist/engine/providers/anthropic.js.map +1 -0
  49. package/dist/engine/providers/failure-policy.d.ts +56 -0
  50. package/dist/engine/providers/failure-policy.d.ts.map +1 -0
  51. package/dist/engine/providers/failure-policy.js +120 -0
  52. package/dist/engine/providers/failure-policy.js.map +1 -0
  53. package/dist/engine/providers/gemini.d.ts +22 -0
  54. package/dist/engine/providers/gemini.d.ts.map +1 -0
  55. package/dist/engine/providers/gemini.js +180 -0
  56. package/dist/engine/providers/gemini.js.map +1 -0
  57. package/dist/engine/providers/index.d.ts +8 -0
  58. package/dist/engine/providers/index.d.ts.map +1 -0
  59. package/dist/engine/providers/index.js +67 -0
  60. package/dist/engine/providers/index.js.map +1 -0
  61. package/dist/engine/providers/local.d.ts +12 -0
  62. package/dist/engine/providers/local.d.ts.map +1 -0
  63. package/dist/engine/providers/local.js +46 -0
  64. package/dist/engine/providers/local.js.map +1 -0
  65. package/dist/engine/providers/model-caps.d.ts +6 -0
  66. package/dist/engine/providers/model-caps.d.ts.map +1 -0
  67. package/dist/engine/providers/model-caps.js +49 -0
  68. package/dist/engine/providers/model-caps.js.map +1 -0
  69. package/dist/engine/providers/openai.d.ts +30 -0
  70. package/dist/engine/providers/openai.d.ts.map +1 -0
  71. package/dist/engine/providers/openai.js +309 -0
  72. package/dist/engine/providers/openai.js.map +1 -0
  73. package/dist/engine/providers/usage-ledger.d.ts +69 -0
  74. package/dist/engine/providers/usage-ledger.d.ts.map +1 -0
  75. package/dist/engine/providers/usage-ledger.js +209 -0
  76. package/dist/engine/providers/usage-ledger.js.map +1 -0
  77. package/dist/engine/search-core.d.ts +40 -0
  78. package/dist/engine/search-core.d.ts.map +1 -0
  79. package/dist/engine/search-core.js +109 -0
  80. package/dist/engine/search-core.js.map +1 -0
  81. package/dist/engine/vector-math.d.ts +5 -0
  82. package/dist/engine/vector-math.d.ts.map +1 -0
  83. package/dist/engine/vector-math.js +25 -0
  84. package/dist/engine/vector-math.js.map +1 -0
  85. package/dist/home-env.d.ts +26 -0
  86. package/dist/home-env.d.ts.map +1 -0
  87. package/dist/home-env.js +87 -0
  88. package/dist/home-env.js.map +1 -0
  89. package/dist/mcp-server.d.ts +13 -0
  90. package/dist/mcp-server.d.ts.map +1 -0
  91. package/dist/mcp-server.js +79 -0
  92. package/dist/mcp-server.js.map +1 -0
  93. package/dist/middleware/auth.d.ts +23 -0
  94. package/dist/middleware/auth.d.ts.map +1 -0
  95. package/dist/middleware/auth.js +104 -0
  96. package/dist/middleware/auth.js.map +1 -0
  97. package/dist/middleware/auto-recall.d.ts +7 -0
  98. package/dist/middleware/auto-recall.d.ts.map +1 -0
  99. package/dist/middleware/auto-recall.js +257 -0
  100. package/dist/middleware/auto-recall.js.map +1 -0
  101. package/dist/middleware/auto-reflect.d.ts +4 -0
  102. package/dist/middleware/auto-reflect.d.ts.map +1 -0
  103. package/dist/middleware/auto-reflect.js +5 -0
  104. package/dist/middleware/auto-reflect.js.map +1 -0
  105. package/dist/middleware/reflect/apply-flag-verdict.d.ts +27 -0
  106. package/dist/middleware/reflect/apply-flag-verdict.d.ts.map +1 -0
  107. package/dist/middleware/reflect/apply-flag-verdict.js +57 -0
  108. package/dist/middleware/reflect/apply-flag-verdict.js.map +1 -0
  109. package/dist/middleware/reflect/arc-entity-resolve.d.ts +29 -0
  110. package/dist/middleware/reflect/arc-entity-resolve.d.ts.map +1 -0
  111. package/dist/middleware/reflect/arc-entity-resolve.js +356 -0
  112. package/dist/middleware/reflect/arc-entity-resolve.js.map +1 -0
  113. package/dist/middleware/reflect/arc-inactivity-timer.d.ts +47 -0
  114. package/dist/middleware/reflect/arc-inactivity-timer.d.ts.map +1 -0
  115. package/dist/middleware/reflect/arc-inactivity-timer.js +175 -0
  116. package/dist/middleware/reflect/arc-inactivity-timer.js.map +1 -0
  117. package/dist/middleware/reflect/arc-pipeline.d.ts +33 -0
  118. package/dist/middleware/reflect/arc-pipeline.d.ts.map +1 -0
  119. package/dist/middleware/reflect/arc-pipeline.js +498 -0
  120. package/dist/middleware/reflect/arc-pipeline.js.map +1 -0
  121. package/dist/middleware/reflect/comprehend-pergroup.d.ts +100 -0
  122. package/dist/middleware/reflect/comprehend-pergroup.d.ts.map +1 -0
  123. package/dist/middleware/reflect/comprehend-pergroup.js +610 -0
  124. package/dist/middleware/reflect/comprehend-pergroup.js.map +1 -0
  125. package/dist/middleware/reflect/comprehend.d.ts +237 -0
  126. package/dist/middleware/reflect/comprehend.d.ts.map +1 -0
  127. package/dist/middleware/reflect/comprehend.js +706 -0
  128. package/dist/middleware/reflect/comprehend.js.map +1 -0
  129. package/dist/middleware/reflect/config.d.ts +34 -0
  130. package/dist/middleware/reflect/config.d.ts.map +1 -0
  131. package/dist/middleware/reflect/config.js +131 -0
  132. package/dist/middleware/reflect/config.js.map +1 -0
  133. package/dist/middleware/reflect/context.d.ts +138 -0
  134. package/dist/middleware/reflect/context.d.ts.map +1 -0
  135. package/dist/middleware/reflect/context.js +619 -0
  136. package/dist/middleware/reflect/context.js.map +1 -0
  137. package/dist/middleware/reflect/cost-breakdown.d.ts +69 -0
  138. package/dist/middleware/reflect/cost-breakdown.d.ts.map +1 -0
  139. package/dist/middleware/reflect/cost-breakdown.js +63 -0
  140. package/dist/middleware/reflect/cost-breakdown.js.map +1 -0
  141. package/dist/middleware/reflect/cost-guard.d.ts +102 -0
  142. package/dist/middleware/reflect/cost-guard.d.ts.map +1 -0
  143. package/dist/middleware/reflect/cost-guard.js +243 -0
  144. package/dist/middleware/reflect/cost-guard.js.map +1 -0
  145. package/dist/middleware/reflect/cost-pricing.d.ts +54 -0
  146. package/dist/middleware/reflect/cost-pricing.d.ts.map +1 -0
  147. package/dist/middleware/reflect/cost-pricing.js +148 -0
  148. package/dist/middleware/reflect/cost-pricing.js.map +1 -0
  149. package/dist/middleware/reflect/cross-group-link.d.ts +61 -0
  150. package/dist/middleware/reflect/cross-group-link.d.ts.map +1 -0
  151. package/dist/middleware/reflect/cross-group-link.js +212 -0
  152. package/dist/middleware/reflect/cross-group-link.js.map +1 -0
  153. package/dist/middleware/reflect/dedup-by-source-and-value.d.ts +70 -0
  154. package/dist/middleware/reflect/dedup-by-source-and-value.d.ts.map +1 -0
  155. package/dist/middleware/reflect/dedup-by-source-and-value.js +0 -0
  156. package/dist/middleware/reflect/dedup-by-source-and-value.js.map +1 -0
  157. package/dist/middleware/reflect/describe-roots.d.ts +58 -0
  158. package/dist/middleware/reflect/describe-roots.d.ts.map +1 -0
  159. package/dist/middleware/reflect/describe-roots.js +266 -0
  160. package/dist/middleware/reflect/describe-roots.js.map +1 -0
  161. package/dist/middleware/reflect/flag-reviewer-startup.d.ts +16 -0
  162. package/dist/middleware/reflect/flag-reviewer-startup.d.ts.map +1 -0
  163. package/dist/middleware/reflect/flag-reviewer-startup.js +107 -0
  164. package/dist/middleware/reflect/flag-reviewer-startup.js.map +1 -0
  165. package/dist/middleware/reflect/flag-reviewer.d.ts +69 -0
  166. package/dist/middleware/reflect/flag-reviewer.d.ts.map +1 -0
  167. package/dist/middleware/reflect/flag-reviewer.js +520 -0
  168. package/dist/middleware/reflect/flag-reviewer.js.map +1 -0
  169. package/dist/middleware/reflect/inline-dedup.d.ts +26 -0
  170. package/dist/middleware/reflect/inline-dedup.d.ts.map +1 -0
  171. package/dist/middleware/reflect/inline-dedup.js +131 -0
  172. package/dist/middleware/reflect/inline-dedup.js.map +1 -0
  173. package/dist/middleware/reflect/justify-decisions.d.ts +37 -0
  174. package/dist/middleware/reflect/justify-decisions.d.ts.map +1 -0
  175. package/dist/middleware/reflect/justify-decisions.js +159 -0
  176. package/dist/middleware/reflect/justify-decisions.js.map +1 -0
  177. package/dist/middleware/reflect/nl-accept.d.ts +35 -0
  178. package/dist/middleware/reflect/nl-accept.d.ts.map +1 -0
  179. package/dist/middleware/reflect/nl-accept.js +167 -0
  180. package/dist/middleware/reflect/nl-accept.js.map +1 -0
  181. package/dist/middleware/reflect/pass0.d.ts +20 -0
  182. package/dist/middleware/reflect/pass0.d.ts.map +1 -0
  183. package/dist/middleware/reflect/pass0.js +423 -0
  184. package/dist/middleware/reflect/pass0.js.map +1 -0
  185. package/dist/middleware/reflect/pass1.d.ts +17 -0
  186. package/dist/middleware/reflect/pass1.d.ts.map +1 -0
  187. package/dist/middleware/reflect/pass1.js +241 -0
  188. package/dist/middleware/reflect/pass1.js.map +1 -0
  189. package/dist/middleware/reflect/pass2-quarantine.d.ts +129 -0
  190. package/dist/middleware/reflect/pass2-quarantine.d.ts.map +1 -0
  191. package/dist/middleware/reflect/pass2-quarantine.js +272 -0
  192. package/dist/middleware/reflect/pass2-quarantine.js.map +1 -0
  193. package/dist/middleware/reflect/pass2-seams.d.ts +205 -0
  194. package/dist/middleware/reflect/pass2-seams.d.ts.map +1 -0
  195. package/dist/middleware/reflect/pass2-seams.js +279 -0
  196. package/dist/middleware/reflect/pass2-seams.js.map +1 -0
  197. package/dist/middleware/reflect/pass2-split-orchestrator.d.ts +37 -0
  198. package/dist/middleware/reflect/pass2-split-orchestrator.d.ts.map +1 -0
  199. package/dist/middleware/reflect/pass2-split-orchestrator.js +531 -0
  200. package/dist/middleware/reflect/pass2-split-orchestrator.js.map +1 -0
  201. package/dist/middleware/reflect/pass2.d.ts +17 -0
  202. package/dist/middleware/reflect/pass2.d.ts.map +1 -0
  203. package/dist/middleware/reflect/pass2.js +324 -0
  204. package/dist/middleware/reflect/pass2.js.map +1 -0
  205. package/dist/middleware/reflect/pass2a.d.ts +141 -0
  206. package/dist/middleware/reflect/pass2a.d.ts.map +1 -0
  207. package/dist/middleware/reflect/pass2a.js +404 -0
  208. package/dist/middleware/reflect/pass2a.js.map +1 -0
  209. package/dist/middleware/reflect/pass2b.d.ts +108 -0
  210. package/dist/middleware/reflect/pass2b.d.ts.map +1 -0
  211. package/dist/middleware/reflect/pass2b.js +480 -0
  212. package/dist/middleware/reflect/pass2b.js.map +1 -0
  213. package/dist/middleware/reflect/pass2c.d.ts +113 -0
  214. package/dist/middleware/reflect/pass2c.d.ts.map +1 -0
  215. package/dist/middleware/reflect/pass2c.js +360 -0
  216. package/dist/middleware/reflect/pass2c.js.map +1 -0
  217. package/dist/middleware/reflect/pass3-batch.d.ts +62 -0
  218. package/dist/middleware/reflect/pass3-batch.d.ts.map +1 -0
  219. package/dist/middleware/reflect/pass3-batch.js +139 -0
  220. package/dist/middleware/reflect/pass3-batch.js.map +1 -0
  221. package/dist/middleware/reflect/pass3.d.ts +23 -0
  222. package/dist/middleware/reflect/pass3.d.ts.map +1 -0
  223. package/dist/middleware/reflect/pass3.js +371 -0
  224. package/dist/middleware/reflect/pass3.js.map +1 -0
  225. package/dist/middleware/reflect/pass4-slice.d.ts +25 -0
  226. package/dist/middleware/reflect/pass4-slice.d.ts.map +1 -0
  227. package/dist/middleware/reflect/pass4-slice.js +315 -0
  228. package/dist/middleware/reflect/pass4-slice.js.map +1 -0
  229. package/dist/middleware/reflect/pass4.d.ts +30 -0
  230. package/dist/middleware/reflect/pass4.d.ts.map +1 -0
  231. package/dist/middleware/reflect/pass4.js +193 -0
  232. package/dist/middleware/reflect/pass4.js.map +1 -0
  233. package/dist/middleware/reflect/pass5.d.ts +22 -0
  234. package/dist/middleware/reflect/pass5.d.ts.map +1 -0
  235. package/dist/middleware/reflect/pass5.js +178 -0
  236. package/dist/middleware/reflect/pass5.js.map +1 -0
  237. package/dist/middleware/reflect/pass_judge.d.ts +44 -0
  238. package/dist/middleware/reflect/pass_judge.d.ts.map +1 -0
  239. package/dist/middleware/reflect/pass_judge.js +263 -0
  240. package/dist/middleware/reflect/pass_judge.js.map +1 -0
  241. package/dist/middleware/reflect/pipeline-flags.d.ts +140 -0
  242. package/dist/middleware/reflect/pipeline-flags.d.ts.map +1 -0
  243. package/dist/middleware/reflect/pipeline-flags.js +314 -0
  244. package/dist/middleware/reflect/pipeline-flags.js.map +1 -0
  245. package/dist/middleware/reflect/pipeline.d.ts +237 -0
  246. package/dist/middleware/reflect/pipeline.d.ts.map +1 -0
  247. package/dist/middleware/reflect/pipeline.js +3114 -0
  248. package/dist/middleware/reflect/pipeline.js.map +1 -0
  249. package/dist/middleware/reflect/promptOverride.d.ts +14 -0
  250. package/dist/middleware/reflect/promptOverride.d.ts.map +1 -0
  251. package/dist/middleware/reflect/promptOverride.js +28 -0
  252. package/dist/middleware/reflect/promptOverride.js.map +1 -0
  253. package/dist/middleware/reflect/provenance-check.d.ts +48 -0
  254. package/dist/middleware/reflect/provenance-check.d.ts.map +1 -0
  255. package/dist/middleware/reflect/provenance-check.js +180 -0
  256. package/dist/middleware/reflect/provenance-check.js.map +1 -0
  257. package/dist/middleware/reflect/provenance-reviewer.d.ts +52 -0
  258. package/dist/middleware/reflect/provenance-reviewer.d.ts.map +1 -0
  259. package/dist/middleware/reflect/provenance-reviewer.js +253 -0
  260. package/dist/middleware/reflect/provenance-reviewer.js.map +1 -0
  261. package/dist/middleware/reflect/prune-collapsed-types.d.ts +11 -0
  262. package/dist/middleware/reflect/prune-collapsed-types.d.ts.map +1 -0
  263. package/dist/middleware/reflect/prune-collapsed-types.js +32 -0
  264. package/dist/middleware/reflect/prune-collapsed-types.js.map +1 -0
  265. package/dist/middleware/reflect/recognize-root.d.ts +75 -0
  266. package/dist/middleware/reflect/recognize-root.d.ts.map +1 -0
  267. package/dist/middleware/reflect/recognize-root.js +204 -0
  268. package/dist/middleware/reflect/recognize-root.js.map +1 -0
  269. package/dist/middleware/reflect/render-agent-flag.d.ts +25 -0
  270. package/dist/middleware/reflect/render-agent-flag.d.ts.map +1 -0
  271. package/dist/middleware/reflect/render-agent-flag.js +39 -0
  272. package/dist/middleware/reflect/render-agent-flag.js.map +1 -0
  273. package/dist/middleware/reflect/retrieve-graph-slice.d.ts +54 -0
  274. package/dist/middleware/reflect/retrieve-graph-slice.d.ts.map +1 -0
  275. package/dist/middleware/reflect/retrieve-graph-slice.js +173 -0
  276. package/dist/middleware/reflect/retrieve-graph-slice.js.map +1 -0
  277. package/dist/middleware/reflect/root-relatedness.d.ts +31 -0
  278. package/dist/middleware/reflect/root-relatedness.d.ts.map +1 -0
  279. package/dist/middleware/reflect/root-relatedness.js +92 -0
  280. package/dist/middleware/reflect/root-relatedness.js.map +1 -0
  281. package/dist/middleware/reflect/schema-heal.d.ts +22 -0
  282. package/dist/middleware/reflect/schema-heal.d.ts.map +1 -0
  283. package/dist/middleware/reflect/schema-heal.js +119 -0
  284. package/dist/middleware/reflect/schema-heal.js.map +1 -0
  285. package/dist/middleware/reflect/schema-validator.d.ts +85 -0
  286. package/dist/middleware/reflect/schema-validator.d.ts.map +1 -0
  287. package/dist/middleware/reflect/schema-validator.js +196 -0
  288. package/dist/middleware/reflect/schema-validator.js.map +1 -0
  289. package/dist/middleware/reflect/stage-audit-graph.d.ts +115 -0
  290. package/dist/middleware/reflect/stage-audit-graph.d.ts.map +1 -0
  291. package/dist/middleware/reflect/stage-audit-graph.js +563 -0
  292. package/dist/middleware/reflect/stage-audit-graph.js.map +1 -0
  293. package/dist/middleware/reflect/stage-d-resolve-graph.d.ts +87 -0
  294. package/dist/middleware/reflect/stage-d-resolve-graph.d.ts.map +1 -0
  295. package/dist/middleware/reflect/stage-d-resolve-graph.js +256 -0
  296. package/dist/middleware/reflect/stage-d-resolve-graph.js.map +1 -0
  297. package/dist/middleware/reflect/synthesizeFromSceneCard.d.ts +15 -0
  298. package/dist/middleware/reflect/synthesizeFromSceneCard.d.ts.map +1 -0
  299. package/dist/middleware/reflect/synthesizeFromSceneCard.js +91 -0
  300. package/dist/middleware/reflect/synthesizeFromSceneCard.js.map +1 -0
  301. package/dist/middleware/reflect/types.d.ts +261 -0
  302. package/dist/middleware/reflect/types.d.ts.map +1 -0
  303. package/dist/middleware/reflect/types.js +3 -0
  304. package/dist/middleware/reflect/types.js.map +1 -0
  305. package/dist/middleware/reflect/v2-integrate.d.ts +120 -0
  306. package/dist/middleware/reflect/v2-integrate.d.ts.map +1 -0
  307. package/dist/middleware/reflect/v2-integrate.js +388 -0
  308. package/dist/middleware/reflect/v2-integrate.js.map +1 -0
  309. package/dist/middleware/reflect/v2-judge.d.ts +44 -0
  310. package/dist/middleware/reflect/v2-judge.d.ts.map +1 -0
  311. package/dist/middleware/reflect/v2-judge.js +191 -0
  312. package/dist/middleware/reflect/v2-judge.js.map +1 -0
  313. package/dist/relation-sets.d.ts +2 -0
  314. package/dist/relation-sets.d.ts.map +1 -0
  315. package/dist/relation-sets.js +35 -0
  316. package/dist/relation-sets.js.map +1 -0
  317. package/dist/routes/__tests__/flags.test.d.ts +2 -0
  318. package/dist/routes/__tests__/flags.test.d.ts.map +1 -0
  319. package/dist/routes/__tests__/flags.test.js +257 -0
  320. package/dist/routes/__tests__/flags.test.js.map +1 -0
  321. package/dist/routes/__tests__/models-catalog.test.d.ts +2 -0
  322. package/dist/routes/__tests__/models-catalog.test.d.ts.map +1 -0
  323. package/dist/routes/__tests__/models-catalog.test.js +130 -0
  324. package/dist/routes/__tests__/models-catalog.test.js.map +1 -0
  325. package/dist/routes/__tests__/reflect-pause-drain.test.d.ts +2 -0
  326. package/dist/routes/__tests__/reflect-pause-drain.test.d.ts.map +1 -0
  327. package/dist/routes/__tests__/reflect-pause-drain.test.js +38 -0
  328. package/dist/routes/__tests__/reflect-pause-drain.test.js.map +1 -0
  329. package/dist/routes/__tests__/spend-pause-drain.test.d.ts +2 -0
  330. package/dist/routes/__tests__/spend-pause-drain.test.d.ts.map +1 -0
  331. package/dist/routes/__tests__/spend-pause-drain.test.js +38 -0
  332. package/dist/routes/__tests__/spend-pause-drain.test.js.map +1 -0
  333. package/dist/routes/admin.d.ts +49 -0
  334. package/dist/routes/admin.d.ts.map +1 -0
  335. package/dist/routes/admin.js +471 -0
  336. package/dist/routes/admin.js.map +1 -0
  337. package/dist/routes/blocks.d.ts +4 -0
  338. package/dist/routes/blocks.d.ts.map +1 -0
  339. package/dist/routes/blocks.js +893 -0
  340. package/dist/routes/blocks.js.map +1 -0
  341. package/dist/routes/chat-proxy.d.ts +5 -0
  342. package/dist/routes/chat-proxy.d.ts.map +1 -0
  343. package/dist/routes/chat-proxy.js +225 -0
  344. package/dist/routes/chat-proxy.js.map +1 -0
  345. package/dist/routes/conversations.d.ts +4 -0
  346. package/dist/routes/conversations.d.ts.map +1 -0
  347. package/dist/routes/conversations.js +139 -0
  348. package/dist/routes/conversations.js.map +1 -0
  349. package/dist/routes/flags.d.ts +4 -0
  350. package/dist/routes/flags.d.ts.map +1 -0
  351. package/dist/routes/flags.js +151 -0
  352. package/dist/routes/flags.js.map +1 -0
  353. package/dist/routes/inject.d.ts +4 -0
  354. package/dist/routes/inject.d.ts.map +1 -0
  355. package/dist/routes/inject.js +183 -0
  356. package/dist/routes/inject.js.map +1 -0
  357. package/dist/routes/mcp-http.d.ts +5 -0
  358. package/dist/routes/mcp-http.d.ts.map +1 -0
  359. package/dist/routes/mcp-http.js +94 -0
  360. package/dist/routes/mcp-http.js.map +1 -0
  361. package/dist/routes/quarantine.d.ts +4 -0
  362. package/dist/routes/quarantine.d.ts.map +1 -0
  363. package/dist/routes/quarantine.js +66 -0
  364. package/dist/routes/quarantine.js.map +1 -0
  365. package/dist/routes/recall.d.ts +5 -0
  366. package/dist/routes/recall.d.ts.map +1 -0
  367. package/dist/routes/recall.js +573 -0
  368. package/dist/routes/recall.js.map +1 -0
  369. package/dist/routes/reflect.d.ts +5 -0
  370. package/dist/routes/reflect.d.ts.map +1 -0
  371. package/dist/routes/reflect.js +231 -0
  372. package/dist/routes/reflect.js.map +1 -0
  373. package/dist/routes/session.d.ts +4 -0
  374. package/dist/routes/session.d.ts.map +1 -0
  375. package/dist/routes/session.js +418 -0
  376. package/dist/routes/session.js.map +1 -0
  377. package/dist/routes/state.d.ts +116 -0
  378. package/dist/routes/state.d.ts.map +1 -0
  379. package/dist/routes/state.js +621 -0
  380. package/dist/routes/state.js.map +1 -0
  381. package/dist/routes/usage.d.ts +3 -0
  382. package/dist/routes/usage.d.ts.map +1 -0
  383. package/dist/routes/usage.js +141 -0
  384. package/dist/routes/usage.js.map +1 -0
  385. package/dist/routes/workspace.d.ts +5 -0
  386. package/dist/routes/workspace.d.ts.map +1 -0
  387. package/dist/routes/workspace.js +435 -0
  388. package/dist/routes/workspace.js.map +1 -0
  389. package/dist/server.d.ts +13 -0
  390. package/dist/server.d.ts.map +1 -0
  391. package/dist/server.js +298 -0
  392. package/dist/server.js.map +1 -0
  393. package/dist/store/__tests__/backup.test.d.ts +2 -0
  394. package/dist/store/__tests__/backup.test.d.ts.map +1 -0
  395. package/dist/store/__tests__/backup.test.js +53 -0
  396. package/dist/store/__tests__/backup.test.js.map +1 -0
  397. package/dist/store/__tests__/quality.test.d.ts +2 -0
  398. package/dist/store/__tests__/quality.test.d.ts.map +1 -0
  399. package/dist/store/__tests__/quality.test.js +75 -0
  400. package/dist/store/__tests__/quality.test.js.map +1 -0
  401. package/dist/store/backup.d.ts +14 -0
  402. package/dist/store/backup.d.ts.map +1 -0
  403. package/dist/store/backup.js +95 -0
  404. package/dist/store/backup.js.map +1 -0
  405. package/dist/store/database.d.ts +407 -0
  406. package/dist/store/database.d.ts.map +1 -0
  407. package/dist/store/database.js +2004 -0
  408. package/dist/store/database.js.map +1 -0
  409. package/dist/store/quality.d.ts +25 -0
  410. package/dist/store/quality.d.ts.map +1 -0
  411. package/dist/store/quality.js +48 -0
  412. package/dist/store/quality.js.map +1 -0
  413. package/dist/tools/__tests__/assemble-block-chains.test.d.ts +2 -0
  414. package/dist/tools/__tests__/assemble-block-chains.test.d.ts.map +1 -0
  415. package/dist/tools/__tests__/assemble-block-chains.test.js +118 -0
  416. package/dist/tools/__tests__/assemble-block-chains.test.js.map +1 -0
  417. package/dist/tools/__tests__/filter-roots-by-concepts.test.d.ts +2 -0
  418. package/dist/tools/__tests__/filter-roots-by-concepts.test.d.ts.map +1 -0
  419. package/dist/tools/__tests__/filter-roots-by-concepts.test.js +68 -0
  420. package/dist/tools/__tests__/filter-roots-by-concepts.test.js.map +1 -0
  421. package/dist/tools/__tests__/flag-surface.test.d.ts +2 -0
  422. package/dist/tools/__tests__/flag-surface.test.d.ts.map +1 -0
  423. package/dist/tools/__tests__/flag-surface.test.js +130 -0
  424. package/dist/tools/__tests__/flag-surface.test.js.map +1 -0
  425. package/dist/tools/core.d.ts +5 -0
  426. package/dist/tools/core.d.ts.map +1 -0
  427. package/dist/tools/core.js +962 -0
  428. package/dist/tools/core.js.map +1 -0
  429. package/dist/tools/derive.d.ts +5 -0
  430. package/dist/tools/derive.d.ts.map +1 -0
  431. package/dist/tools/derive.js +182 -0
  432. package/dist/tools/derive.js.map +1 -0
  433. package/dist/tools/flag-surface.d.ts +26 -0
  434. package/dist/tools/flag-surface.d.ts.map +1 -0
  435. package/dist/tools/flag-surface.js +59 -0
  436. package/dist/tools/flag-surface.js.map +1 -0
  437. package/dist/tools/helpers.d.ts +99 -0
  438. package/dist/tools/helpers.d.ts.map +1 -0
  439. package/dist/tools/helpers.js +243 -0
  440. package/dist/tools/helpers.js.map +1 -0
  441. package/dist/tools/projects.d.ts +5 -0
  442. package/dist/tools/projects.d.ts.map +1 -0
  443. package/dist/tools/projects.js +175 -0
  444. package/dist/tools/projects.js.map +1 -0
  445. package/dist/tools/system.d.ts +5 -0
  446. package/dist/tools/system.d.ts.map +1 -0
  447. package/dist/tools/system.js +1361 -0
  448. package/dist/tools/system.js.map +1 -0
  449. package/dist/tools/tasks.d.ts +5 -0
  450. package/dist/tools/tasks.d.ts.map +1 -0
  451. package/dist/tools/tasks.js +289 -0
  452. package/dist/tools/tasks.js.map +1 -0
  453. package/package.json +69 -0
  454. package/scripts/nodedex-entry.mjs +396 -0
  455. package/tui-dist/App.js +185 -0
  456. package/tui-dist/api.js +197 -0
  457. package/tui-dist/cli.js +53 -0
  458. package/tui-dist/components.js +63 -0
  459. package/tui-dist/config.js +242 -0
  460. package/tui-dist/connect-snippets.js +98 -0
  461. package/tui-dist/feed.js +51 -0
  462. package/tui-dist/health.js +465 -0
  463. package/tui-dist/hooks.js +23 -0
  464. package/tui-dist/memory.js +220 -0
  465. package/tui-dist/onboarding.js +498 -0
  466. package/tui-dist/review.js +193 -0
  467. package/tui-dist/servers.js +556 -0
  468. package/tui-dist/smoke.js +15 -0
  469. package/tui-dist/theme.js +106 -0
@@ -0,0 +1,3114 @@
1
+ import { stampQualityScore } from "../../store/quality.js";
2
+ import { getLLMProvider } from "../../engine/providers/index.js";
3
+ import { blockEmbeddingText } from "../../engine/embeddings.js";
4
+ import { v4 as uuidv4 } from "uuid";
5
+ import * as fs from "fs";
6
+ import * as path from "path";
7
+ import { buildProjectContext, buildPreSearchContext, buildDuplicateAlerts, buildItemContext, reflectTokenStats, embeddingStats } from "./context.js";
8
+ import { getThinkingBudget } from "./config.js";
9
+ import { callPass2LLM } from "./pass2.js";
10
+ import { runPass2Split } from "./pass2-split-orchestrator.js";
11
+ import { buildCostBreakdown } from "./cost-breakdown.js";
12
+ import { validateUniqueSchema, schemaMismatchReason, demoteForSave } from "./schema-validator.js";
13
+ import { callPass3Batched } from "./pass3-batch.js";
14
+ import { applyArcEntityCanonicalNames } from "./arc-entity-resolve.js";
15
+ import { recognizeRootsForArc, applyRootRemap, recognizerEnabled } from "./recognize-root.js";
16
+ import { resolveArcEntitiesForItems } from "./stage-d-resolve-graph.js";
17
+ import { callPass4LLM, chunkForPass4 } from "./pass4.js";
18
+ import { buildPass4Slice, pass4SliceEnabled, pass4SliceMinGraph } from "./pass4-slice.js";
19
+ import { callPass5LLM } from "./pass5.js";
20
+ import { CAUSAL_TRAVERSAL_RELS } from "../../relation-sets.js";
21
+ import { dedupBySourceAndValue } from "./dedup-by-source-and-value.js";
22
+ import { writePipelineFlag } from "./pipeline-flags.js";
23
+ import { flagBlockExcerptInline } from "./provenance-check.js";
24
+ import { inlineDedupEnabled, dedupNewBlocksInline } from "./inline-dedup.js";
25
+ export { reflectTokenStats };
26
+ // ─── Reflect debug log ────────────────────────────────────────────────────────
27
+ const REFLECT_LOG_PATH = path.join(process.cwd(), "data", "reflect-last.json");
28
+ const REFLECT_TURNS_DIR = path.join(process.cwd(), "data", "reflect-turns");
29
+ export function getReflectLogPath() { return REFLECT_LOG_PATH; }
30
+ // Turn-log sequence number. Two failure modes fixed 2026-06-12:
31
+ // (1) the old `if (!checkpoint) _turnCounter++` guard meant v2 per-turn runs
32
+ // (which always arrive WITH a fresh checkpoint from the front-half) never
33
+ // advanced the counter → every run overwrote turn-00.json;
34
+ // (2) the counter was process-local starting at 0 → every server restart
35
+ // overwrote the previous process's logs.
36
+ // Now: the counter scan-initializes from the existing files once per process and
37
+ // increments at WRITE time — every written log gets a unique, monotonic file.
38
+ let _turnCounter = 0;
39
+ let _turnCounterInited = false;
40
+ /** Pure: next turn-log number given existing file names. Exported for tests. */
41
+ export function computeNextTurnNumber(names) {
42
+ let max = -1;
43
+ for (const n of names) {
44
+ const m = /^turn-(\d+)\.json$/.exec(n);
45
+ if (m) {
46
+ const v = parseInt(m[1], 10);
47
+ if (v > max)
48
+ max = v;
49
+ }
50
+ }
51
+ return max + 1;
52
+ }
53
+ /**
54
+ * Resolve a WITHIN-BATCH reference to a created block's label.
55
+ *
56
+ * A ref can be an item id — v1 `item_N` OR v2 `group::local_id` — or an
57
+ * existing graph label. The old resolvers gated on `startsWith("item_")`,
58
+ * a v1-ism: every v2 within-batch based_on / supersedes / semantic edge fell
59
+ * into the treat-as-label branch and was SILENTLY DROPPED at save
60
+ * (found 2026-06-13 via the graph-vs-log audit: all 5 decisions wired at item
61
+ * level, zero based_on edges in the graph; likely depressed v2 connectivity
62
+ * in every prior A/B). Item-map FIRST — it is authoritative for this batch,
63
+ * and demote-at-save updates it to the final label before it's read — then
64
+ * label fallback; an item-shaped ref that created no block must NEVER be
65
+ * treated as a label.
66
+ */
67
+ export function resolveWithinBatchRefLabel(ref, itemIdToLabel) {
68
+ const viaMap = itemIdToLabel.get(ref);
69
+ if (viaMap)
70
+ return { label: viaMap, viaItemMap: true };
71
+ if (ref.startsWith("item_") || ref.includes("::"))
72
+ return null; // item ref with no created block
73
+ return { label: ref, viaItemMap: false };
74
+ }
75
+ /** Wipes the per-turn log directory — call at benchmark start. */
76
+ export function clearTurnLogs() {
77
+ try {
78
+ if (fs.existsSync(REFLECT_TURNS_DIR)) {
79
+ for (const f of fs.readdirSync(REFLECT_TURNS_DIR)) {
80
+ fs.unlinkSync(path.join(REFLECT_TURNS_DIR, f));
81
+ }
82
+ }
83
+ _turnCounter = 0;
84
+ _turnCounterInited = true; // dir is known-empty — no rescan needed
85
+ }
86
+ catch { /* non-critical */ }
87
+ }
88
+ function writeReflectLog(entry) {
89
+ try {
90
+ fs.mkdirSync(path.dirname(REFLECT_LOG_PATH), { recursive: true });
91
+ fs.writeFileSync(REFLECT_LOG_PATH, JSON.stringify({ ts: new Date().toISOString(), ...entry }, null, 2));
92
+ }
93
+ catch { /* non-critical */ }
94
+ }
95
+ /**
96
+ * Recover a drifted/missing `from_item_id` on Pass-3 `new_blocks` by type-matching each
97
+ * unlinked block to an as-yet-unclaimed Pass-2 item of the same type. Mirrors the
98
+ * save-loop fallback, but is hoisted ahead of the mandatory-item accounting guard so a
99
+ * single drifted id no longer discards the entire arc (every correctly-built block
100
+ * included). Mutates `newBlocks` in place; returns the count recovered.
101
+ *
102
+ * Only touches blocks whose `from_item_id` is absent or points at no real Pass-2 item —
103
+ * a valid id is never reassigned. Matching is greedy against unclaimed items, so the SET
104
+ * of from_item_ids ends up covering every accounted item even if an individual pairing
105
+ * is approximate (the block content was already built correctly by Pass 3; this only
106
+ * re-establishes the item↔block join used for accounting, relation-wiring, provenance).
107
+ */
108
+ export function recoverDriftedFromItemIds(newBlocks, classified) {
109
+ if (!Array.isArray(newBlocks) || newBlocks.length === 0)
110
+ return 0;
111
+ const validIds = new Set(classified.map((i) => i.id));
112
+ const claimed = new Set(newBlocks
113
+ .map((b) => b.from_item_id)
114
+ .filter((id) => typeof id === "string" && validIds.has(id)));
115
+ let recovered = 0;
116
+ for (const b of newBlocks) {
117
+ if (typeof b.from_item_id === "string" && validIds.has(b.from_item_id))
118
+ continue;
119
+ const match = classified.find((i) => i.type === b.is_a && !claimed.has(i.id));
120
+ if (match) {
121
+ b.from_item_id = match.id;
122
+ claimed.add(match.id);
123
+ recovered++;
124
+ }
125
+ }
126
+ return recovered;
127
+ }
128
+ function writeTurnLog(turnData) {
129
+ try {
130
+ fs.mkdirSync(REFLECT_TURNS_DIR, { recursive: true });
131
+ if (!_turnCounterInited) {
132
+ _turnCounter = computeNextTurnNumber(fs.readdirSync(REFLECT_TURNS_DIR));
133
+ _turnCounterInited = true;
134
+ }
135
+ const n = String(_turnCounter).padStart(2, "0");
136
+ const turnPath = path.join(REFLECT_TURNS_DIR, `turn-${n}.json`);
137
+ // `turn` is the log SEQUENCE number (file identity), injected here so it can
138
+ // never disagree with the filename.
139
+ fs.writeFileSync(turnPath, JSON.stringify({ ...turnData, turn: _turnCounter }, null, 2));
140
+ _turnCounter++;
141
+ }
142
+ catch { /* non-critical */ }
143
+ }
144
+ // ─── Exported deterministic rules (tested in __tests__/pipeline-rules.test.ts) ─
145
+ /** Pre-populates extends_item from Pass 1 extends_id when Pass 2 left it blank. */
146
+ export function prePopulateExtendsItem(pass1Items, pass2Items) {
147
+ const pass1Map = new Map(pass1Items.map(i => [i.id, i]));
148
+ for (const classified of pass2Items) {
149
+ if (!classified.extends_item) {
150
+ const p1Item = pass1Map.get(classified.id);
151
+ if (p1Item?.extends_id)
152
+ classified.extends_item = p1Item.extends_id;
153
+ }
154
+ }
155
+ }
156
+ /**
157
+ * Seam contract: if Pass 2 changed an item's type but didn't set review_reason,
158
+ * stamp "type_override". Pass 2's prompt (pass2.ts:100) tells the model to set
159
+ * the flag when overriding, but the model sometimes forgets — and downstream
160
+ * consumers (Tier 1B validator, agent UI) can't distinguish a model-asserted
161
+ * reclassification from a kept Pass 1 type without it. Charter rule 4: don't
162
+ * coordinate via LLM restraint; enforce the seam contract in code.
163
+ *
164
+ * Stamp ONLY when (a) the Pass 1 item exists for this id, (b) types differ,
165
+ * (c) review_reason is empty — never overwrites a Pass 2-set reason (graph_align,
166
+ * novel_type, weak_match, etc.). Returns the count of items stamped.
167
+ */
168
+ export function stampTypeOverrides(pass1Items, pass2Items) {
169
+ const pass1TypeById = new Map(pass1Items.map(p1 => [p1.id, p1.provisional_type]));
170
+ let stamped = 0;
171
+ for (const item of pass2Items) {
172
+ const pass1Type = pass1TypeById.get(item.id);
173
+ if (pass1Type && pass1Type !== item.type && !item.review_reason) {
174
+ item.review_reason = "type_override";
175
+ stamped++;
176
+ }
177
+ }
178
+ return stamped;
179
+ }
180
+ /**
181
+ * Code dedup guard — collapses Pass 2 items with byte-identical normalized text but
182
+ * different type. This is the structurally-determined slice of cross-type dedup: the LLM
183
+ * emits the same sentence twice under two provisional types (e.g. fact + constraint), and
184
+ * Pass 2 inconsistently catches it (~1/3 of runs in the refund A/B). Identical text is not
185
+ * a semantic call — it is structural — so code is the right layer (charter rule 3).
186
+ *
187
+ * Charter compliance:
188
+ * - Rule 3 (match-to-competence): exact-text equality is structurally determined.
189
+ * - Rule 6 (catches a failure, never overrides a success): no-op when Pass 2 already
190
+ * collapsed the twins. Only fires on the failure case.
191
+ * - Rule 6 (falsifiable): the test is normalized-text equality — checkable.
192
+ * - Rule 7 (deterministic on probabilistic input): only catches IDENTICAL text;
193
+ * paraphrase/near-dup remains Pass 2's job (semantic judgment, LLM competence).
194
+ * The function deliberately does NOT extend to embedding similarity.
195
+ * - Identical text rules out a "genuine role-split": a real role-split has DIFFERENT
196
+ * content reflecting two different epistemic roles (e.g. decision "chose Y" + a
197
+ * separately-stated constraint Y the decision creates). Two items with byte-identical
198
+ * text are redundancy by definition, even if Pass 2 wired an edge between them — the
199
+ * edge is a self-referential redundancy, vestigial after collapse, and is dropped.
200
+ *
201
+ * Winner pick (when collapsing): prefer the item with non-empty causal wiring (it carries
202
+ * the structural role); else prefer a non-fact type (more-specific epistemic role —
203
+ * matches the LLM's own "kept as the more specific constraint type" choice in the firing
204
+ * runs); else earliest by id.
205
+ *
206
+ * Returns the kept set + a record of every drop with its merge target. Cross-references
207
+ * in surviving items' triggered_by_items / based_on_items are rewired from the dropped
208
+ * id to the winner; any self-reference that results from the merge is stripped.
209
+ */
210
+ export function dedupIdenticalEssenceTwins(items) {
211
+ const normalize = (s) => (s || "").toLowerCase().trim().replace(/\s+/g, " ");
212
+ const groups = new Map();
213
+ for (const item of items) {
214
+ const key = normalize(item.text);
215
+ if (!key)
216
+ continue;
217
+ const arr = groups.get(key);
218
+ if (arr)
219
+ arr.push(item);
220
+ else
221
+ groups.set(key, [item]);
222
+ }
223
+ const dropIds = new Set();
224
+ const dropped = [];
225
+ const replacement = new Map(); // dropped id → kept id, for rewiring
226
+ for (const group of groups.values()) {
227
+ if (group.length < 2)
228
+ continue;
229
+ const types = new Set(group.map(i => i.type));
230
+ if (types.size < 2)
231
+ continue; // same-type dups are Pass 2 STEP I's job, not ours
232
+ const hasWiring = (i) => (i.triggered_by_items?.length || 0) + (i.based_on_items?.length || 0) > 0;
233
+ const sorted = [...group].sort((a, b) => {
234
+ const aw = hasWiring(a) ? 1 : 0;
235
+ const bw = hasWiring(b) ? 1 : 0;
236
+ if (aw !== bw)
237
+ return bw - aw;
238
+ const aFact = a.type === "fact" ? 1 : 0;
239
+ const bFact = b.type === "fact" ? 1 : 0;
240
+ if (aFact !== bFact)
241
+ return aFact - bFact; // non-fact first
242
+ return a.id.localeCompare(b.id);
243
+ });
244
+ const winner = sorted[0];
245
+ for (let i = 1; i < sorted.length; i++) {
246
+ const loser = sorted[i];
247
+ dropIds.add(loser.id);
248
+ replacement.set(loser.id, winner.id);
249
+ dropped.push({
250
+ id: loser.id,
251
+ mergedInto: winner.id,
252
+ reason: `identical normalized text — ${loser.type} merged into ${winner.type}`,
253
+ });
254
+ }
255
+ }
256
+ if (dropIds.size === 0)
257
+ return { kept: items, dropped };
258
+ const kept = items
259
+ .filter(i => !dropIds.has(i.id))
260
+ .map(i => {
261
+ const rewire = (refs) =>
262
+ // Map dropped → winner, deduplicate, then drop any self-reference that emerged from the merge
263
+ Array.from(new Set((refs || []).map(ref => replacement.get(ref) ?? ref))).filter(r => r !== i.id);
264
+ // extends_item is a single within-batch id ref. Rewire dropped→winner; strip if the merge
265
+ // makes it self-referential — otherwise it dangles at a deleted twin → spurious
266
+ // extends_item_unresolved skip downstream (pipeline.ts resolution). supersedes_ref/resolved_ref
267
+ // are external graph LABELS, not within-batch ids, so they can't dangle here — left untouched.
268
+ const rewiredExtends = i.extends_item ? (replacement.get(i.extends_item) ?? i.extends_item) : i.extends_item;
269
+ return {
270
+ ...i,
271
+ triggered_by_items: rewire(i.triggered_by_items || []),
272
+ based_on_items: rewire(i.based_on_items || []),
273
+ extends_item: rewiredExtends === i.id ? undefined : rewiredExtends,
274
+ };
275
+ });
276
+ return { kept, dropped };
277
+ }
278
+ /**
279
+ * Tier 1C (2026-05-24): unique{}-content dedup.
280
+ *
281
+ * A block's identity is its content.unique{} (the structured data), NOT its
282
+ * essence (a paraphrasable summary — see [[feedback-identity-is-unique-not-label]]).
283
+ * dedupIdenticalEssenceTwins compares essence TEXT and so misses two real
284
+ * duplicate patterns:
285
+ * - CROSS-TYPE: same claim emitted under two type labels with different essence
286
+ * wording AND different field NAMES — e.g. decision{value:"Chalice",reason:…}
287
+ * vs dead_end{approach:"Chalice",reason:…}. Same data, different shape.
288
+ * - PARAPHRASE: same claim, same type, slightly different essence wording but
289
+ * identical unique{} values.
290
+ *
291
+ * This groups items by their normalized unique{} VALUE-SET. Field NAMES are
292
+ * ignored — only the values matter, because field names are interchangeable
293
+ * across types for the same claim. Identical value-sets collapse to one.
294
+ *
295
+ * Safe against FRAGMENTATION: fragments carry DIFFERENT unique{} values (different
296
+ * slices of one larger thing), so they never share a value-set key and are left
297
+ * untouched. Only EXACT normalized value-set matches collapse — which makes the
298
+ * false-positive risk near zero (byte-identical structured data IS the same claim).
299
+ *
300
+ * Winner selection prefers, in order: schema-valid (Tier 1B validateUniqueSchema)
301
+ * → has causal wiring → non-fact → lowest id. The schema-valid preference means
302
+ * the structurally-correct block survives (the dead_end{approach,reason} beats the
303
+ * mis-typed decision{value,reason} for the same Chalice claim).
304
+ *
305
+ * Runs after dedupIdenticalEssenceTwins, behind NODEDEX_CODE_DEDUP. Charter rule 7
306
+ * (deterministic guard on probabilistic input) + rule 2 (pre-commit collapse only).
307
+ */
308
+ export function dedupIdenticalUniqueValues(items) {
309
+ const normalize = (s) => (s || "").toLowerCase().trim().replace(/\s+/g, " ");
310
+ const valueSetKey = (item) => {
311
+ const u = item.unique || {};
312
+ const vals = [];
313
+ for (const v of Object.values(u)) {
314
+ if (v === null || v === undefined)
315
+ continue;
316
+ const s = normalize(String(v));
317
+ if (s === "")
318
+ continue;
319
+ vals.push(s);
320
+ }
321
+ if (vals.length === 0)
322
+ return ""; // nothing to compare (e.g. project/process)
323
+ return vals.sort().join(" ¦ ");
324
+ };
325
+ const groups = new Map();
326
+ for (const item of items) {
327
+ const key = valueSetKey(item);
328
+ if (!key)
329
+ continue;
330
+ const arr = groups.get(key);
331
+ if (arr)
332
+ arr.push(item);
333
+ else
334
+ groups.set(key, [item]);
335
+ }
336
+ const dropIds = new Set();
337
+ const dropped = [];
338
+ const replacement = new Map(); // dropped id → kept id, for rewiring
339
+ const schemaValid = (i) => validateUniqueSchema(i.type, i.unique || {}).ok;
340
+ const hasWiring = (i) => (i.triggered_by_items?.length || 0) + (i.based_on_items?.length || 0) > 0;
341
+ for (const group of groups.values()) {
342
+ if (group.length < 2)
343
+ continue;
344
+ const sorted = [...group].sort((a, b) => {
345
+ const av = schemaValid(a) ? 1 : 0;
346
+ const bv = schemaValid(b) ? 1 : 0;
347
+ if (av !== bv)
348
+ return bv - av; // schema-valid first
349
+ const aw = hasWiring(a) ? 1 : 0;
350
+ const bw = hasWiring(b) ? 1 : 0;
351
+ if (aw !== bw)
352
+ return bw - aw; // wired first
353
+ const aFact = a.type === "fact" ? 1 : 0;
354
+ const bFact = b.type === "fact" ? 1 : 0;
355
+ if (aFact !== bFact)
356
+ return aFact - bFact; // non-fact first
357
+ return a.id.localeCompare(b.id); // stable tiebreak
358
+ });
359
+ const winner = sorted[0];
360
+ for (let i = 1; i < sorted.length; i++) {
361
+ const loser = sorted[i];
362
+ dropIds.add(loser.id);
363
+ replacement.set(loser.id, winner.id);
364
+ const crossType = loser.type !== winner.type;
365
+ dropped.push({
366
+ id: loser.id,
367
+ mergedInto: winner.id,
368
+ reason: `identical unique{} value-set — ${loser.type} merged into ${winner.type}${crossType ? " (cross-type)" : " (same-type paraphrase)"}`,
369
+ });
370
+ }
371
+ }
372
+ if (dropIds.size === 0)
373
+ return { kept: items, dropped };
374
+ const kept = items
375
+ .filter(i => !dropIds.has(i.id))
376
+ .map(i => {
377
+ const rewire = (refs) =>
378
+ // Map dropped → winner, deduplicate, then drop any self-reference that emerged from the merge
379
+ Array.from(new Set((refs || []).map(ref => replacement.get(ref) ?? ref))).filter(r => r !== i.id);
380
+ // extends_item is a single within-batch id ref. Rewire dropped→winner; strip if the merge
381
+ // makes it self-referential — otherwise it dangles at a deleted twin → spurious
382
+ // extends_item_unresolved skip downstream (pipeline.ts resolution). supersedes_ref/resolved_ref
383
+ // are external graph LABELS, not within-batch ids, so they can't dangle here — left untouched.
384
+ const rewiredExtends = i.extends_item ? (replacement.get(i.extends_item) ?? i.extends_item) : i.extends_item;
385
+ return {
386
+ ...i,
387
+ triggered_by_items: rewire(i.triggered_by_items || []),
388
+ based_on_items: rewire(i.based_on_items || []),
389
+ extends_item: rewiredExtends === i.id ? undefined : rewiredExtends,
390
+ };
391
+ });
392
+ return { kept, dropped };
393
+ }
394
+ /**
395
+ * FOLD constituent reason-facts into the state-change unit they justify.
396
+ *
397
+ * The problem (traversal-proven 2026-06-04, [[project-fragmentation-not-worth-fold-2026-06-04]]):
398
+ * the pipeline's over-extraction is GRANULARITY, not WORTH. Pass 1 fragments one
399
+ * coherent unit — a decision/dead_end/constraint and its REASONS — into many
400
+ * separate blocks (e.g. decision "raised beds" + standalone facts "better
401
+ * drainage" / "soil control" / "easier weeding"). Every fragment is genuine
402
+ * residue, so JUDGE (which keeps/drops WHOLE items) correctly keeps them all and
403
+ * physically CANNOT consolidate. WORTH and GRANULARITY are different jobs at
404
+ * different seams: worth = JUDGE (residue vs scaffolding); granularity = here.
405
+ *
406
+ * The fix is FOLD, not dedup. Dedup = "A and B are the same claim, keep one."
407
+ * Fold = "B is A's REASON; it already belongs inside A's rationale — absorb it,
408
+ * don't scatter it as a redundant standalone block." Charter §3: the graph grows
409
+ * by ENRICHMENT, not just addition. A block that merely restates a parent's reason
410
+ * carries no distinct meaning forward.
411
+ *
412
+ * Why HERE (post-2c, pre-Pass-3) and not Pass 1/2a or JUDGE:
413
+ * - A fact sits on MULTIPLE within-batch paths; foldability needs the FULL wired
414
+ * relationship set, which only exists after Pass 2c wired causality. At
415
+ * classify time (2a) the path picture is unknown — you'd fold blind.
416
+ * - Pass 1 has weak restraint/decomposition (charter Rule 4: "a fix that relies
417
+ * on the LLM holding back will fail") — fragmentation ORIGINATES there.
418
+ * - JUDGE is worth, not granularity.
419
+ *
420
+ * Detection is STRUCTURAL / deterministic (charter Rule 3 — count wired edges,
421
+ * never semantic similarity). A fact F folds into unit U iff:
422
+ * (1) F.type === "fact" — only facts fold; units never do.
423
+ * (2) EXACTLY ONE classified item references F at all, counting based_on +
424
+ * triggered_by + extends_item — the "sole-constituent / no shared anchor"
425
+ * guard. >1 referrer → KEEP (a shared root like a shelf-life fact reused by
426
+ * two decisions). This is the un-fenced over-folding risk, made REAL by
427
+ * counting the wiring instead of guessing.
428
+ * (3) that single referrer U references F via based_on_items — the RATIONALE
429
+ * edge. We deliberately do NOT fold on triggered_by/extends alone: a fact
430
+ * that merely TRIGGERED a decision is often independently valuable (e.g.
431
+ * "budget cut 40%"), whereas a based_on fact is a pure justification. This
432
+ * is MORE conservative than the design memo's "based_on/prompted_by" wording
433
+ * — chosen on the asymmetric-cost rule (a false fold loses data permanently;
434
+ * a redundant block is mere clutter), matching the code-dedup stance above.
435
+ * (4) U.type ∈ {decision, dead_end, constraint} — only state-change units have a
436
+ * `reason` slot to absorb into.
437
+ * (5) F has NO outgoing reason edge (based_on/extends) — a true leaf. A fact with
438
+ * its own provenance is not a pure constituent; folding would dangle it.
439
+ *
440
+ * ENRICH, never delete (charter Rule 2 safe): F's content is appended to U's
441
+ * unique.reason (if not already substantively present — 2b often already filled
442
+ * it, in which case F was pure redundancy), then F is removed from the classified
443
+ * set so Pass 3 never builds it as a standalone block. Nothing is deleted — F was
444
+ * never a block; its meaning is RELOCATED into the unit it always belonged to.
445
+ *
446
+ * Source/mode-independent: the agent's own "I chose X because Y" folds the same as
447
+ * a user-sourced reason. Default OFF (NODEDEX_FOLD_CONSTITUENTS=1).
448
+ */
449
+ const FOLD_STATE_CHANGE_TYPES = new Set(["decision", "dead_end", "constraint"]);
450
+ export function foldConstituentFacts(items) {
451
+ const folded = [];
452
+ const foldedIds = new Set();
453
+ // Inverted reference index over the WIRED within-batch edges.
454
+ // - rationaleRefs[F] = units U with based_on F (F is U's justification).
455
+ // - anyRefs[F] = every item referencing F via based_on | triggered_by |
456
+ // extends — the "no shared anchor" guard population.
457
+ const rationaleRefs = new Map();
458
+ const anyRefs = new Map();
459
+ const push = (m, key, v) => {
460
+ const a = m.get(key);
461
+ if (a)
462
+ a.push(v);
463
+ else
464
+ m.set(key, [v]);
465
+ };
466
+ for (const u of items) {
467
+ for (const ref of u.based_on_items || []) {
468
+ push(rationaleRefs, ref, u);
469
+ push(anyRefs, ref, u);
470
+ }
471
+ for (const ref of u.triggered_by_items || []) {
472
+ push(anyRefs, ref, u);
473
+ }
474
+ if (u.extends_item)
475
+ push(anyRefs, u.extends_item, u);
476
+ }
477
+ for (const f of items) {
478
+ if (f.type !== "fact")
479
+ continue; // guard 1
480
+ const refs = anyRefs.get(f.id) || [];
481
+ if (refs.length !== 1)
482
+ continue; // guard 2 — sole referrer
483
+ const u = refs[0];
484
+ if (!(u.based_on_items || []).includes(f.id))
485
+ continue; // guard 3 — rationale edge
486
+ if (!FOLD_STATE_CHANGE_TYPES.has(u.type))
487
+ continue; // guard 4 — unit absorbs
488
+ if ((f.based_on_items || []).length > 0 || f.extends_item)
489
+ continue; // guard 5 — F is a leaf
490
+ // ENRICH (Rule 2 safe) — relocate F's claim into U's rationale. F's `text` is
491
+ // the human-readable claim at this stage (essence is derived later by Pass 3);
492
+ // fall back to the first non-empty unique{} value.
493
+ const claim = ((f.text || "").trim())
494
+ || (Object.values(f.unique || {}).map(v => (v ?? "").toString().trim()).find(v => v !== "") || "");
495
+ if (!u.unique)
496
+ u.unique = {};
497
+ const existing = (u.unique.reason || "").trim();
498
+ const normExisting = existing.toLowerCase().replace(/\s+/g, " ");
499
+ const normClaimHead = claim.toLowerCase().replace(/\s+/g, " ").slice(0, 40);
500
+ let enriched = false;
501
+ if (claim && normClaimHead && !normExisting.includes(normClaimHead)) {
502
+ u.unique.reason = existing ? `${existing}; ${claim}` : claim;
503
+ enriched = true;
504
+ }
505
+ // Drop F's now-absorbed edge from U so it does not dangle at an item Pass 3
506
+ // never builds. (By guard 2, U is the ONLY referrer — nothing else to rewire.)
507
+ u.based_on_items = (u.based_on_items || []).filter(r => r !== f.id);
508
+ foldedIds.add(f.id);
509
+ folded.push({
510
+ id: f.id,
511
+ foldedInto: u.id,
512
+ enriched,
513
+ reason: `constituent reason-fact folded into ${u.type} ${u.id}`
514
+ + (enriched ? " (rationale enriched)" : " (already in rationale — redundant block removed)"),
515
+ });
516
+ }
517
+ if (foldedIds.size === 0)
518
+ return { kept: items, folded };
519
+ return { kept: items.filter(i => !foldedIds.has(i.id)), folded };
520
+ }
521
+ /** Returns true if a block label already exists — label-level dedup. */
522
+ export function isDuplicateLabel(label, allBlocks) {
523
+ return allBlocks.some(b => b.label === label);
524
+ }
525
+ /**
526
+ * Commit this turn's pending blocks → 'active', but ONLY those STILL pending.
527
+ *
528
+ * A block created this turn (status='pending') can be archived in the interim by a
529
+ * SAME-TURN supersede: Pass 4 creates a `supersedes` edge, and database.ts:981 archives
530
+ * a superseded decision/blueprint. The old blind loop set every pending id to 'active'
531
+ * unconditionally → it RESURRECTED the just-archived block, undoing the supersede-archive
532
+ * (the activate-pending race; surfaced by the 5-turn deep test 2026-05-26, T5 retrospective).
533
+ *
534
+ * This rule lives HERE (the committer), not in the DB layer, deliberately: only the committer
535
+ * has the context "these are THIS turn's new blocks." The DB can't enforce "archived↛active"
536
+ * because that transition is legitimately used for reactivation-on-recreate (database.ts:603).
537
+ * The committer reads each block's CURRENT status (not the stale create-time snapshot) and
538
+ * commits only the still-pending ones; an interim archive is respected.
539
+ *
540
+ * Pure-ish: takes a minimal structural db (getBlock + updateBlock) so it's unit-testable
541
+ * without a real DB. Returns counts for logging/audit.
542
+ */
543
+ export function activatePendingBlocks(db, pendingIds) {
544
+ let activated = 0;
545
+ let skippedArchived = 0;
546
+ for (const id of pendingIds) {
547
+ const b = db.getBlock(id);
548
+ if (b && b.status === "pending") {
549
+ db.updateBlock(id, { status: "active" });
550
+ activated++;
551
+ }
552
+ else if (b && b.status === "archived") {
553
+ skippedArchived++; // archived in-turn (same-turn supersede) — leave it; never resurrect
554
+ }
555
+ }
556
+ return { activated, skippedArchived };
557
+ }
558
+ /**
559
+ * Relation dedup guard — prevents exact duplicate relations.
560
+ * Only skips when source + type + target are all identical.
561
+ * Does NOT collapse different relation types to the same target —
562
+ * prompted_by, based_on, and extends are semantically distinct even when
563
+ * they point at the same block.
564
+ */
565
+ export function shouldSkipRelation(sourceId, targetId, relType, db) {
566
+ if (sourceId === targetId)
567
+ return true; // self-referential relation
568
+ const existing = db.getRelations(sourceId)
569
+ .filter(r => r.direction === "outgoing" && r.target_id === targetId);
570
+ return existing.some(r => r.type === relType);
571
+ }
572
+ /** Returns true if the project prefix is recognised (known or just created this batch). */
573
+ export function isKnownProject(project, knownProjects, newProjectLabels) {
574
+ return knownProjects.has(project) || newProjectLabels.has(project);
575
+ }
576
+ /** Returns true if the label has an acceptable number of underscore-separated segments. */
577
+ export function isValidLabelSegmentCount(label, knownBlockTypes) {
578
+ const segs = label.split("_");
579
+ if (segs.length <= 4)
580
+ return true;
581
+ const hasCompound = knownBlockTypes.has(segs.slice(1, 3).join("_")) ||
582
+ knownBlockTypes.has(segs.slice(2, 4).join("_"));
583
+ return hasCompound;
584
+ }
585
+ /**
586
+ * Bug 3 fix (2026-05-28): resolve the effective parent for a project_creates[]
587
+ * entry. Pass 3's `parent` is optional and the LLM effectively never sets it
588
+ * (0 part_of edges across 10 transcripts in the scale audit), but Pass 0's
589
+ * scope_project IS the parent by design for any sub-project in the same batch.
590
+ *
591
+ * Rule:
592
+ * - If pass3 set `parent` explicitly → respect it (don't override LLM intent).
593
+ * - Else, if scope_project exists AND the projDef isn't the scope itself →
594
+ * default parent = scope.
595
+ * - Else → no parent (top-level project).
596
+ *
597
+ * Charter rule 3 — structurally-determined transformation belongs in code, not
598
+ * delegated to LLM judgment.
599
+ */
600
+ export function resolveProjectParent(projDef, scopeProjectLabel) {
601
+ if (projDef.parent)
602
+ return projDef.parent;
603
+ if (scopeProjectLabel && projDef.label !== scopeProjectLabel)
604
+ return scopeProjectLabel;
605
+ return undefined;
606
+ }
607
+ /**
608
+ * Bug 2 fix (2026-05-28): repair labels that use a multi-word type's literal
609
+ * form (`dead_end`, or any custom multi-word type) where the canonical rule
610
+ * (pass3.ts:50) requires hyphens within the type dimension (`dead-end`).
611
+ *
612
+ * Empirical: 2/6 dead_end blocks in the 2026-05-28 scale audit were buggy
613
+ * underscore-form, adding a phantom dimension to label-parsers. Same anti-
614
+ * pattern as Bug 3 — a structurally-determined transformation delegated to
615
+ * LLM judgment (charter rule 3). Pure idempotent function: a clean label is
616
+ * unchanged.
617
+ *
618
+ * Anchoring: replaces only `_${type}_` (within-label) and trailing `_${type}`
619
+ * (end-of-label) — never matches coincidental concept-token occurrences.
620
+ * Idempotent: hyphenated form has no underscores to find on a second pass.
621
+ */
622
+ export function normalizeMultiWordTypeInLabel(label, multiWordTypes) {
623
+ if (!label || typeof label !== "string")
624
+ return label;
625
+ let result = label;
626
+ for (const t of multiWordTypes) {
627
+ if (!t.includes("_"))
628
+ continue;
629
+ const hyphenated = t.replaceAll("_", "-");
630
+ // Within-label: project_dead_end_concept → project_dead-end_concept
631
+ result = result.split(`_${t}_`).join(`_${hyphenated}_`);
632
+ // End-of-label: project_dead_end → project_dead-end (rare; defensive)
633
+ if (result.endsWith(`_${t}`)) {
634
+ result = result.slice(0, result.length - t.length) + hyphenated;
635
+ }
636
+ }
637
+ return result;
638
+ }
639
+ // ─── chain_id post-processing ─────────────────────────────────────────────────
640
+ // Stamps a shared chain_id on blocks connected by prompted_by, based_on, or extends.
641
+ function stampFlowRolesAndChains(savedLabels, allBlocks, db) {
642
+ if (savedLabels.length === 0)
643
+ return;
644
+ const labelToId = new Map();
645
+ for (const b of allBlocks)
646
+ labelToId.set(b.label, b.id);
647
+ const newIds = new Set(savedLabels.map(l => labelToId.get(l)).filter(Boolean));
648
+ if (newIds.size === 0)
649
+ return;
650
+ // chain_id clustering uses the shared causal-thread set (relation-sets.ts) —
651
+ // unified 2026-06-05 with read-side traversal + Pass 5 assembly so the three
652
+ // "chain" notions agree on which edges are the causal thread. This ADDS
653
+ // supports / supersedes / superseded_by / resolves (supports alone is ~half of
654
+ // all causal edges) so supports-linked residue — e.g. a user's lived failure
655
+ // wired as "specific instance SUPPORTS the general dead-end" — joins its arc
656
+ // instead of orphaning. Validated by a clustering simulation (C:/tmp): pulls
657
+ // orphans into their arc, keeps genuinely edge-less blocks out, no cross-arc
658
+ // over-merge (components stay within-project + topically coherent).
659
+ const allRels = db.getAllRelations(false).filter((r) => r.status === "active" && CAUSAL_TRAVERSAL_RELS.has(r.type));
660
+ const modifiedChainIds = new Set();
661
+ // Build adjacency graph among new blocks only (within-batch connections)
662
+ const graph = new Map();
663
+ for (const id of newIds)
664
+ graph.set(id, new Set());
665
+ for (const rel of allRels) {
666
+ if (newIds.has(rel.source_id) && newIds.has(rel.target_id)) {
667
+ graph.get(rel.source_id).add(rel.target_id);
668
+ graph.get(rel.target_id).add(rel.source_id);
669
+ }
670
+ }
671
+ // BFS — find connected components within this batch and stamp shared chain_id
672
+ const visited = new Set();
673
+ for (const startId of newIds) {
674
+ if (visited.has(startId))
675
+ continue;
676
+ const component = [];
677
+ const queue = [startId];
678
+ while (queue.length > 0) {
679
+ const id = queue.shift();
680
+ if (visited.has(id))
681
+ continue;
682
+ visited.add(id);
683
+ component.push(id);
684
+ for (const neighbor of graph.get(id) ?? []) {
685
+ if (!visited.has(neighbor))
686
+ queue.push(neighbor);
687
+ }
688
+ }
689
+ if (component.length >= 2) {
690
+ const chainId = uuidv4();
691
+ for (const id of component) {
692
+ db.updateBlock(id, { chain_id: chainId });
693
+ const b = allBlocks.find((x) => x.id === id);
694
+ if (b)
695
+ b.chain_id = chainId;
696
+ }
697
+ modifiedChainIds.add(chainId);
698
+ }
699
+ }
700
+ // Cross-batch stitching: connect new blocks to existing blocks via CHAIN_RELs.
701
+ const idToBlock = new Map();
702
+ for (const b of allBlocks)
703
+ idToBlock.set(b.id, b);
704
+ for (const newId of newIds) {
705
+ const newBlock = idToBlock.get(newId);
706
+ if (!newBlock)
707
+ continue;
708
+ for (const rel of allRels) {
709
+ const newIsSource = rel.source_id === newId && !newIds.has(rel.target_id);
710
+ const newIsTarget = rel.target_id === newId && !newIds.has(rel.source_id);
711
+ if (!newIsSource && !newIsTarget)
712
+ continue;
713
+ const existingId = newIsSource ? rel.target_id : rel.source_id;
714
+ const existingBlock = db.getBlock(existingId);
715
+ if (!existingBlock || existingBlock.status === "archived")
716
+ continue;
717
+ const existingChainId = existingBlock.chain_id ?? null;
718
+ const newChainId = newBlock.chain_id ?? null;
719
+ if (existingChainId && newChainId) {
720
+ if (existingChainId !== newChainId) {
721
+ // blk_ chain_ids are canonical (assigned by Pass 5, saved in DB).
722
+ // UUID chain_ids are ephemeral (scratch, assigned during this batch's BFS).
723
+ // Canonical always beats ephemeral — never let a UUID overwrite a blk_ chain.
724
+ const existingIsCanonical = existingChainId.startsWith("blk_");
725
+ const newIsCanonical = newChainId.startsWith("blk_");
726
+ const winner = (existingIsCanonical && !newIsCanonical) ? existingChainId
727
+ : (!existingIsCanonical && newIsCanonical) ? newChainId
728
+ : (existingChainId < newChainId ? existingChainId : newChainId);
729
+ const loser = winner === existingChainId ? newChainId : existingChainId;
730
+ const toMerge = allBlocks.filter((b) => b.chain_id === loser);
731
+ for (const b of toMerge) {
732
+ db.updateBlock(b.id, { chain_id: winner });
733
+ b.chain_id = winner;
734
+ }
735
+ db.updateBlock(existingId, { chain_id: winner });
736
+ newBlock.chain_id = winner;
737
+ modifiedChainIds.delete(loser);
738
+ modifiedChainIds.add(winner);
739
+ }
740
+ else {
741
+ modifiedChainIds.add(existingChainId);
742
+ }
743
+ }
744
+ else if (existingChainId && !newChainId) {
745
+ db.updateBlock(newId, { chain_id: existingChainId });
746
+ newBlock.chain_id = existingChainId;
747
+ modifiedChainIds.add(existingChainId);
748
+ }
749
+ else if (!existingChainId && newChainId) {
750
+ db.updateBlock(existingId, { chain_id: newChainId });
751
+ modifiedChainIds.add(newChainId);
752
+ }
753
+ else {
754
+ const chainId = uuidv4();
755
+ db.updateBlock(newId, { chain_id: chainId });
756
+ db.updateBlock(existingId, { chain_id: chainId });
757
+ newBlock.chain_id = chainId;
758
+ modifiedChainIds.add(chainId);
759
+ }
760
+ }
761
+ }
762
+ }
763
+ // ═══════════════════════════════════════════════════════════════════════════════
764
+ // PIPELINE ORCHESTRATOR
765
+ // ═══════════════════════════════════════════════════════════════════════════════
766
+ export async function runAutoReflect(db, agentResponse, loadedBlocks, _userMessage, agentThinking, embeddings, recalledBlocks, agentId, checkpoint,
767
+ // ── DEBT 5 Phase 2: turn identity for conversation_turns persistence ──
768
+ // Required when NODEDEX_ARC_EXTRACTION=1 + agentId is set. When the flag
769
+ // is on AND all three (agentId + turnNumber) are present, the pipeline
770
+ // INSERTs a conversation_turns row at start, runs Pass 0-1, UPDATEs with
771
+ // pass01_output_json, then early-returns (Pass 2-5 will run at arc trigger,
772
+ // Phase 3). When the flag is off OR turn identity is missing, behaves
773
+ // identically to today (no new code paths fire). Per Variant A §2.1, §2.6.
774
+ turnNumber, turnName) {
775
+ const empty = { saved: 0, updated: 0, skipped: 0, saved_labels: [], uncertain_count: 0, created_blocks: [], updated_blocks: [] };
776
+ const provider = getLLMProvider();
777
+ if (!provider.isAvailable())
778
+ return empty;
779
+ const stateLabel = agentId ? `agent_session_state_${agentId}` : "agent_session_state";
780
+ const geminiCreatedBy = agentId ? `gemini_reflect_${agentId.slice(0, 8)}` : "gemini_reflect";
781
+ const KNOWN_BLOCK_TYPES = new Set([
782
+ "fact", "question", "hypothesis", "decision", "dead_end", "insight",
783
+ "preference", "constraint", "blueprint", "process",
784
+ "note", "project", "event", "entity",
785
+ // reasoning_chain/metric/claim collapsed → insight/fact (2026-06-15)
786
+ ]);
787
+ // Skip only EMPTY turns. NODEDEX_EXTRACT_ALL_SOURCES disables the LENGTH heuristic
788
+ // entirely (length != residue — a 12-char user turn can be a decision); empty turns
789
+ // then self-handle (extraction finds nothing -> JUDGE keeps nothing -> no blocks).
790
+ // NODEDEX_MIN_TURN_CHARS tunes the floor (default 10 — extraction itself, not length, is
791
+ // the real filter; a short turn with no residue simply yields 0 blocks). Raise it to cut
792
+ // pipeline calls on tiny turns when running a PAID extraction model (free models = $0).
793
+ const extractAllSources = process.env.NODEDEX_EXTRACT_ALL_SOURCES === "1";
794
+ const minTurnChars = (() => {
795
+ const n = parseInt(process.env.NODEDEX_MIN_TURN_CHARS ?? "", 10);
796
+ return Number.isFinite(n) && n >= 0 ? n : 10;
797
+ })();
798
+ const combinedLength = (agentResponse?.length ?? 0) + (agentThinking?.length ?? 0);
799
+ if (!extractAllSources && combinedLength < minTurnChars) {
800
+ console.log(`Auto-Reflect: skipping trivial turn (< ${minTurnChars} chars)`);
801
+ return empty;
802
+ }
803
+ // ─── DEBT 5 Phase 2: arc-extraction mode — capture turn at start ──────────
804
+ // Required gating: flag on AND we have an agent+turn identity. Captures the
805
+ // transcript even if Pass 0/1 fail (charter Rule 2 spirit: don't lose source).
806
+ // The row gets UPDATEd to status='pass01_done' after Pass JUDGE completes.
807
+ // If the flag is off OR identity is missing → _conversationTurnId stays null
808
+ // and the existing per-turn pipeline runs unchanged.
809
+ let _conversationTurnId = null;
810
+ const _arcExtractionOn = process.env.NODEDEX_ARC_EXTRACTION === "1";
811
+ if (_arcExtractionOn && agentId && turnNumber !== undefined) {
812
+ try {
813
+ const transcriptJson = JSON.stringify({
814
+ user_message: _userMessage ?? "",
815
+ agent_response: agentResponse,
816
+ agent_thinking: agentThinking ?? "",
817
+ });
818
+ const existing = db.getConversationTurnByAgentTurn(agentId, turnNumber);
819
+ if (existing) {
820
+ // Idempotency: pipeline re-fire for same (agent, turn) — reuse the row.
821
+ // Common path: reflect-queue retry after rate-limit puts the same job back.
822
+ _conversationTurnId = existing.id;
823
+ console.log(`[arc-extract] re-entering existing conversation_turn id=${existing.id} status=${existing.status} (agent=${agentId.slice(0, 8)} turn=${turnNumber})`);
824
+ }
825
+ else {
826
+ const row = db.createConversationTurn({
827
+ agent_id: agentId,
828
+ turn_number: turnNumber,
829
+ turn_name: turnName ?? null,
830
+ transcript_json: transcriptJson,
831
+ });
832
+ _conversationTurnId = row.id;
833
+ console.log(`[arc-extract] captured conversation_turn id=${row.id} agent=${agentId.slice(0, 8)} turn=${turnNumber}`);
834
+ }
835
+ }
836
+ catch (e) {
837
+ console.warn(`[arc-extract] conversation_turn INSERT failed (${e?.message}) — falling back to per-turn pipeline (no arc capture for this turn)`);
838
+ _conversationTurnId = null;
839
+ }
840
+ }
841
+ else if (_arcExtractionOn) {
842
+ // Flag on but identity missing — possible misconfig. Log once + degrade.
843
+ console.warn(`[arc-extract] NODEDEX_ARC_EXTRACTION=1 but agentId/turnNumber missing (agentId=${agentId ?? "<none>"} turnNumber=${turnNumber ?? "<none>"}) — running per-turn pipeline (no arc capture)`);
844
+ }
845
+ // ARC CAPTURE — ALWAYS lazy (v1 retired & disabled 2026-06-22). The v2 arc engine
846
+ // re-reads the RAW transcript (arc-pipeline.ts) and IGNORES per-turn pass01 items;
847
+ // v1 — the only thing that ever consumed them — is now disabled, so running Pass 0-1
848
+ // at capture is pure waste with NO consumer. The transcript is already stored above;
849
+ // persist an EMPTY pass01 (status → pass01_done = the arc-ready signal) and return,
850
+ // SKIPPING Pass 0-1 BEFORE any LLM call. The NODEDEX_V2_LAZY_CAPTURE gate is dropped:
851
+ // capture is unconditionally lazy now (the v2 arc always reads raw).
852
+ if (_conversationTurnId) {
853
+ try {
854
+ db.updateConversationTurnPass01(_conversationTurnId, JSON.stringify({ scene_card: null, items: [] }));
855
+ console.log(`[arc-extract] lazy-capture: stored raw, SKIPPED Pass 0-1 (v2 reads raw at arc) — id=${_conversationTurnId}`);
856
+ }
857
+ catch (e) {
858
+ console.warn(`[arc-extract] lazy-capture persist failed id=${_conversationTurnId}: ${e?.message}`);
859
+ }
860
+ return empty;
861
+ }
862
+ try {
863
+ const allBlocks = db.getAllBlocks();
864
+ const allRels = db.getAllRelations(false);
865
+ // ── Orphan heal: set project_id on blocks whose label prefix matches a project ──
866
+ {
867
+ const projectMap = new Map(allBlocks.filter((b) => b.type === "project").map((b) => [b.label, b]));
868
+ for (const block of allBlocks) {
869
+ if (block.type === "project" || block.project_id)
870
+ continue;
871
+ const parts = block.label.split("_");
872
+ const proj = projectMap.get(parts[0]);
873
+ if (!proj)
874
+ continue;
875
+ db.updateBlock(block.id, { project_id: proj.id });
876
+ block.project_id = proj.id; // keep in-memory allBlocks current
877
+ console.log(`Auto-Reflect: healed orphan "${block.label}" → project "${proj.label}"`);
878
+ }
879
+ }
880
+ // ── Read session state ──
881
+ let recentSaves = "";
882
+ let prevEntityMap = [];
883
+ try {
884
+ const stateBlock = db.getBlock(stateLabel);
885
+ if (stateBlock) {
886
+ const sc = JSON.parse(stateBlock.content);
887
+ const recents = sc.gemini_recent_saves || [];
888
+ if (recents.length > 0) {
889
+ recentSaves = recents.map((r) => {
890
+ let line = `- ${r.label}: "${r.essence}"`;
891
+ if (r.values)
892
+ line += ` [values: ${r.values}]`;
893
+ return line;
894
+ }).join("\n");
895
+ }
896
+ if (sc.prev_turn_context?.entity_map) {
897
+ prevEntityMap = sc.prev_turn_context.entity_map;
898
+ }
899
+ }
900
+ }
901
+ catch { /* */ }
902
+ // ── Agent-saved blocks this turn (last 20 min, non-Gemini) ──
903
+ const twentyMinutesAgo = Date.now() - 20 * 60 * 1000;
904
+ const agentSavedBlocks = allBlocks
905
+ .filter((b) => {
906
+ if (!b.created_at || b.type === "project")
907
+ return false;
908
+ if (typeof b.created_by === "string" && b.created_by.startsWith("gemini_reflect"))
909
+ return false;
910
+ return new Date(b.created_at).getTime() >= twentyMinutesAgo;
911
+ })
912
+ .map((b) => {
913
+ let unique = {};
914
+ try {
915
+ unique = (typeof b.content === "string" ? JSON.parse(b.content) : b.content)?.unique ?? {};
916
+ }
917
+ catch { /* */ }
918
+ return { id: b.id, label: b.label, type: b.type, essence: b.essence || "", unique };
919
+ });
920
+ if (agentSavedBlocks.length > 0) {
921
+ console.log(`Auto-Reflect: ${agentSavedBlocks.length} agent-saved block(s) this turn`);
922
+ }
923
+ // ── Known project roots ──
924
+ const knownRoots = allBlocks
925
+ .filter((b) => b.type === "project")
926
+ .map((b) => ({ label: b.label, essence: b.essence || "" }));
927
+ const allProjectPrefixes = new Set(knownRoots.map((r) => r.label));
928
+ // Infer active project from loaded blocks
929
+ const activeProjectPrefixes = new Set();
930
+ for (const id of loadedBlocks) {
931
+ const b = allBlocks.find((x) => x.id === id);
932
+ if (!b)
933
+ continue;
934
+ const prefix = (b.label || "").split("_")[0];
935
+ if (allProjectPrefixes.has(prefix))
936
+ activeProjectPrefixes.add(prefix);
937
+ }
938
+ if (activeProjectPrefixes.size === 0) {
939
+ for (const p of allProjectPrefixes)
940
+ activeProjectPrefixes.add(p);
941
+ }
942
+ // Full project context for Pass 3
943
+ const { context: projectContext, reflectedIds: _reflectedIds0 } = buildProjectContext(allBlocks, allRels, allProjectPrefixes, loadedBlocks);
944
+ db.stampReflectedAt(_reflectedIds0);
945
+ // ── PASS 1 + 2: Extract & Classify ──
946
+ let pass1 = null;
947
+ let pass2 = null;
948
+ let _pass0Raw = null;
949
+ let _sceneCard;
950
+ let _pass1Thinking = "";
951
+ let _pass2Thinking = "";
952
+ let _pass2Context = "";
953
+ let _pass4Thinking = "";
954
+ let _pass4Result = null;
955
+ // Pass 5 chain-assembly result captured for the turn log. The chains carry
956
+ // the per-chain `reasoning` field (commit 450d631) — debug instrumentation
957
+ // that has no graph-persistence path (chain blocks store arc/conclusion/essence
958
+ // only), so the turn log is where it's read post-run. Without this, the
959
+ // reasoning the LLM produces is dropped on the floor.
960
+ let _pass5Result = null;
961
+ let _pass0Provider;
962
+ let _pass1Provider;
963
+ let _pass2Provider;
964
+ let _pass3Provider;
965
+ let _pass4Provider;
966
+ let _pass5Provider;
967
+ // Pass 2 split orchestrator returns auditable per-stage counts (2a/2b/2c
968
+ // throughput, seam α verdicts, re-fill salvage outcomes, quarantine
969
+ // writes). Captured only when NODEDEX_PASS2_SPLIT=1 fires the split
970
+ // branch — surfaces in writeTurnLog so split runs are debuggable
971
+ // without querying the quarantine table directly. Per
972
+ // PASS2-SPLIT-DESIGN.md §7 telemetry requirement.
973
+ let _pass2SplitAudit;
974
+ // JUDGE (precision filter between Pass 1 and Pass 2) — flag-gated via
975
+ // NODEDEX_WORTH_JUDGE_ENABLED. Captures the per-call provider trail + the verdicts
976
+ // for the turn log so drops can be content-audited (per the lesson: verdict lives
977
+ // in the pass-log reasoning, never in block counts).
978
+ let _passJudgeProvider;
979
+ let _passJudgeKeptCount;
980
+ let _passJudgeDropped = [];
981
+ let _passJudgeAnchorOverrides = [];
982
+ // debt-4 Stage A — per-pass wall time tracking. Local to this runReflect call
983
+ // (resets implicitly per turn since it's a fresh closure). Each pass measured
984
+ // by Date.now() bracketing around the await callPassNLLM(...) site.
985
+ // Surfaces in turn-log so a future reader can see where time goes per pass,
986
+ // not just aggregate cost. The current cost_breakdown only covers $$;
987
+ // wall_ms covers time (the two correlate but aren't identical — thinking
988
+ // budget dominates time while output tokens dominate cost).
989
+ const _passWallMs = {};
990
+ // debt-4 Stage A — embedding stats are accumulated globally (same lifecycle
991
+ // as reflectTokenStats). Capture snapshot at runReflect start so the
992
+ // turn-log can record per-turn DELTA (consumers can also reconstruct from
993
+ // cross-turn diff). This is the hidden time tax exposed: ~100+ sequential
994
+ // embedding calls per moderate turn at ~300ms each = ~30s/turn invisible
995
+ // to cost_breakdown today.
996
+ const _embStart = { calls: embeddingStats.calls, ms_total: embeddingStats.ms_total, input_chars: embeddingStats.input_chars };
997
+ // IDs of blocks written as status='pending' during Pass 3 — activated after Pass 4 succeeds.
998
+ // Ensures blocks are invisible to users until the full pipeline (through Pass 4) completes.
999
+ const p3PendingBlockIds = [];
1000
+ // ── Resume from checkpoint or run passes fresh ──────────────────────────────
1001
+ // Restore the outputs of every pass that already completed successfully.
1002
+ // Each pass always runs fresh — it receives only the previous passes' outputs,
1003
+ // never any partial work from its own aborted attempt.
1004
+ if (checkpoint?.pass0) {
1005
+ _sceneCard = checkpoint.pass0.sceneCard;
1006
+ _pass0Raw = checkpoint.pass0.raw;
1007
+ }
1008
+ if (checkpoint?.pass1Items)
1009
+ pass1 = { items: checkpoint.pass1Items };
1010
+ if (checkpoint?.pass2Classified)
1011
+ pass2 = { skipped: [], classified: checkpoint.pass2Classified };
1012
+ if (checkpoint?.p3PendingBlockIds)
1013
+ p3PendingBlockIds.push(...checkpoint.p3PendingBlockIds);
1014
+ if (!pass2) {
1015
+ // Open blueprints context needed by Pass 0 and Pass 1
1016
+ const openBlueprints = allBlocks
1017
+ .filter((b) => b.type === "blueprint" && b.status === "active")
1018
+ .map((b) => ({ label: b.label, essence: b.essence || "" }));
1019
+ if (!pass1) {
1020
+ // ⚠ v1 SCENE-CARD FRONT-HALF — RETIRED & DISABLED (2026-06-22). v2 (COMPREHEND)
1021
+ // ALWAYS supplies pass1/pass2 via a checkpoint (resumeFrom:'pass3' on both the
1022
+ // per-turn and arc paths), so this block is unreachable on every live path.
1023
+ // Guarded to FAIL LOUD on a routing regression rather than silently extracting
1024
+ // through the retired scene-card engine. The Pass 0-1 code below is kept verbatim
1025
+ // for the follow-up deletion PR (which removes pass0.ts / pass1.ts /
1026
+ // synthesizeFromSceneCard.ts and this whole block).
1027
+ throw new Error("v1 scene-card pipeline is retired and disabled — Pass 1 must arrive from a v2 " +
1028
+ "COMPREHEND checkpoint. Reaching runAutoReflect's Pass 0-1 path indicates an " +
1029
+ "extraction routing bug (see arc-pipeline.ts / routes/state.ts).");
1030
+ }
1031
+ // ─── DEBT 5 Phase 2: arc-extraction flag-gated early-return ──────────
1032
+ // If we captured a conversation_turn at start (flag on + identity present),
1033
+ // persist Pass 0-1 output and skip Pass 2-5. Pass 2-5 will run at the arc-
1034
+ // extract trigger time (Phase 3) over consolidated input from all
1035
+ // pass01_done turns in the conversation. Per Variant A §2.6.
1036
+ //
1037
+ // The empty-items case STILL persists pass01_done (with empty items[]) —
1038
+ // the conversation_turns record is the canonical source-of-truth even
1039
+ // when this turn yielded nothing extractable; Phase 3 must distinguish
1040
+ // "ran-and-empty" from "didn't-run-yet" via status.
1041
+ if (_conversationTurnId) {
1042
+ const pass01Output = {
1043
+ scene_card: _pass0Raw ?? null,
1044
+ items: pass1?.items ?? [],
1045
+ };
1046
+ try {
1047
+ db.updateConversationTurnPass01(_conversationTurnId, JSON.stringify(pass01Output));
1048
+ console.log(`[arc-extract] persisted Pass 0-1 to conversation_turn id=${_conversationTurnId} items=${pass1?.items.length ?? 0} — Pass 2-5 deferred to arc trigger`);
1049
+ }
1050
+ catch (e) {
1051
+ // Most likely cause: row already past 'captured' (idempotency re-fire on
1052
+ // a turn whose pass01 was previously saved). Tolerate, log, move on.
1053
+ console.warn(`[arc-extract] updateConversationTurnPass01 failed for id=${_conversationTurnId}: ${e?.message} — Pass 0-1 may already be persisted from prior run`);
1054
+ }
1055
+ writeReflectLog({ pass1, pass2: null, pass3: null });
1056
+ return empty;
1057
+ }
1058
+ if (!pass1 || pass1.items.length === 0) {
1059
+ writeReflectLog({ pass1, pass2: null, pass3: null });
1060
+ console.log("Auto-Reflect Pass 1: nothing extracted — skipping passes 2 and 3");
1061
+ return empty;
1062
+ }
1063
+ // Re-derive active project from Pass 0 output.
1064
+ // loadedBlocks is empty on external triggers (no agent session) — the fallback
1065
+ // at line ~358 adds ALL projects, so activeProjectPrefixes is wrong by this point.
1066
+ // Pass 0 identifies the correct project in two places:
1067
+ // 1. projects[] — when the transcript explicitly names the project
1068
+ // 2. technologies[].project — always populated from KNOWN PROJECTS matching
1069
+ // Use both; technologies[].project is the reliable fallback when projects[] is empty.
1070
+ const pass0ProjectNames = new Set();
1071
+ for (const p of (_pass0Raw?.projects ?? [])) {
1072
+ pass0ProjectNames.add(p.name);
1073
+ }
1074
+ for (const t of (_pass0Raw?.technologies ?? [])) {
1075
+ if (t.project)
1076
+ pass0ProjectNames.add(t.project);
1077
+ }
1078
+ const pass0Matched = [...pass0ProjectNames].filter(n => allProjectPrefixes.has(n));
1079
+ if (pass0Matched.length > 0) {
1080
+ activeProjectPrefixes.clear();
1081
+ for (const n of pass0Matched)
1082
+ activeProjectPrefixes.add(n);
1083
+ }
1084
+ // If Pass 0 found nothing known (brand-new project), keep the existing set
1085
+ // Pre-search: build targeted context for Pass 2 (always fresh — queries current graph state)
1086
+ const pass2Context = await buildPreSearchContext(pass1.items, allBlocks, allRels, knownRoots, embeddings ?? null, activeProjectPrefixes);
1087
+ _pass2Context = pass2Context;
1088
+ // Thinking budget — tiered by Pass 1 item count, capped by NODEDEX_THINKING_BUDGET
1089
+ const itemCount = pass1.items.length;
1090
+ const p2Budget = getThinkingBudget(itemCount <= 3 ? 512 : itemCount <= 10 ? 4096 : 8192);
1091
+ console.log(`Auto-Reflect: ${itemCount} item(s) → thinking budget P2=${p2Budget}`);
1092
+ // Pass 2: always runs fresh — receives Pass 1 items + Pass 0 scene card.
1093
+ // Flag-gated routing per PASS2-SPLIT-DESIGN.md §4 (parallel migration).
1094
+ // DEFAULT-ON FLIP 2026-05-25 (§9 Week 4-5): split is now the default path;
1095
+ // set NODEDEX_PASS2_SPLIT=0 for emergency rollback to monolith pass2.ts
1096
+ // (kept in tree per §9, removed in a later commit after the stabilization
1097
+ // window). Flip rationale: cost gate closed + reconciled to dashboard
1098
+ // within ~1% (all 4 fixtures Acceptable+), Bug-3 quarantine containment
1099
+ // audit-confirmed, truncation gone. Reversible (this flag) + failures
1100
+ // contained (quarantine) — see commit + sticky. ON → split orchestrator
1101
+ // (2a → seam α → 2b → seam β → 2c → composer + quarantine routing). Result
1102
+ // shape is identical so the downstream graft + Pass 3 don't care.
1103
+ let p2;
1104
+ const _t2 = Date.now();
1105
+ if (process.env.NODEDEX_PASS2_SPLIT !== "0") {
1106
+ const sourceSessionId = agentId ?? "default";
1107
+ const split = await runPass2Split(provider, db, pass1.items, pass2Context, prevEntityMap, p2Budget, _sceneCard, sourceSessionId);
1108
+ p2 = {
1109
+ result: split.result,
1110
+ thinking: split.thinking,
1111
+ rateLimited: split.rateLimited,
1112
+ model: split.model,
1113
+ attempts: split.attempts,
1114
+ };
1115
+ _pass2SplitAudit = split.splitAudit;
1116
+ }
1117
+ else {
1118
+ p2 = await callPass2LLM(provider, pass1.items, pass2Context, prevEntityMap, p2Budget, _sceneCard);
1119
+ }
1120
+ _pass2Thinking = p2.thinking;
1121
+ _pass2Provider = { model: p2.model, attempts: p2.attempts };
1122
+ _passWallMs.pass2 = Date.now() - _t2;
1123
+ // Pre-populate extends_item from Pass 1 extends_id
1124
+ if (p2.result) {
1125
+ prePopulateExtendsItem(pass1.items, p2.result.classified);
1126
+ }
1127
+ if (!p2.result || p2.result.classified.length === 0) {
1128
+ writeReflectLog({ pass1, pass2: p2.result, pass3: null });
1129
+ if (p2.rateLimited || !p2.result) {
1130
+ const reason = p2.rateLimited ? "rate limited" : "API failure";
1131
+ console.log(`Auto-Reflect Pass 2: ${reason} — re-queuing with Pass 0+1 output`);
1132
+ return { ...empty, checkpoint: { resumeFrom: 'pass2', pass0: { sceneCard: _sceneCard, raw: _pass0Raw }, pass1Items: pass1.items } };
1133
+ }
1134
+ console.log("Auto-Reflect Pass 2: nothing classified — skipping pass 3");
1135
+ return empty;
1136
+ }
1137
+ pass2 = p2.result;
1138
+ }
1139
+ // Seam contract enforcement: code-stamp "type_override" when Pass 2 changed
1140
+ // an item's type but didn't set review_reason. See stampTypeOverrides() docs.
1141
+ if (pass1) {
1142
+ const stamped = stampTypeOverrides(pass1.items, pass2.classified);
1143
+ if (stamped > 0) {
1144
+ console.log(`Auto-Reflect Pass 2: code-stamped type_override on ${stamped} item(s) (Pass 2 changed type without setting review_reason)`);
1145
+ }
1146
+ }
1147
+ // Merge causal_wiring[] into classified items (schema separates them for reasoning order)
1148
+ if (pass2.causal_wiring && Array.isArray(pass2.causal_wiring)) {
1149
+ const wiringMap = new Map();
1150
+ for (const w of pass2.causal_wiring) {
1151
+ if (w.item_id)
1152
+ wiringMap.set(w.item_id, w);
1153
+ }
1154
+ for (const item of pass2.classified) {
1155
+ const wiring = wiringMap.get(item.id);
1156
+ if (wiring) {
1157
+ item.triggered_by_items = wiring.triggered_by ?? [];
1158
+ item.based_on_items = wiring.based_on ?? [];
1159
+ }
1160
+ else {
1161
+ if (!item.triggered_by_items)
1162
+ item.triggered_by_items = [];
1163
+ if (!item.based_on_items)
1164
+ item.based_on_items = [];
1165
+ }
1166
+ }
1167
+ }
1168
+ // Log Pass 2 skipped items (graph existence check)
1169
+ if (pass2.skipped && pass2.skipped.length > 0) {
1170
+ for (const s of pass2.skipped) {
1171
+ console.log(`Auto-Reflect Pass 2: skipped item ${s.id} — ${s.reason}`);
1172
+ }
1173
+ }
1174
+ // Falsifiable-skip guard: a Pass 2 skip is legitimate only if its reason
1175
+ // cites something verifiable — a real label or essence fragment from
1176
+ // allBlocks (an "already in graph" skip), OR a sibling item id from this
1177
+ // batch (an intra-batch-duplicate skip — Pass 2 Q0 STEP I sometimes
1178
+ // routes here despite the prompt's "drop, don't skip" instruction). A
1179
+ // skip citing nothing verifiable is unfalsifiable; the item is rescued
1180
+ // into classified[] with its Pass 1 provisional type.
1181
+ // No phrase matching — pure verification against real state or batch
1182
+ // siblings. Deterministic backstop for the STATE CONVENTION rule.
1183
+ if (pass2.skipped && pass2.skipped.length > 0 && pass1) {
1184
+ const knownLabels = allBlocks
1185
+ .map((b) => (b.label || "").toLowerCase())
1186
+ .filter((l) => l.length >= 4);
1187
+ const knownEssences = allBlocks
1188
+ .map((b) => (b.essence || "").toLowerCase().trim())
1189
+ .filter((e) => e.length >= 20);
1190
+ // Same-batch sibling item ids (e.g. "item_8", "syn_1"). A skip whose
1191
+ // reason cites a sibling is a legitimate intra-batch-duplicate skip —
1192
+ // the sibling exists in pass1.items, so the citation is verifiable.
1193
+ const siblingIds = (pass1?.items || [])
1194
+ .map((i) => (i.id || "").toLowerCase())
1195
+ .filter((id) => id.length >= 3);
1196
+ const citesRealState = (reason) => {
1197
+ const r = (reason || "").toLowerCase();
1198
+ if (knownLabels.some((lbl) => r.includes(lbl)))
1199
+ return true;
1200
+ if (siblingIds.some((id) => r.includes(id)))
1201
+ return true;
1202
+ // Any 20-char window of a real essence appearing in the reason = a citation
1203
+ for (const ess of knownEssences) {
1204
+ for (let i = 0; i + 20 <= ess.length; i++) {
1205
+ if (r.includes(ess.slice(i, i + 20)))
1206
+ return true;
1207
+ }
1208
+ }
1209
+ return false;
1210
+ };
1211
+ const rescued = [];
1212
+ pass2.skipped = pass2.skipped.filter(s => {
1213
+ if (citesRealState(s.reason || ""))
1214
+ return true; // verifiable — keep the skip
1215
+ const item = pass1?.items.find((i) => i.id === s.id);
1216
+ if (!item)
1217
+ return true; // cannot rescue — leave as skip
1218
+ rescued.push({
1219
+ id: item.id,
1220
+ text: item.text,
1221
+ type: item.provisional_type,
1222
+ project: "",
1223
+ unique: {},
1224
+ classification_reasoning: `[rescued by guard — Pass 2 skip claim could not be verified against PROJECT GRAPH or any sibling item in this batch; reverting to provisional_type=${item.provisional_type}]`,
1225
+ triggered_by_items: [],
1226
+ based_on_items: [],
1227
+ });
1228
+ return false;
1229
+ });
1230
+ if (rescued.length > 0) {
1231
+ console.warn(`Auto-Reflect Pass 2 guard: rescued ${rescued.length} item(s) with unverifiable skip claims — types: ${rescued.map(r => r.type).join(',')}`);
1232
+ pass2.classified.push(...rescued);
1233
+ }
1234
+ }
1235
+ // SLICE 1 SUB-STEP 1.4 — collected duplicate pairs detected by D2 dedup.
1236
+ // Hoisted out of the dedup block scope so the post-Pass-3 Stage FLAG writer
1237
+ // (after stampFlowRolesAndChains) can iterate them, resolve item_id →
1238
+ // block_id via itemIdToLabel + db.getBlock, and writePipelineFlag for each.
1239
+ // Empty array on per-turn paths where no D2 detect runs.
1240
+ const atomicDupCandidates = [];
1241
+ // DEBT 5 Slice 3 Part 4 — Stage D cross-arc resolve results, hoisted like
1242
+ // atomicDupCandidates so the post-Pass-3 flag writer (Touchpoint B) can
1243
+ // resolve item_id → block_id and write cross_arc_dup_candidate flags.
1244
+ // Empty unless arc mode + NODEDEX_STAGE_D_ENABLED + Stage D found attach/flag.
1245
+ let stageDEntries = [];
1246
+ // Code dedup guard (charter rule 3/6) — DEFAULT ON since 2026-05-24. Validated
1247
+ // across 4 domains (framework-choice, rl-fixed, garden-blight, refund): 0 false
1248
+ // collapses, stays idle when no exact dups exist. Set NODEDEX_CODE_DEDUP=0 to disable.
1249
+ // Two deterministic, exact-match-only steps; never extends to embedding similarity
1250
+ // (that is semantic judgment — Pass 2's job, not code's). Conservative on role-splits.
1251
+ if (process.env.NODEDEX_CODE_DEDUP !== "0") {
1252
+ // Step 1 — identical-essence-text twins (cross-type). The original guard.
1253
+ {
1254
+ const before = pass2.classified.length;
1255
+ const { kept, dropped } = dedupIdenticalEssenceTwins(pass2.classified);
1256
+ if (dropped.length > 0) {
1257
+ console.log(`[code-dedup] collapsed ${dropped.length} identical-essence twin(s) (${before} → ${kept.length}):`);
1258
+ for (const d of dropped)
1259
+ console.log(` - dropped ${d.id} → merged into ${d.mergedInto} | ${d.reason}`);
1260
+ pass2.classified = kept;
1261
+ }
1262
+ }
1263
+ // Step 2 (Tier 1C) — identical unique{} value-set. Catches cross-type AND
1264
+ // same-type-paraphrase dups that differ in essence wording but carry the
1265
+ // same structured data. Identity = unique{}, not essence. Runs after Step 1
1266
+ // on the already-reduced set. Winner = schema-valid block (Tier 1B validator).
1267
+ {
1268
+ const before = pass2.classified.length;
1269
+ const { kept, dropped } = dedupIdenticalUniqueValues(pass2.classified);
1270
+ if (dropped.length > 0) {
1271
+ console.log(`[code-dedup] collapsed ${dropped.length} identical-unique{}-value twin(s) (${before} → ${kept.length}):`);
1272
+ for (const d of dropped)
1273
+ console.log(` - dropped ${d.id} → merged into ${d.mergedInto} | ${d.reason}`);
1274
+ pass2.classified = kept;
1275
+ }
1276
+ }
1277
+ // Step 3 (DEBT 5 D2 §2.5.1) — dedup by (source_excerpt, primary_value).
1278
+ // Catches what Step 1 (essence) and Step 2 (unique-value) miss: items
1279
+ // with DIFFERENT unique-value-text but pinned to THE SAME source line
1280
+ // (e.g., arc-mode where two turns each extracted "the celebration of
1281
+ // family-style menu" and the LLM phrased the value slightly differently
1282
+ // across turns — different unique.value, same source_excerpt → still
1283
+ // the same observation).
1284
+ //
1285
+ // This is the provider-agnostic dedup per D2: identity = (where it came
1286
+ // from + what's the canonical fact), NOT label or type. Per
1287
+ // [[project-pass1-pass2a-provider-drift-2026-05-30]] this is the
1288
+ // structural answer to LLM-typing variance across providers.
1289
+ //
1290
+ // Only fires on items with non-empty excerpt — pre-Debt-5 atomic blocks
1291
+ // (NULL/empty source_excerpt) are conservatively left as-is.
1292
+ //
1293
+ // SLICE 1 SUB-STEP 1.4 behavior change: D2 now DETECTS (flags) instead
1294
+ // of auto-dropping. Both blocks survive to Pass 3 + DB write; after
1295
+ // createBlock loop, the Stage FLAG writer below (post-stampFlowRoles)
1296
+ // resolves item_ids → block_ids and writes pipeline_flags rows for
1297
+ // the async LLM reviewer (Slice 2) to decide merge/leave/split.
1298
+ // Per user direction: system FLAGS, reasoning MERGES.
1299
+ {
1300
+ const { duplicates } = dedupBySourceAndValue(pass2.classified);
1301
+ if (duplicates.length > 0) {
1302
+ console.log(`[code-dedup D2] DETECTED ${duplicates.length} (source_excerpt, primary_value)-twin(s) — FLAGGING (not dropping):`);
1303
+ for (const d of duplicates)
1304
+ console.log(` - flag candidate ${d.id} as duplicate of ${d.duplicate_of}`);
1305
+ atomicDupCandidates.push(...duplicates);
1306
+ }
1307
+ }
1308
+ }
1309
+ // ── FOLD constituent reason-facts (granularity fix; default OFF) ─────────────
1310
+ // Over-extraction is GRANULARITY not worth: Pass 1 fragments a decision + its
1311
+ // reasons into separate blocks (traversal-proven 2026-06-04). JUDGE (keep/drop
1312
+ // whole items) cannot consolidate fragments. foldConstituentFacts absorbs a
1313
+ // sole-constituent reason-fact INTO the state-change unit it justifies (enrich
1314
+ // unique.reason) so it is not built as a redundant standalone block. ENRICH not
1315
+ // delete (Rule 2 — content relocated, nothing lost). Runs AFTER code-dedup (so
1316
+ // based_on references are already consistent) and BEFORE Pass 3 build. Detection
1317
+ // is structural/deterministic (Rule 3 — counts wired based_on edges, never
1318
+ // semantic similarity). Mode-independent (arc + per-turn). Default OFF:
1319
+ // NODEDEX_FOLD_CONSTITUENTS=1. Design + guards:
1320
+ // [[project-fragmentation-not-worth-fold-2026-06-04]].
1321
+ if (process.env.NODEDEX_FOLD_CONSTITUENTS === "1") {
1322
+ const before = pass2.classified.length;
1323
+ const { kept, folded } = foldConstituentFacts(pass2.classified);
1324
+ if (folded.length > 0) {
1325
+ console.log(`[fold] folded ${folded.length} constituent reason-fact(s) (${before} → ${kept.length}):`);
1326
+ for (const f of folded)
1327
+ console.log(` - ${f.id} → ${f.foldedInto} | ${f.reason}`);
1328
+ pass2.classified = kept;
1329
+ }
1330
+ }
1331
+ // Outer-scope state — populated by Pass 3 (normal run) or restored from pending blocks (pass4 retry)
1332
+ const result = { saved: 0, updated: 0, skipped: 0, saved_labels: [], uncertain_count: 0, created_blocks: [], updated_blocks: [] };
1333
+ const pipelineSkips = [];
1334
+ const _pendingRecentSaves = [];
1335
+ let analysis = null;
1336
+ let geminiThinking = "";
1337
+ if (checkpoint?.resumeFrom !== 'pass4') {
1338
+ // Thinking budget for Pass 3 — cap at 2048 to prevent Flash over-deliberation
1339
+ const p3ThinkBudget = getThinkingBudget(pass1
1340
+ ? (pass1.items.length <= 3 ? 1024 : 2048)
1341
+ : 2048);
1342
+ console.log(`Auto-Reflect Pass 2: ${pass2.classified.length} blocks to build (${pass2.skipped?.length ?? 0} skipped by graph check) → proceeding to Pass 3 (thinking=${p3ThinkBudget})`);
1343
+ // Semantic duplicate pre-filter
1344
+ let duplicateAlerts = "";
1345
+ if (embeddings?.isAvailable()) {
1346
+ try {
1347
+ duplicateAlerts = await buildDuplicateAlerts(pass2.classified, allBlocks, embeddings);
1348
+ }
1349
+ catch { /* non-critical */ }
1350
+ }
1351
+ // Per-item neighborhood context for Pass 3
1352
+ let itemContext = {};
1353
+ try {
1354
+ itemContext = await buildItemContext(pass2.classified, allBlocks, allRels, embeddings ?? null);
1355
+ }
1356
+ catch { /* non-critical */ }
1357
+ // Filter superseded blocks from project context — prevents Pass 3 from deduping
1358
+ // a new block against the block it's meant to replace (supersedes_ref conflict)
1359
+ const supersededLabels = new Set(pass2.classified
1360
+ .filter(item => item.supersedes_ref)
1361
+ .map(item => item.supersedes_ref));
1362
+ const p3AllBlocks = supersededLabels.size > 0
1363
+ ? allBlocks.filter((b) => !supersededLabels.has(b.label))
1364
+ : allBlocks;
1365
+ let p3ProjectContext = projectContext;
1366
+ if (supersededLabels.size > 0) {
1367
+ const { context: filteredCtx, reflectedIds: _reflectedIds1 } = buildProjectContext(p3AllBlocks, allRels, allProjectPrefixes, loadedBlocks);
1368
+ db.stampReflectedAt(_reflectedIds1);
1369
+ p3ProjectContext = filteredCtx;
1370
+ }
1371
+ if (supersededLabels.size > 0) {
1372
+ console.log(`Auto-Reflect Pass 3: filtered ${supersededLabels.size} superseded block(s) from project context: [${[...supersededLabels].join(", ")}]`);
1373
+ }
1374
+ // ── DEBT 5 Slice 1 Sub-step 1.3 — apply Stage C canonical names ──────────
1375
+ // Between Pass 2 and Pass 3 in ARC mode only: walk Pass 2 classified items,
1376
+ // for each item that appears in a Stage C entity cluster, overwrite its
1377
+ // .project field with the cluster's canonical_name. Pass 3 trusts Pass 2's
1378
+ // project field verbatim (pass3.ts:75-77), so this is the seam where the
1379
+ // 5-projects-for-1-arc fix lands without modifying Pass 3's prompt.
1380
+ //
1381
+ // Degrade path: when checkpoint.arcEntityResolution is undefined (Stage C
1382
+ // skipped/failed OR non-arc per-turn runs), the helper is a no-op — items
1383
+ // keep Pass 2's per-turn names. Phase 11 5-projects-bug is the cost; we
1384
+ // accept it as the original behavior, not a regression.
1385
+ if (pass2.classified.length > 0 && checkpoint?.arcEntityResolution) {
1386
+ const result = applyArcEntityCanonicalNames(pass2.classified, checkpoint.arcEntityResolution);
1387
+ pass2 = { skipped: pass2.skipped, classified: result.items };
1388
+ console.log(`Auto-Reflect Pass 3 (arc): canonicalized ${result.renamed_count} item(s) across ${result.clusters_used} cluster(s); ${result.unmatched_item_ids.length} item(s) kept per-turn names`);
1389
+ }
1390
+ // ── Recognition Layer step 2 — RECOGNIZER (root-fork fix; default OFF) ───────
1391
+ // After Stage C named the clusters, ask for each NEW-root candidate: does this
1392
+ // cluster actually belong to an EXISTING root (same domain + same owner)? On a
1393
+ // confident attach, rewrite the cluster's .project to that root's EXACT label so
1394
+ // Pass 3's root-create finds it (no fork). Else keep the new root (the safe fork
1395
+ // per §1; the post-hoc AUDIT-heal pass surfaces fork-pairs for the agent/user).
1396
+ // "Stage D for roots" — LLM-primary, judges on the root DESCRIPTION (essence) +
1397
+ // scope, with the 5 guards (recognize-root.ts). Default OFF
1398
+ // (NODEDEX_RECOGNIZER_ENABLED=1), arc mode only. Graceful degrade on any error.
1399
+ if (recognizerEnabled() &&
1400
+ checkpoint?.arcEntityResolution &&
1401
+ pass2.classified.length > 0 &&
1402
+ knownRoots.length > 0) {
1403
+ try {
1404
+ const rec = await recognizeRootsForArc({ provider, items: pass2.classified, knownRoots });
1405
+ if (rec.remap.length > 0) {
1406
+ const applied = applyRootRemap(pass2.classified, rec.remap);
1407
+ pass2 = { skipped: pass2.skipped, classified: applied.items };
1408
+ console.log(`Auto-Reflect Recognizer (arc): ${rec.attached} attach / ${rec.candidates} candidate(s); rewrote ${applied.rewritten} item(s) — ${rec.llm_calls} LLM call(s)`);
1409
+ }
1410
+ else if (rec.candidates > 0) {
1411
+ console.log(`Auto-Reflect Recognizer (arc): 0 attach / ${rec.candidates} candidate(s) kept as new — ${rec.llm_calls} LLM call(s)`);
1412
+ }
1413
+ }
1414
+ catch (e) {
1415
+ console.warn(`[recognizer] threw (${e?.message}) — keeping Stage C names (extraction continues)`);
1416
+ }
1417
+ }
1418
+ // ── DEBT 5 Slice 3 Part 4 — STAGE D: cross-arc resolve (Touchpoint A) ────────
1419
+ // After Stage C applied canonical names, ask for each classified item: does
1420
+ // this entity ALREADY EXIST in the graph (attach), or is it a different-owner
1421
+ // collision / ambiguous (flag)? Stage D DETECTS + JUDGES + FLAGS only — it does
1422
+ // NOT mutate the graph (the async reviewer ACTS; charter flag-vs-auto-act,
1423
+ // PIPELINE-FIRST-PRINCIPLES §4 Insight 3). Results are drained into
1424
+ // cross_arc_dup_candidate flags at Touchpoint B (after the createBlock loop,
1425
+ // when item→block_id is resolvable).
1426
+ //
1427
+ // Default OFF — opt-in via NODEDEX_STAGE_D_ENABLED=1. Arc mode only (needs the
1428
+ // Stage C resolution that runs at arc time). The cost gate (minIdentityForLLM)
1429
+ // bounds spend to items with a plausible duplicate candidate. Graceful degrade:
1430
+ // any failure here is caught and logged — it must never block extraction.
1431
+ if (process.env.NODEDEX_STAGE_D_ENABLED === "1" &&
1432
+ checkpoint?.arcEntityResolution &&
1433
+ pass2.classified.length > 0) {
1434
+ try {
1435
+ const batch = await resolveArcEntitiesForItems({ db, provider, items: pass2.classified });
1436
+ stageDEntries = batch.entries;
1437
+ console.log(`Auto-Reflect Stage D (arc): resolved ${batch.items_resolved} item(s) — ${batch.attached} attach, ${batch.flagged} flag, ${batch.llm_calls} LLM call(s)`);
1438
+ }
1439
+ catch (e) {
1440
+ console.warn(`[stage-d] resolve threw (${e?.message}) — skipping cross-arc flags (extraction continues)`);
1441
+ stageDEntries = [];
1442
+ }
1443
+ }
1444
+ // ── PASS 3: Build ──
1445
+ // callPass3Batched is a drop-in for callPass3LLM: when NODEDEX_PASS3_BATCH=1 AND
1446
+ // the item count exceeds the batch size it splits the write into chunks (the model
1447
+ // gives up past ~25 items in one call); otherwise it delegates to a single call =
1448
+ // byte-identical to before. Cross-chunk relations wire server-side at save.
1449
+ const _t3 = Date.now();
1450
+ const p3 = await callPass3Batched(provider, pass2.classified, knownRoots, p3ProjectContext, agentSavedBlocks, p3ThinkBudget, duplicateAlerts, itemContext);
1451
+ _passWallMs.pass3 = Date.now() - _t3;
1452
+ analysis = p3.analysis;
1453
+ geminiThinking = p3.geminiThinking;
1454
+ const rateLimited = p3.rateLimited;
1455
+ _pass3Provider = { model: p3.model, attempts: p3.attempts };
1456
+ writeReflectLog({ pass1, pass2, pass3: analysis, pass4: null });
1457
+ if (!analysis) {
1458
+ const reason = rateLimited ? "rate limited" : "API failure";
1459
+ console.warn(`Auto-Reflect Pass 3: ${reason} — saving ${pass2.classified.length} classified items for re-queue`);
1460
+ return { ...empty, checkpoint: { resumeFrom: 'pass3', pass0: { sceneCard: _sceneCard, raw: _pass0Raw }, pass1Items: pass1?.items, pass2Classified: pass2.classified } };
1461
+ }
1462
+ // ── Recover a drifted/missing from_item_id BEFORE the accounting guards ──
1463
+ // Pass 3 (the model) must echo each block's source item id as from_item_id, but
1464
+ // Gemini occasionally omits or drifts it on ONE block in a large batched write.
1465
+ // The save loop already has a type-match fallback for this (see ~"inferred
1466
+ // from_item_id by type match" below), but it runs DOWNSTREAM of the mandatory-item
1467
+ // guard — so a single unrecovered id there would discard the WHOLE analysis (every
1468
+ // correctly-built block included) before the recovery ever runs. Hoist the same
1469
+ // recovery here so the guard evaluates the recovered state: one drifted id no
1470
+ // longer nukes the entire arc.
1471
+ {
1472
+ const _recovered = recoverDriftedFromItemIds(analysis.new_blocks, pass2.classified);
1473
+ if (_recovered > 0)
1474
+ console.log(`Auto-Reflect Pass 3: recovered ${_recovered} drifted/missing from_item_id(s) by pre-accounting type-match`);
1475
+ }
1476
+ // Truncation detection: if Pass 3 accounts for far fewer items than Pass 2 sent,
1477
+ // the model returned a syntactically valid but incomplete response. Re-queue.
1478
+ {
1479
+ const substantiveItems = pass2.classified.filter(i => i.type !== "task").length;
1480
+ const accounted = (analysis.new_blocks?.length ?? 0)
1481
+ + (analysis.skip_reasons?.length ?? 0)
1482
+ + (analysis.updates?.length ?? 0);
1483
+ if (substantiveItems >= 3 && accounted < Math.ceil(substantiveItems / 2)) {
1484
+ console.warn(`Auto-Reflect Pass 3: truncated response detected (${accounted} accounted / ${substantiveItems} expected) — re-queuing`);
1485
+ return { ...empty, checkpoint: { resumeFrom: 'pass3', pass0: { sceneCard: _sceneCard, raw: _pass0Raw }, pass1Items: pass1?.items, pass2Classified: pass2.classified } };
1486
+ }
1487
+ // Mandatory item check: verify all tier-1/tier-3 items are accounted for.
1488
+ // Tier 1 — decision/constraint/dead_end/blueprint + supersedes_ref items.
1489
+ // Tier 3 — facts with no existing neighborhood match (new state data).
1490
+ // If any are missing from new_blocks/skip_reasons/updates → re-queue.
1491
+ const MANDATORY_PASS3_TYPES = new Set(["decision", "constraint", "dead_end", "blueprint"]);
1492
+ const mandatoryItems = pass2.classified.filter(i => {
1493
+ if (MANDATORY_PASS3_TYPES.has(i.type) || !!i.supersedes_ref)
1494
+ return true; // tier 1
1495
+ if (i.type === "fact") {
1496
+ const ctx = itemContext[i.id];
1497
+ return !ctx || ctx === "(no existing match — create new block)"; // tier 3
1498
+ }
1499
+ return false;
1500
+ });
1501
+ if (mandatoryItems.length > 0) {
1502
+ const fromItemIds = new Set((analysis.new_blocks || []).map((b) => b.from_item_id).filter(Boolean));
1503
+ const skipItemIds = new Set((analysis.skip_reasons || []).map((s) => s.item_id).filter(Boolean));
1504
+ const updateBlockIds = new Set((analysis.updates || []).map((u) => u.block_id).filter(Boolean));
1505
+ const missing = mandatoryItems.filter(i => !fromItemIds.has(i.id) && !skipItemIds.has(i.id) && !updateBlockIds.has(i.id));
1506
+ if (missing.length > 0) {
1507
+ const labels = missing.map(i => `${i.id}[${i.type}]`).join(", ");
1508
+ console.warn(`Auto-Reflect Pass 3: mandatory item(s) unaccounted: ${labels} — re-queuing`);
1509
+ return { ...empty, checkpoint: { resumeFrom: 'pass3', pass0: { sceneCard: _sceneCard, raw: _pass0Raw }, pass1Items: pass1?.items, pass2Classified: pass2.classified } };
1510
+ }
1511
+ }
1512
+ }
1513
+ // ── Supersedes-ref guard: items with supersedes_ref must be in new_blocks, not updates ──
1514
+ // The prompt says "supersedes_ref → ALWAYS new_blocks + supersedes relation, NEVER updates".
1515
+ // If the model violates this, warn and let it through (updates[] still processes, but the
1516
+ // superseded block won't be properly archived as history).
1517
+ {
1518
+ const supersedesItemIds = new Set(pass2.classified.filter(i => !!i.supersedes_ref).map(i => i.id));
1519
+ if (supersedesItemIds.size > 0) {
1520
+ const newBlockItemIds = new Set((analysis.new_blocks || []).map((b) => b.from_item_id).filter(Boolean));
1521
+ for (const itemId of supersedesItemIds) {
1522
+ if (!newBlockItemIds.has(itemId)) {
1523
+ const item = pass2.classified.find(i => i.id === itemId);
1524
+ console.warn(`Auto-Reflect Pass 3 guard: supersedes_ref item "${itemId}" [${item?.type}] not in new_blocks[] — superseded block may not be properly archived`);
1525
+ pipelineSkips.push({ label: itemId, reason: `supersedes_ref_not_in_new_blocks` });
1526
+ }
1527
+ }
1528
+ }
1529
+ }
1530
+ result.skipped = (analysis.skip_reasons || []).length;
1531
+ // ── Scope project: the guaranteed home for cross-cutting blocks ──
1532
+ // Pass 0 names one overarching subject per transcript. Ensure it exists as a
1533
+ // root BEFORE the project_creates[] loop so that:
1534
+ // (1) the sentinel guard in the build loop has somewhere to re-home blocks
1535
+ // (2) sub-projects in project_creates[] can default their `parent` to the
1536
+ // scope project and find it in allBlocks immediately (Bug 3 fix —
1537
+ // structurally-determined nesting belongs in code, not delegated to
1538
+ // LLM judgment via the optional pass3 `parent` field; charter rule 3).
1539
+ const SENTINEL_PROJECT_NAMES = new Set(["null", "undefined", "unknown", "general", "misc", "none", ""]);
1540
+ // A COMPREHEND group_id leaking as a project name ("group_1", "g-2") is a
1541
+ // placeholder, not a root — treat it as sentinel so it is re-homed (v1) rather than
1542
+ // becoming a root. In v2 the converter (comprehendResultToPass2Items) already
1543
+ // guarantees a real item.project; this is the WRITE-seam backstop.
1544
+ const GROUP_ID_PLACEHOLDER = /^(?:group|g)[-_]?\d+$/i;
1545
+ const isSentinelProject = (p) => p == null
1546
+ || SENTINEL_PROJECT_NAMES.has(String(p).trim().toLowerCase())
1547
+ || GROUP_ID_PLACEHOLDER.test(String(p).trim());
1548
+ const newProjectLabels = new Set();
1549
+ const scopeProjectLabel = typeof _pass0Raw?.scope_project?.name === "string" && !isSentinelProject(_pass0Raw.scope_project.name)
1550
+ ? _pass0Raw.scope_project.name.trim()
1551
+ : undefined;
1552
+ if (scopeProjectLabel) {
1553
+ let scopeProj = allBlocks.find((b) => b.label === scopeProjectLabel && b.type === "project");
1554
+ if (!scopeProj) {
1555
+ const dbScope = db.getBlock(scopeProjectLabel);
1556
+ if (dbScope && dbScope.type === "project") {
1557
+ scopeProj = dbScope;
1558
+ allBlocks.push(dbScope);
1559
+ }
1560
+ else {
1561
+ scopeProj = db.createBlock({
1562
+ label: scopeProjectLabel, type: "project", status: "active",
1563
+ essence: _pass0Raw?.scope_project?.scope || `Overarching subject: ${scopeProjectLabel}`,
1564
+ content: { is_a: "project", concepts: [], unique: {} },
1565
+ ttl: "permanent", source: "Auto-Reflect", created_by: geminiCreatedBy,
1566
+ });
1567
+ stampQualityScore(db, scopeProj, []); // Bug 1 fix: pipeline-created blocks must have quality_score set, else recall filters reject them at q=0
1568
+ allBlocks.push(scopeProj);
1569
+ console.log(`Auto-Reflect: ensured scope project root "${scopeProjectLabel}"`);
1570
+ }
1571
+ }
1572
+ newProjectLabels.add(scopeProjectLabel);
1573
+ }
1574
+ // ── Create new project roots ──
1575
+ for (const projDef of (analysis.project_creates || [])) {
1576
+ if (!projDef?.label || !projDef?.essence)
1577
+ continue;
1578
+ // Bug 3 fix: default `parent` to scope_project for non-scope sub-projects.
1579
+ // See resolveProjectParent doc for full rationale.
1580
+ const desiredParent = resolveProjectParent(projDef, scopeProjectLabel);
1581
+ const existingProj = allBlocks.find((b) => b.label === projDef.label && b.type === "project");
1582
+ if (existingProj) {
1583
+ // Project already exists — still wire parent relation if specified and not yet present
1584
+ if (desiredParent) {
1585
+ const alreadyLinked = db.getRelations(existingProj.id).some((r) => r.type === "part_of");
1586
+ if (!alreadyLinked) {
1587
+ const parentBlock = allBlocks.find((b) => b.label === desiredParent && b.type === "project")
1588
+ ?? db.getBlock(desiredParent);
1589
+ if (parentBlock) {
1590
+ db.createRelation({ source_id: existingProj.id, target_id: parentBlock.id, type: "part_of", bidirectional: false });
1591
+ console.log(`Auto-Reflect: retroactively linked existing project "${projDef.label}" → parent "${desiredParent}"`);
1592
+ }
1593
+ else {
1594
+ console.warn(`Auto-Reflect: existing project "${projDef.label}" — parent "${desiredParent}" not found, skipping nest`);
1595
+ }
1596
+ }
1597
+ }
1598
+ continue;
1599
+ }
1600
+ const newProj = db.createBlock({
1601
+ label: projDef.label, type: "project", status: "active",
1602
+ essence: projDef.essence,
1603
+ content: { is_a: "project", concepts: [], unique: {} },
1604
+ ttl: "permanent", source: "Auto-Reflect", created_by: geminiCreatedBy,
1605
+ });
1606
+ stampQualityScore(db, newProj, []); // Bug 1 fix
1607
+ allBlocks.push(newProj);
1608
+ newProjectLabels.add(projDef.label);
1609
+ // Wire nested root → parent if specified (collection-member pattern)
1610
+ if (desiredParent) {
1611
+ const parentBlock = allBlocks.find((b) => b.label === desiredParent && b.type === "project")
1612
+ ?? db.getBlock(desiredParent);
1613
+ if (parentBlock) {
1614
+ db.createRelation({ source_id: newProj.id, target_id: parentBlock.id, type: "part_of", bidirectional: false });
1615
+ console.log(`Auto-Reflect: created project root "${projDef.label}" (nested under "${desiredParent}")`);
1616
+ }
1617
+ else {
1618
+ console.warn(`Auto-Reflect: created project root "${projDef.label}" — parent "${desiredParent}" not found in graph, skipping nest`);
1619
+ }
1620
+ }
1621
+ else {
1622
+ console.log(`Auto-Reflect: created project root "${projDef.label}"`);
1623
+ }
1624
+ const prefix = projDef.label;
1625
+ const orphans = allBlocks.filter((b) => b.type !== "project" && b.label.startsWith(prefix + "_") && !b.project_id);
1626
+ for (const orphan of orphans) {
1627
+ db.updateBlock(orphan.id, { project_id: newProj.id });
1628
+ orphan.project_id = newProj.id;
1629
+ }
1630
+ }
1631
+ // Priority-tier ordering
1632
+ const TIER1 = new Set(["dead_end"]);
1633
+ const TIER2 = new Set(["decision", "constraint", "preference"]);
1634
+ const rawBlocks = (analysis.new_blocks || []).filter((b) => b?.essence && b?.label);
1635
+ const tier1 = rawBlocks.filter((b) => TIER1.has(b.is_a) || (b.relations || []).some((r) => r.type === "contradicts"));
1636
+ const tier2 = rawBlocks.filter((b) => !tier1.includes(b) && TIER2.has(b.is_a));
1637
+ const tier3 = rawBlocks.filter((b) => !tier1.includes(b) && !tier2.includes(b));
1638
+ const orderedBlocks = [...tier1, ...tier2, ...tier3];
1639
+ // Review map: item_id → review_reason from Pass 2
1640
+ const reviewMap = new Map();
1641
+ for (const item of pass2.classified) {
1642
+ if (item.review_reason)
1643
+ reviewMap.set(item.id, item.review_reason);
1644
+ }
1645
+ // Source-type map: item_id → source_type provenance from Pass 2 (debt-3
1646
+ // demote-edge sets "seam_demoted"). Looked up at createBlock via
1647
+ // blockDef.from_item_id, mirroring reviewMap. Monolith path never sets it →
1648
+ // map empty → source_type defaults to "agent_derived" (backward compatible).
1649
+ const sourceTypeMap = new Map();
1650
+ for (const item of pass2.classified) {
1651
+ if (item.source_type)
1652
+ sourceTypeMap.set(item.id, item.source_type);
1653
+ }
1654
+ // DEBT 5 D3 (§2.3.2) Phase 5: item_id → source_excerpt. Pinned from Pass 1's
1655
+ // excerpt field, carried through Pass 2a re-join (mirrors text re-join),
1656
+ // through composeForDownstream into Pass2Item.excerpt. Pass 3 reads via
1657
+ // blockDef.from_item_id at createBlock to populate blocks.source_excerpt
1658
+ // column. Empty string when Pass 1 left it empty (defensive — Pass 2a logs
1659
+ // a re-join miss but doesn't fail). Map keeps non-empty values only so
1660
+ // createBlock falls back to NULL for items without provenance.
1661
+ const sourceExcerptMap = new Map();
1662
+ for (const item of pass2.classified) {
1663
+ if (item.excerpt && item.excerpt.length > 0)
1664
+ sourceExcerptMap.set(item.id, item.excerpt);
1665
+ }
1666
+ // Seed type set
1667
+ const seedTypeNames = new Set([
1668
+ "artifact", "constraint", "dead_end", "decision", "draft", "fact", "insight",
1669
+ "note", "process", "project", "question", "task",
1670
+ "hypothesis", "preference", "blueprint", "event", "entity",
1671
+ // reasoning_chain/metric/claim collapsed → insight/fact (2026-06-15)
1672
+ ]);
1673
+ const allDbTypes = db.getBlockTypes?.() ?? [];
1674
+ allDbTypes.filter((t) => !seedTypeNames.has(t.name)).forEach((t) => seedTypeNames.add(t.name));
1675
+ // Cache the multi-word type set once — used by normalizeMultiWordTypeInLabel
1676
+ // to repair labels the LLM occasionally writes with underscores in the type
1677
+ // segment (e.g. `garden_dead_end_x`) where the rule is hyphens-within-dimension
1678
+ // (`garden_dead-end_x`). Bug 2 fix, 2026-05-28.
1679
+ const MULTI_WORD_TYPES = new Set([...seedTypeNames].filter((t) => t.includes("_")));
1680
+ // from_item_id → assembled label, for server-side extends_item resolution
1681
+ const itemIdToLabel = new Map();
1682
+ const pendingTriggeredBy = [];
1683
+ // item_id → triggered_by_items from Pass 2, for TS-layer fallback when Pass 3 omits triggered_by
1684
+ const itemTriggeredByItems = new Map();
1685
+ for (const item of pass2.classified) {
1686
+ if (item.id && Array.isArray(item.triggered_by_items)) {
1687
+ itemTriggeredByItems.set(item.id, item.triggered_by_items);
1688
+ }
1689
+ }
1690
+ for (const _blockDefRaw of orderedBlocks) {
1691
+ if (!_blockDefRaw?.essence || !_blockDefRaw?.label)
1692
+ continue;
1693
+ // Assemble label from object
1694
+ let blockDef = _blockDefRaw;
1695
+ let labelFromObject = false;
1696
+ if (typeof blockDef.label === "object" && blockDef.label !== null) {
1697
+ const lp = blockDef.label;
1698
+ // Sentinel-project guard: Pass 3 gave no real project → re-home to the
1699
+ // scope project so "null"/"" can never become a project root.
1700
+ if (isSentinelProject(lp.project) && scopeProjectLabel)
1701
+ lp.project = scopeProjectLabel;
1702
+ if (lp.project && lp.type && lp.concept) {
1703
+ const parts = lp.subgroup
1704
+ ? [lp.project, lp.subgroup, lp.type, lp.concept]
1705
+ : [lp.project, lp.type, lp.concept];
1706
+ blockDef = { ...blockDef, label: parts.join("_") };
1707
+ labelFromObject = true;
1708
+ }
1709
+ else {
1710
+ console.warn(`Auto-Reflect: skipped block — incomplete label object`);
1711
+ pipelineSkips.push({ label: JSON.stringify(_blockDefRaw.label), reason: "incomplete_label_object" });
1712
+ result.skipped++;
1713
+ continue;
1714
+ }
1715
+ }
1716
+ // Bug 2 fix (2026-05-28): normalize multi-word type segments in the
1717
+ // block's own label AND in cross-block references that target labels
1718
+ // with the same convention. Catches LLM cases where the type's literal
1719
+ // underscore form leaked through into the label (`dead_end` → `dead-end`).
1720
+ // Idempotent; refs starting with blk_/__item_ref__/__based_on__ are
1721
+ // markers, not labels — leave them alone.
1722
+ blockDef = {
1723
+ ...blockDef,
1724
+ label: normalizeMultiWordTypeInLabel(blockDef.label, MULTI_WORD_TYPES),
1725
+ ...(Array.isArray(blockDef.triggered_by) ? {
1726
+ triggered_by: blockDef.triggered_by.map((r) => typeof r === "string" && !r.startsWith("blk_") && !r.startsWith("__item_ref__") && !r.startsWith("__based_on__")
1727
+ ? normalizeMultiWordTypeInLabel(r, MULTI_WORD_TYPES)
1728
+ : r),
1729
+ } : {}),
1730
+ ...(Array.isArray(blockDef.based_on) ? {
1731
+ based_on: blockDef.based_on.map((r) => typeof r === "string" && !r.startsWith("blk_")
1732
+ ? normalizeMultiWordTypeInLabel(r, MULTI_WORD_TYPES)
1733
+ : r),
1734
+ } : {}),
1735
+ ...(Array.isArray(blockDef.relations) ? {
1736
+ relations: blockDef.relations.map((rel) => rel && typeof rel === "object" && typeof rel.target_id === "string"
1737
+ && !rel.target_id.startsWith("blk_") && rel.target_id !== "null"
1738
+ ? { ...rel, target_id: normalizeMultiWordTypeInLabel(rel.target_id, MULTI_WORD_TYPES) }
1739
+ : rel),
1740
+ } : {}),
1741
+ };
1742
+ // Project root validation — if project is unknown, auto-create rather than reject
1743
+ // (mirrors the flat-string path below; project_creates[] is a hint not a gate)
1744
+ if (labelFromObject) {
1745
+ const lp = _blockDefRaw.label;
1746
+ const blockProject = lp.project;
1747
+ if (!isKnownProject(blockProject, allProjectPrefixes, newProjectLabels)) {
1748
+ const existingProj = db.getBlock(blockProject);
1749
+ if (existingProj && existingProj.type === "project") {
1750
+ allBlocks.push(existingProj);
1751
+ }
1752
+ else {
1753
+ const autoProj = db.createBlock({
1754
+ label: blockProject, type: "project", status: "active",
1755
+ essence: `Auto-created project for '${blockProject}' domain`,
1756
+ content: { is_a: "project", unique: {}, concepts: [] },
1757
+ ttl: "permanent", source: "Auto-Reflect", created_by: geminiCreatedBy,
1758
+ });
1759
+ stampQualityScore(db, autoProj, []); // Bug 1 fix
1760
+ allBlocks.push(autoProj);
1761
+ newProjectLabels.add(blockProject);
1762
+ console.log(`Auto-Reflect: auto-created project root "${blockProject}" for "${blockDef.label}"`);
1763
+ }
1764
+ }
1765
+ }
1766
+ // Label dedup — merge computed metadata into existing block instead of skipping
1767
+ if (isDuplicateLabel(blockDef.label, allBlocks)) {
1768
+ const existing = allBlocks.find((b) => b.label === blockDef.label) ?? db.getBlock(blockDef.label);
1769
+ if (existing) {
1770
+ // Apply concepts if existing block has none
1771
+ let existingConcepts = [];
1772
+ try {
1773
+ existingConcepts = JSON.parse(typeof existing.concepts === "string" ? existing.concepts : "[]");
1774
+ }
1775
+ catch { /* */ }
1776
+ const newConcepts = blockDef.concepts || [];
1777
+ if (existingConcepts.length === 0 && newConcepts.length > 0) {
1778
+ const existingContent = typeof existing.content === "string" ? (JSON.parse(existing.content) || {}) : (existing.content || {});
1779
+ existingContent.concepts = newConcepts;
1780
+ db.updateBlock(existing.id, { concepts: newConcepts, content: JSON.stringify(existingContent) });
1781
+ }
1782
+ // Create prompted_by relations from blockDef.triggered_by (resolved inline — triggeredByIds not yet declared)
1783
+ for (const ref of (blockDef.triggered_by || [])) {
1784
+ if (ref.startsWith("__item_ref__")) {
1785
+ pendingTriggeredBy.push({ sourceId: existing.id, labelRef: ref });
1786
+ continue;
1787
+ }
1788
+ const tb = ref.startsWith("blk_") ? db.getBlock(ref) : allBlocks.find((bl) => bl.label === ref) ?? null;
1789
+ if (tb) {
1790
+ if (!shouldSkipRelation(existing.id, tb.id, "prompted_by", db))
1791
+ db.createRelation({ source_id: existing.id, target_id: tb.id, type: "prompted_by", bidirectional: false });
1792
+ }
1793
+ else {
1794
+ pendingTriggeredBy.push({ sourceId: existing.id, labelRef: ref });
1795
+ }
1796
+ }
1797
+ // Create based_on relations
1798
+ for (const ref of (blockDef.based_on || [])) {
1799
+ const targetBlock = ref.startsWith("blk_")
1800
+ ? db.getBlock(ref)
1801
+ : allBlocks.find((b) => b.label === ref) ?? null;
1802
+ if (!targetBlock) {
1803
+ pendingTriggeredBy.push({ sourceId: existing.id, labelRef: `__based_on__${ref}` });
1804
+ continue;
1805
+ }
1806
+ if (!shouldSkipRelation(existing.id, targetBlock.id, "based_on", db))
1807
+ db.createRelation({ source_id: existing.id, target_id: targetBlock.id, type: "based_on", bidirectional: false });
1808
+ }
1809
+ // Create other relations
1810
+ const ALLOWED_RELS_MERGE = new Set(["contradicts", "based_on", "related_to", "resolves", "supports", "prompted_by", "extends", "supersedes", "superseded_by", "derived_from", "affects"]);
1811
+ for (const rel of (blockDef.relations || [])) {
1812
+ if (!rel?.type || !rel?.target_id || rel.type === "part_of" || rel.type === "null" || rel.target_id === "null")
1813
+ continue;
1814
+ const relType = rel.type === "triggered_by" ? "prompted_by" : rel.type;
1815
+ if (!ALLOWED_RELS_MERGE.has(relType))
1816
+ continue;
1817
+ const targetBlock = db.getBlock(rel.target_id) ?? allBlocks.find((b) => b.label === rel.target_id) ?? null;
1818
+ if (!targetBlock)
1819
+ continue;
1820
+ if (!shouldSkipRelation(existing.id, targetBlock.id, relType, db))
1821
+ db.createRelation({ source_id: existing.id, target_id: targetBlock.id, type: relType, bidirectional: false });
1822
+ }
1823
+ console.log(`Auto-Reflect: duplicate_label merged — "${blockDef.label}" (applied: concepts=${newConcepts.length})`);
1824
+ pipelineSkips.push({ label: blockDef.label, reason: "duplicate_merged" });
1825
+ }
1826
+ else {
1827
+ pipelineSkips.push({ label: blockDef.label, reason: "duplicate_label" });
1828
+ }
1829
+ result.skipped++;
1830
+ continue;
1831
+ }
1832
+ // Segment validation (max 4 underscore-separated segments)
1833
+ if (!labelFromObject) {
1834
+ if (!isValidLabelSegmentCount(blockDef.label, KNOWN_BLOCK_TYPES)) {
1835
+ const segs = blockDef.label.split("_");
1836
+ console.warn(`Auto-Reflect: rejected "${blockDef.label}" — ${segs.length} segments (max 4)`);
1837
+ pipelineSkips.push({ label: blockDef.label, reason: `too_many_segments: ${segs.length}` });
1838
+ result.skipped++;
1839
+ continue;
1840
+ }
1841
+ }
1842
+ // is_a validation
1843
+ if (blockDef.is_a && !seedTypeNames.has(blockDef.is_a)) {
1844
+ console.warn(`Auto-Reflect: rejected "${blockDef.label}" — unknown is_a "${blockDef.is_a}"`);
1845
+ pipelineSkips.push({ label: blockDef.label, reason: `unknown_type: "${blockDef.is_a}"` });
1846
+ result.skipped++;
1847
+ continue;
1848
+ }
1849
+ // Embedding
1850
+ let embedding;
1851
+ if (embeddings?.isAvailable()) {
1852
+ const embText = blockEmbeddingText({ essence: blockDef.essence, concepts: blockDef.concepts });
1853
+ embedding = await embeddings.embed(embText) ?? undefined;
1854
+ }
1855
+ // Infer project from label prefix
1856
+ const relations = [...(blockDef.relations || [])];
1857
+ const projectBlocks = allBlocks.filter((b) => b.type === "project");
1858
+ let inferredProj = projectBlocks.find((p) => blockDef.label.startsWith(p.label + "_"));
1859
+ if (!inferredProj) {
1860
+ const prefix = blockDef.label.split("_")[0];
1861
+ if (prefix && prefix !== blockDef.label) {
1862
+ const existingProj = db.getBlock(prefix);
1863
+ if (existingProj && existingProj.type === "project") {
1864
+ inferredProj = existingProj;
1865
+ allBlocks.push(existingProj);
1866
+ }
1867
+ else {
1868
+ const autoProj = db.createBlock({
1869
+ label: prefix, type: "project", status: "active",
1870
+ essence: `Auto-created project for '${prefix}' domain`,
1871
+ content: { is_a: "project", unique: {}, concepts: [] },
1872
+ ttl: "permanent", source: "Auto-Reflect", created_by: geminiCreatedBy,
1873
+ });
1874
+ stampQualityScore(db, autoProj, []); // Bug 1 fix
1875
+ allBlocks.push(autoProj);
1876
+ newProjectLabels.add(prefix);
1877
+ inferredProj = autoProj;
1878
+ console.log(`Auto-Reflect: auto-created project root "${prefix}" for "${blockDef.label}"`);
1879
+ }
1880
+ }
1881
+ }
1882
+ // TS-layer fallback: if Pass 3 omitted triggered_by, synthesize from Pass 2 triggered_by_items.
1883
+ // Pass 3 is required to output triggered_by but Gemini sometimes omits it despite schema enforcement.
1884
+ // triggered_by_items values are either item IDs ("item_N") or existing block labels.
1885
+ if (!Array.isArray(blockDef.triggered_by) || blockDef.triggered_by.length === 0) {
1886
+ let fromItemId = blockDef.from_item_id;
1887
+ // Secondary fallback: if from_item_id is missing, match by type against unmatched Pass 2 items
1888
+ if (!fromItemId) {
1889
+ const usedItemIds = new Set(itemIdToLabel.keys());
1890
+ const matchedItem = pass2.classified.find((item) => item.type === blockDef.is_a && !usedItemIds.has(item.id));
1891
+ if (matchedItem) {
1892
+ fromItemId = matchedItem.id;
1893
+ blockDef = { ...blockDef, from_item_id: fromItemId };
1894
+ console.log(`Auto-Reflect: inferred from_item_id="${fromItemId}" for "${blockDef.label}" by type match`);
1895
+ }
1896
+ }
1897
+ const p2Items = fromItemId ? (itemTriggeredByItems.get(fromItemId) ?? []) : [];
1898
+ if (p2Items.length > 0) {
1899
+ const synthesized = [];
1900
+ for (const ref of p2Items) {
1901
+ if (ref.startsWith("item_")) {
1902
+ // item ID → resolve after itemIdToLabel is populated (add to pending)
1903
+ // We can't resolve yet since other blocks may not have labels yet —
1904
+ // store as a special marker to process in second pass
1905
+ synthesized.push(`__item_ref__${ref}`);
1906
+ }
1907
+ else {
1908
+ // existing block label — use directly
1909
+ synthesized.push(ref);
1910
+ }
1911
+ }
1912
+ if (synthesized.length > 0)
1913
+ blockDef = { ...blockDef, triggered_by: synthesized };
1914
+ }
1915
+ }
1916
+ // Resolve triggered_by
1917
+ const triggeredByIds = [];
1918
+ const unresolvedTriggeredByRefs = [];
1919
+ for (const ref of (blockDef.triggered_by || [])) {
1920
+ if (ref.startsWith("blk_")) {
1921
+ const b = db.getBlock(ref);
1922
+ if (b)
1923
+ triggeredByIds.push(b.id);
1924
+ }
1925
+ else if (ref.startsWith("__item_ref__")) {
1926
+ // Synthesized item ID reference — defer to second pass once itemIdToLabel is fully populated
1927
+ const itemId = ref.slice("__item_ref__".length);
1928
+ unresolvedTriggeredByRefs.push(`__item_ref__${itemId}`);
1929
+ }
1930
+ else {
1931
+ const b = allBlocks.find((bl) => bl.label === ref);
1932
+ if (b) {
1933
+ triggeredByIds.push(b.id);
1934
+ }
1935
+ else {
1936
+ unresolvedTriggeredByRefs.push(ref);
1937
+ }
1938
+ }
1939
+ }
1940
+ const hasTrigger = triggeredByIds.length > 0;
1941
+ const explicitReviewReason = blockDef.review_reason ||
1942
+ (blockDef.from_item_id ? (reviewMap.get(blockDef.from_item_id) ?? null) : null);
1943
+ // Tier 1B demote-at-save (2026-06-12, default-OFF NODEDEX_SAVE_DEMOTE=1):
1944
+ // the v2 path skips seam-α, so demotable insights (observation present,
1945
+ // implication unfillable) were arriving here FLAGGED instead of demoted.
1946
+ // Apply the SAME universal DEMOTE_TARGETS equivalence at the final seam —
1947
+ // exact-case only; label's type segment renamed so label/type agree; any
1948
+ // shape mismatch falls through to the soft flag below (capture-first).
1949
+ const demotion = process.env.NODEDEX_SAVE_DEMOTE !== "0"
1950
+ ? demoteForSave(blockDef.is_a, (blockDef.unique || {}), blockDef.label)
1951
+ : null;
1952
+ if (demotion) {
1953
+ console.log(`Auto-Reflect Tier1B: demoted ${demotion.from_type}→${demotion.type} at save (required field unfillable): ${demotion.label}`);
1954
+ blockDef.is_a = demotion.type;
1955
+ blockDef.unique = demotion.unique;
1956
+ blockDef.label = demotion.label;
1957
+ }
1958
+ // Tier 1B (2026-05-24): type ↔ unique{} schema validator. Soft mode —
1959
+ // flags mismatches via review_status, never rejects. Charter rule 2 (never
1960
+ // delete vetted blocks) + rule 6 (guards catch failure, never override success).
1961
+ // Source of truth for schemas: docs/reference/block-types.md (verified 2026-05-24).
1962
+ const schemaCheck = validateUniqueSchema(blockDef.is_a, (blockDef.unique || {}));
1963
+ const schemaReason = schemaMismatchReason(schemaCheck);
1964
+ const reviewReason = explicitReviewReason && schemaReason
1965
+ ? `${explicitReviewReason} | ${schemaReason}`
1966
+ : (explicitReviewReason || schemaReason);
1967
+ // Block provenance: default "agent_derived"; demote-edge marks "seam_demoted"
1968
+ // (looked up by from_item_id, mirroring reviewMap). Only the split path with
1969
+ // NODEDEX_SEAM_ALPHA_DEMOTE sets this — monolith leaves the map empty.
1970
+ // Save-time demote (above) marks "save_demoted" — distinct value so the
1971
+ // audit trail shows WHICH seam applied the equivalence.
1972
+ const blockSourceType = demotion
1973
+ ? "save_demoted"
1974
+ : (blockDef.from_item_id ? sourceTypeMap.get(blockDef.from_item_id) : undefined);
1975
+ // DEBT 5 D3 (§2.3.2) Phase 5: line-level provenance pinning. Look up
1976
+ // Pass 1's excerpt by from_item_id and pass to createBlock as
1977
+ // source_excerpt (separate column from content.raw_excerpt — which is
1978
+ // the LLM-emitted Pass 3 justification, may differ across runs).
1979
+ // source_excerpt is STABLE across runs because Pass 1 produces it once
1980
+ // per turn and arc re-extract re-uses the same pass01_output_json.
1981
+ // Falls back to undefined when from_item_id is missing OR Pass 1 left
1982
+ // excerpt empty → createBlock writes NULL → dedup logic (D2) treats
1983
+ // NULL as "pre-Debt-5 atomic" / no-pin and falls through to label dedup.
1984
+ const blockSourceExcerpt = blockDef.from_item_id ? sourceExcerptMap.get(blockDef.from_item_id) : undefined;
1985
+ const created = db.createBlock({
1986
+ label: blockDef.label,
1987
+ type: blockDef.is_a || "note",
1988
+ status: "pending", // activated after Pass 4 — invisible to users until pipeline completes
1989
+ essence: blockDef.essence,
1990
+ content: {
1991
+ is_a: blockDef.is_a,
1992
+ unique: blockDef.unique || {},
1993
+ ...(blockDef.has && Object.keys(blockDef.has).length ? { has: blockDef.has } : {}),
1994
+ relations,
1995
+ concepts: blockDef.concepts || [],
1996
+ concepts_source: "gemini_reflect",
1997
+ novelty_reason: blockDef.novelty_reason || "",
1998
+ raw_excerpt: blockDef.raw_excerpt || "",
1999
+ },
2000
+ concepts: blockDef.concepts || [],
2001
+ ttl: blockDef.ttl || "permanent",
2002
+ source: "Auto-Reflect",
2003
+ ...(blockSourceType ? { source_type: blockSourceType } : {}),
2004
+ ...(blockSourceExcerpt ? { source_excerpt: blockSourceExcerpt } : {}),
2005
+ embedding,
2006
+ created_by: geminiCreatedBy,
2007
+ });
2008
+ stampQualityScore(db, created, blockDef.concepts || []); // Bug 1 fix: pipeline-created blocks must have quality_score set, else recall filters reject them at q=0
2009
+ p3PendingBlockIds.push(created.id);
2010
+ // Inline PROVENANCE check (followup 2026-06-20): the LLM extractor can
2011
+ // FABRICATE source_excerpt. We have the excerpt + the transcript right here,
2012
+ // so verify the quote actually appears in the transcript and FLAG it if not
2013
+ // (flag-don't-act: the block stays, the reviewer/agent judges; best-effort,
2014
+ // never throws). Inline because the transcript is in hand — works arc AND
2015
+ // per-turn, no block_extractions lookup; only 'missing' (totally-wrong) flags.
2016
+ if (blockSourceExcerpt) {
2017
+ flagBlockExcerptInline(db.db, created.id, blockSourceExcerpt, `${_userMessage ?? ""}\n${agentThinking ?? ""}\n${agentResponse ?? ""}`);
2018
+ }
2019
+ if (schemaReason) {
2020
+ console.warn(`Auto-Reflect schema-validator: ${blockDef.label} ${schemaReason}`);
2021
+ }
2022
+ if (reviewReason) {
2023
+ db.updateBlock(created.id, { review_status: "needs_review", review_reason: reviewReason });
2024
+ result.uncertain_count++;
2025
+ }
2026
+ // 4-part label: auto-create subgroup entity if needed
2027
+ let subgroupBlock = null;
2028
+ const labelParts = blockDef.label.split("_");
2029
+ if (labelParts.length >= 4 && inferredProj &&
2030
+ !KNOWN_BLOCK_TYPES.has(labelParts[1]) && KNOWN_BLOCK_TYPES.has(labelParts[2])) {
2031
+ const subgroupLabel = `${labelParts[0]}_${labelParts[1]}`;
2032
+ subgroupBlock = allBlocks.find((b) => b.label === subgroupLabel) ?? db.getBlock(subgroupLabel);
2033
+ if (!subgroupBlock) {
2034
+ subgroupBlock = db.createBlock({
2035
+ label: subgroupLabel, type: "entity", status: "active",
2036
+ essence: `${labelParts[1]} sub-group within ${labelParts[0]}`,
2037
+ content: { is_a: "entity", unique: {}, concepts: [] },
2038
+ ttl: "permanent", source: "Auto-Reflect", created_by: geminiCreatedBy,
2039
+ project_id: inferredProj.id,
2040
+ });
2041
+ stampQualityScore(db, subgroupBlock, []); // Bug 1 fix
2042
+ allBlocks.push(subgroupBlock);
2043
+ console.log(`Auto-Reflect: created subgroup "${subgroupLabel}" under "${inferredProj.label}"`);
2044
+ }
2045
+ }
2046
+ // project_id — set directly on the block (subgroup entity or project root)
2047
+ const projectOwner = subgroupBlock ?? inferredProj;
2048
+ if (projectOwner) {
2049
+ db.updateBlock(created.id, { project_id: projectOwner.id });
2050
+ created.project_id = projectOwner.id;
2051
+ }
2052
+ // prompted_by from triggered_by
2053
+ for (const targetId of triggeredByIds) {
2054
+ if (!shouldSkipRelation(created.id, targetId, "prompted_by", db))
2055
+ db.createRelation({ source_id: created.id, target_id: targetId, type: "prompted_by", bidirectional: false });
2056
+ }
2057
+ // Defer unresolved triggered_by refs
2058
+ for (const ref of unresolvedTriggeredByRefs) {
2059
+ pendingTriggeredBy.push({ sourceId: created.id, labelRef: ref });
2060
+ }
2061
+ // based_on from Pass 3 top-level based_on[] field (same resolution as triggered_by)
2062
+ for (const ref of (blockDef.based_on || [])) {
2063
+ const targetBlock = ref.startsWith("blk_")
2064
+ ? db.getBlock(ref)
2065
+ : allBlocks.find((b) => b.label === ref) ?? null;
2066
+ if (!targetBlock) {
2067
+ pendingTriggeredBy.push({ sourceId: created.id, labelRef: `__based_on__${ref}` });
2068
+ continue;
2069
+ }
2070
+ if (!shouldSkipRelation(created.id, targetBlock.id, "based_on", db)) {
2071
+ db.createRelation({ source_id: created.id, target_id: targetBlock.id, type: "based_on", bidirectional: false });
2072
+ console.log(`Auto-Reflect: based_on (Pass 3) — "${blockDef.label}" → "${targetBlock.label}"`);
2073
+ }
2074
+ }
2075
+ // Extra relations
2076
+ const ALLOWED_RELS = new Set(["contradicts", "based_on", "related_to", "resolves", "supports", "prompted_by", "extends", "supersedes", "superseded_by", "derived_from", "affects"]);
2077
+ for (const rel of (blockDef.relations || [])) {
2078
+ if (!rel?.type || !rel?.target_id || rel.type === "part_of" || rel.type === "null" || rel.target_id === "null")
2079
+ continue;
2080
+ // triggered_by is a Pass 3 alias for prompted_by — translate it
2081
+ const relType = rel.type === "triggered_by" ? "prompted_by" : rel.type;
2082
+ if (!ALLOWED_RELS.has(relType)) {
2083
+ console.warn(`Auto-Reflect: relation type "${rel.type}" on "${blockDef.label}" not in ALLOWED_RELS — dropped`);
2084
+ pipelineSkips.push({ label: blockDef.label, reason: `relation_type_not_allowed: ${rel.type} → ${rel.target_id}` });
2085
+ continue;
2086
+ }
2087
+ const targetBlock = db.getBlock(rel.target_id) ?? allBlocks.find((b) => b.label === rel.target_id) ?? null;
2088
+ if (!targetBlock)
2089
+ continue;
2090
+ if (!shouldSkipRelation(created.id, targetBlock.id, relType, db))
2091
+ db.createRelation({ source_id: created.id, target_id: targetBlock.id, type: relType, bidirectional: false });
2092
+ }
2093
+ // Quality score
2094
+ let qScore = 1;
2095
+ {
2096
+ const qc = typeof created.content === "string" ? (JSON.parse(created.content) || {}) : (created.content || {});
2097
+ if (qc.is_a)
2098
+ qScore++;
2099
+ if (qc.unique && Object.keys(qc.unique).length >= 2)
2100
+ qScore++;
2101
+ if ((blockDef.concepts || []).length >= 3)
2102
+ qScore++;
2103
+ if (db.getRelations(created.id).length > 0)
2104
+ qScore++;
2105
+ qScore = Math.min(qScore, 5);
2106
+ db.updateBlock(created.id, { quality_score: qScore });
2107
+ }
2108
+ if (blockDef.from_item_id)
2109
+ itemIdToLabel.set(blockDef.from_item_id, blockDef.label);
2110
+ allBlocks.push(created);
2111
+ result.saved++;
2112
+ result.saved_labels.push(blockDef.label);
2113
+ result.created_blocks.push({
2114
+ label: blockDef.label,
2115
+ type: blockDef.is_a || "note",
2116
+ quality: qScore,
2117
+ project: inferredProj?.label ?? blockDef.label.split("_")[0] ?? "unknown",
2118
+ });
2119
+ const uniqueVals = blockDef.unique && typeof blockDef.unique === "object" && Object.keys(blockDef.unique).length > 0
2120
+ ? Object.entries(blockDef.unique).map(([k, v]) => `${k}: ${v}`).join(", ")
2121
+ : undefined;
2122
+ _pendingRecentSaves.push({ label: blockDef.label, essence: blockDef.essence, values: uniqueVals });
2123
+ }
2124
+ // Second-pass triggered_by resolution
2125
+ if (pendingTriggeredBy.length > 0) {
2126
+ for (const { sourceId, labelRef } of pendingTriggeredBy) {
2127
+ // Resolve __item_ref__ markers from TS-layer synthesis fallback
2128
+ let resolvedRef = labelRef;
2129
+ if (labelRef.startsWith("__item_ref__")) {
2130
+ const itemId = labelRef.slice("__item_ref__".length);
2131
+ const resolvedLabel = itemIdToLabel.get(itemId);
2132
+ if (!resolvedLabel)
2133
+ continue;
2134
+ resolvedRef = resolvedLabel;
2135
+ }
2136
+ const isBased = labelRef.startsWith("__based_on__");
2137
+ if (isBased)
2138
+ resolvedRef = labelRef.slice("__based_on__".length);
2139
+ const target = allBlocks.find((b) => b.label === resolvedRef);
2140
+ if (!target)
2141
+ continue;
2142
+ const relType = isBased ? "based_on" : "prompted_by";
2143
+ if (shouldSkipRelation(sourceId, target.id, relType, db))
2144
+ continue;
2145
+ db.createRelation({ source_id: sourceId, target_id: target.id, type: relType, bidirectional: false });
2146
+ console.log(`Auto-Reflect: deferred ${relType} resolved — "${resolvedRef}"`);
2147
+ }
2148
+ }
2149
+ // Shared fresh lookup for server-side resolution — allBlocks is built incrementally during
2150
+ // the Pass 3 loop but may miss blocks if any were skipped. A fresh DB fetch is authoritative.
2151
+ const freshBlockByLabel = new Map(db.getAllBlocks().map((b) => [b.label, b]));
2152
+ // Server-side extends_item resolution
2153
+ if (itemIdToLabel.size > 0) {
2154
+ for (const item of pass2.classified) {
2155
+ if (!item.extends_item)
2156
+ continue;
2157
+ const sourceLabel = itemIdToLabel.get(item.id);
2158
+ let targetLabel = itemIdToLabel.get(item.extends_item);
2159
+ if (!targetLabel && !item.extends_item.startsWith("item_") && !item.extends_item.includes("::")) {
2160
+ targetLabel = item.extends_item; // Pass 2 emitted a label (target was skipped); item-shaped refs (v1 item_ / v2 ::) never are
2161
+ }
2162
+ if (!sourceLabel || !targetLabel || sourceLabel === targetLabel) {
2163
+ if (!sourceLabel || !targetLabel) {
2164
+ const missing = !sourceLabel ? `source item_id=${item.id}` : `target extends_item=${item.extends_item}`;
2165
+ console.warn(`Auto-Reflect: extends_item resolution skipped — ${missing} not in itemIdToLabel and not a label`);
2166
+ pipelineSkips.push({ label: item.id, reason: `extends_item_unresolved: ${missing}` });
2167
+ }
2168
+ continue;
2169
+ }
2170
+ const sourceBlock = freshBlockByLabel.get(sourceLabel);
2171
+ const targetBlock = freshBlockByLabel.get(targetLabel) ?? db.getBlock(targetLabel);
2172
+ if (!sourceBlock || !targetBlock)
2173
+ continue;
2174
+ const existingRels = db.getRelations(sourceBlock.id);
2175
+ if (existingRels.some((r) => r.type === "extends" && r.target_id === targetBlock.id))
2176
+ continue;
2177
+ db.createRelation({ source_id: sourceBlock.id, target_id: targetBlock.id, type: "extends", bidirectional: false });
2178
+ console.log(`Auto-Reflect: extends resolved server-side — "${sourceLabel}" → "${targetLabel}"`);
2179
+ }
2180
+ }
2181
+ // Server-side based_on_items resolution
2182
+ // Mirrors triggered_by_items translation — Pass 2 based_on_items[] → based_on relations in DB.
2183
+ // Values are item IDs ("item_N") resolved via itemIdToLabel, or existing block labels.
2184
+ for (const item of pass2.classified) {
2185
+ if (!Array.isArray(item.based_on_items) || item.based_on_items.length === 0)
2186
+ continue;
2187
+ const sourceLabel = itemIdToLabel.get(item.id);
2188
+ if (!sourceLabel)
2189
+ continue;
2190
+ const sourceBlock = freshBlockByLabel.get(sourceLabel);
2191
+ if (!sourceBlock)
2192
+ continue;
2193
+ for (const ref of item.based_on_items) {
2194
+ const resolved = resolveWithinBatchRefLabel(ref, itemIdToLabel);
2195
+ if (!resolved)
2196
+ continue;
2197
+ const targetBlock = freshBlockByLabel.get(resolved.label)
2198
+ ?? (resolved.viaItemMap ? null : db.getBlock(resolved.label));
2199
+ if (!targetBlock)
2200
+ continue;
2201
+ if (shouldSkipRelation(sourceBlock.id, targetBlock.id, "based_on", db))
2202
+ continue;
2203
+ db.createRelation({ source_id: sourceBlock.id, target_id: targetBlock.id, type: "based_on", bidirectional: false });
2204
+ console.log(`Auto-Reflect: based_on resolved server-side — "${sourceLabel}" → "${targetBlock.label}"`);
2205
+ }
2206
+ }
2207
+ // Server-side triggered_by_items resolution (2026-06-15 — the prompted_by edge-loss fix).
2208
+ // MIRRORS the based_on_items loop above. prompted_by was the ONE causal relation never
2209
+ // given a server-side resolver: cefbb2d (May) added one for based_on, and later supersedes
2210
+ // / the semantic rels got theirs, but triggered_by was left on the fragile inline path that
2211
+ // resolves the Pass 3 LLM's RE-EMITTED LABELS during the save loop. That broke two ways —
2212
+ // the Pass 3 LLM silently dropped forward edges it was handed, and demote-at-save renamed a
2213
+ // target out from under a label reference (a `_insight_` → `_fact_` demote orphaned every
2214
+ // prompted_by pointing at the old label). Reading the AUTHORITATIVE item.triggered_by_items
2215
+ // (item-ids) here, AFTER all blocks are saved + demoted, via the demote-aware item-map, fixes
2216
+ // both — identical to how based_on works. shouldSkipRelation dedupes against the inline path.
2217
+ // (2026-06-15 aquarium arc: a blueprint whose only edge was prompted_by was orphaned; live
2218
+ // graphs carried 0 prompted_by edges as a result.)
2219
+ for (const item of pass2.classified) {
2220
+ if (!Array.isArray(item.triggered_by_items) || item.triggered_by_items.length === 0)
2221
+ continue;
2222
+ const sourceLabel = itemIdToLabel.get(item.id);
2223
+ if (!sourceLabel)
2224
+ continue;
2225
+ const sourceBlock = freshBlockByLabel.get(sourceLabel);
2226
+ if (!sourceBlock)
2227
+ continue;
2228
+ for (const ref of item.triggered_by_items) {
2229
+ const resolved = resolveWithinBatchRefLabel(ref, itemIdToLabel);
2230
+ if (!resolved)
2231
+ continue;
2232
+ const targetBlock = freshBlockByLabel.get(resolved.label)
2233
+ ?? (resolved.viaItemMap ? null : db.getBlock(resolved.label));
2234
+ if (!targetBlock)
2235
+ continue;
2236
+ if (shouldSkipRelation(sourceBlock.id, targetBlock.id, "prompted_by", db))
2237
+ continue;
2238
+ db.createRelation({ source_id: sourceBlock.id, target_id: targetBlock.id, type: "prompted_by", bidirectional: false });
2239
+ console.log(`Auto-Reflect: prompted_by resolved server-side — "${sourceLabel}" → "${targetBlock.label}"`);
2240
+ }
2241
+ }
2242
+ // Server-side within-batch supersedes resolution (Pass 2 supersedes_ref = item ID)
2243
+ // When Pass 2 sets supersedes_ref to an item ID (not a block label), resolve it here.
2244
+ // Within-batch test = map membership, NOT an id-prefix (v2 ids are group::local).
2245
+ for (const item of pass2.classified) {
2246
+ if (!item.supersedes_ref || !itemIdToLabel.has(item.supersedes_ref))
2247
+ continue;
2248
+ const sourceLabel = itemIdToLabel.get(item.id);
2249
+ if (!sourceLabel)
2250
+ continue;
2251
+ const sourceBlock = freshBlockByLabel.get(sourceLabel);
2252
+ if (!sourceBlock)
2253
+ continue;
2254
+ const targetLabel = itemIdToLabel.get(item.supersedes_ref);
2255
+ if (!targetLabel)
2256
+ continue;
2257
+ const targetBlock = freshBlockByLabel.get(targetLabel);
2258
+ if (!targetBlock)
2259
+ continue;
2260
+ if (shouldSkipRelation(sourceBlock.id, targetBlock.id, "supersedes", db))
2261
+ continue;
2262
+ db.createRelation({ source_id: sourceBlock.id, target_id: targetBlock.id, type: "supersedes", bidirectional: false });
2263
+ console.log(`Auto-Reflect: supersedes resolved server-side (within-batch) — "${sourceLabel}" → "${targetLabel}"`);
2264
+ }
2265
+ // Server-side semantic relations resolution (Pass 2 relations[] field)
2266
+ // Handles contradicts, supports, resolves, derived_from, affects wired by Pass 2 Q5.
2267
+ // Values are item IDs ("item_N") or existing block labels — same pattern as based_on_items.
2268
+ const SEMANTIC_RELS = new Set(["contradicts", "supports", "resolves", "derived_from", "affects", "related_to"]);
2269
+ for (const item of pass2.classified) {
2270
+ if (!Array.isArray(item.relations) || item.relations.length === 0)
2271
+ continue;
2272
+ const sourceLabel = itemIdToLabel.get(item.id);
2273
+ if (!sourceLabel)
2274
+ continue;
2275
+ const sourceBlock = freshBlockByLabel.get(sourceLabel);
2276
+ if (!sourceBlock)
2277
+ continue;
2278
+ for (const rel of item.relations) {
2279
+ if (!rel?.type || !rel?.target || !SEMANTIC_RELS.has(rel.type))
2280
+ continue;
2281
+ const resolved = resolveWithinBatchRefLabel(rel.target, itemIdToLabel);
2282
+ if (!resolved)
2283
+ continue;
2284
+ const targetBlock = freshBlockByLabel.get(resolved.label)
2285
+ ?? (resolved.viaItemMap ? null : db.getBlock(resolved.label));
2286
+ if (!targetBlock)
2287
+ continue;
2288
+ if (shouldSkipRelation(sourceBlock.id, targetBlock.id, rel.type, db))
2289
+ continue;
2290
+ db.createRelation({ source_id: sourceBlock.id, target_id: targetBlock.id, type: rel.type, bidirectional: false });
2291
+ console.log(`Auto-Reflect: ${rel.type} resolved server-side — "${sourceLabel}" → "${targetBlock.label}"`);
2292
+ }
2293
+ }
2294
+ // Stamp chain_id on connected groups
2295
+ stampFlowRolesAndChains(result.saved_labels, allBlocks, db);
2296
+ // ── SLICE 1 SUB-STEP 1.4 — STAGE FLAG writer ──────────────────────────────
2297
+ // For each (source_excerpt, primary_value)-twin DETECTED by D2 dedup
2298
+ // earlier in this run, write a pipeline_flags row of type='atomic_dup_
2299
+ // candidate'. The async LLM reviewer (Slice 2) will consume these and
2300
+ // decide merge/leave/split with full graph context.
2301
+ //
2302
+ // Resolution: dedup detected at the item-id level (e.g., "item_T1_3");
2303
+ // pipeline_flags FK requires block_id. We resolve via itemIdToLabel
2304
+ // (populated during createBlock loop) → db.getBlock(label) → block.id.
2305
+ // When either side fails to resolve, we skip the flag (graceful degrade;
2306
+ // the duplicate already exists in the graph, just without the flag —
2307
+ // Slice 2 AUDIT will catch the missed ones later).
2308
+ //
2309
+ // origin_range_id is NULL here — pipeline.ts doesn't know arc range_id
2310
+ // (arc-pipeline.ts owns range creation, after runAutoReflect returns).
2311
+ // Stage AUDIT (Slice 2) can backfill if needed; for Sub-step 1.4 the
2312
+ // flag without range linkage is still actionable for the reviewer.
2313
+ if (atomicDupCandidates.length > 0) {
2314
+ let flagsWritten = 0;
2315
+ let flagsSkipped = 0;
2316
+ const rawDb = db.rawDb ?? db.db;
2317
+ if (!rawDb) {
2318
+ console.warn(`[stage-flag] cannot write ${atomicDupCandidates.length} flag(s) — no raw DB handle`);
2319
+ }
2320
+ else {
2321
+ for (const dup of atomicDupCandidates) {
2322
+ try {
2323
+ const loserLabel = itemIdToLabel.get(dup.id);
2324
+ const winnerLabel = itemIdToLabel.get(dup.duplicate_of);
2325
+ if (!loserLabel || !winnerLabel) {
2326
+ flagsSkipped++;
2327
+ continue;
2328
+ }
2329
+ const loserBlock = db.getBlock(loserLabel);
2330
+ const winnerBlock = db.getBlock(winnerLabel);
2331
+ if (!loserBlock || !winnerBlock) {
2332
+ flagsSkipped++;
2333
+ continue;
2334
+ }
2335
+ writePipelineFlag(rawDb, {
2336
+ flag_type: 'atomic_dup_candidate',
2337
+ block_id_a: loserBlock.id,
2338
+ block_id_b: winnerBlock.id,
2339
+ criteria: {
2340
+ detected_by: 'dedup_by_source_and_value',
2341
+ dedup_key: dup.key,
2342
+ loser_item_id: dup.id,
2343
+ winner_item_id: dup.duplicate_of,
2344
+ loser_label: loserLabel,
2345
+ winner_label: winnerLabel,
2346
+ },
2347
+ scope_check: 'unknown',
2348
+ origin_writer: 'stage_flag_dedup',
2349
+ origin_range_id: null,
2350
+ });
2351
+ flagsWritten++;
2352
+ }
2353
+ catch (e) {
2354
+ console.warn(`[stage-flag] writePipelineFlag failed for ${dup.id}: ${e?.message}`);
2355
+ flagsSkipped++;
2356
+ }
2357
+ }
2358
+ if (flagsWritten > 0 || flagsSkipped > 0) {
2359
+ console.log(`[stage-flag] wrote ${flagsWritten} atomic_dup_candidate flag(s), skipped ${flagsSkipped} (block resolution failed)`);
2360
+ }
2361
+ }
2362
+ }
2363
+ // ── DEBT 5 Slice 3 Part 4 — STAGE D flag writer (Touchpoint B) ──────────────
2364
+ // Drain the cross-arc resolve results (Touchpoint A) into cross_arc_dup_candidate
2365
+ // flags, now that item_id → block_id is resolvable via itemIdToLabel. Same
2366
+ // pattern + same graceful-degrade as the atomic_dup writer above. Stage D
2367
+ // FLAGS only — the async reviewer (flag-reviewer.ts) decides merge/leave and is
2368
+ // the sole actor that mutates the graph.
2369
+ // block_id_a = THIS arc's newly-created block (the new item)
2370
+ // block_id_b = the existing graph block it matched against (now set for
2371
+ // flag_for_review too, not just attach_existing)
2372
+ // Which side WINS is decided at REVIEW time, not here. For attach_existing the
2373
+ // existing block (b) is canonical; for flag_for_review (owner-unknown) the winner
2374
+ // is undetermined — the agent/reviewer/user adjudicates (often the owned new block
2375
+ // adopts the orphan). So a/b are just "the pair", NOT a fixed loser/winner.
2376
+ if (stageDEntries.length > 0) {
2377
+ let sdWritten = 0, sdSkipped = 0;
2378
+ const rawDb = db.rawDb ?? db.db;
2379
+ if (!rawDb) {
2380
+ console.warn(`[stage-d] cannot write ${stageDEntries.length} flag(s) — no raw DB handle`);
2381
+ }
2382
+ else {
2383
+ for (const e of stageDEntries) {
2384
+ try {
2385
+ const newLabel = itemIdToLabel.get(e.item_id);
2386
+ if (!newLabel) {
2387
+ sdSkipped++;
2388
+ continue;
2389
+ } // item wasn't written as a block (skipped) — nothing to flag
2390
+ const newBlock = db.getBlock(newLabel);
2391
+ if (!newBlock) {
2392
+ sdSkipped++;
2393
+ continue;
2394
+ }
2395
+ writePipelineFlag(rawDb, {
2396
+ flag_type: 'cross_arc_dup_candidate',
2397
+ block_id_a: newBlock.id,
2398
+ block_id_b: e.matched_block_id ?? null,
2399
+ criteria: {
2400
+ detected_by: 'stage_d_resolve',
2401
+ decision: e.decision, // 'attach_existing' | 'flag_for_review'
2402
+ resolved_by: e.resolved_by, // 'code_exact' | 'llm' | 'no_candidates'
2403
+ new_item_id: e.item_id,
2404
+ new_label: newLabel,
2405
+ matched_label: e.matched_block_label ?? null,
2406
+ reasoning: e.reasoning,
2407
+ flag_reason: e.flag_reason ?? null,
2408
+ },
2409
+ // attach_existing = resolver judged same scope; flag_for_review = owner unknown.
2410
+ scope_check: e.decision === 'attach_existing' ? 'same' : 'unknown',
2411
+ origin_writer: 'stage_d_resolve',
2412
+ origin_range_id: null,
2413
+ });
2414
+ sdWritten++;
2415
+ }
2416
+ catch (err) {
2417
+ console.warn(`[stage-d] writePipelineFlag failed for ${e.item_id}: ${err?.message}`);
2418
+ sdSkipped++;
2419
+ }
2420
+ }
2421
+ if (sdWritten > 0 || sdSkipped > 0) {
2422
+ console.log(`[stage-d] wrote ${sdWritten} cross_arc_dup_candidate flag(s), skipped ${sdSkipped} (block resolution failed)`);
2423
+ }
2424
+ }
2425
+ }
2426
+ // Process updates
2427
+ const ALLOWED_RELS = new Set(["contradicts", "based_on", "related_to", "resolves", "supports", "prompted_by", "extends", "supersedes", "superseded_by", "derived_from", "affects"]);
2428
+ for (const updateDef of (analysis.updates || [])) {
2429
+ if (!updateDef?.block_id)
2430
+ continue;
2431
+ const existing = db.getBlock(updateDef.block_id);
2432
+ if (!existing)
2433
+ continue;
2434
+ const content = typeof existing.content === "string"
2435
+ ? JSON.parse(existing.content)
2436
+ : Object.assign({}, existing.content);
2437
+ if (updateDef.unique_patch && typeof updateDef.unique_patch === "object") {
2438
+ content.unique = { ...(content.unique || {}), ...updateDef.unique_patch };
2439
+ }
2440
+ const blockUpdates = { content };
2441
+ if (updateDef.essence)
2442
+ blockUpdates.essence = updateDef.essence;
2443
+ if (updateDef.ttl) {
2444
+ console.warn(`Auto-Reflect: ttl "${updateDef.ttl}" on update for "${existing.label}" ignored — updates do not apply ttl changes`);
2445
+ pipelineSkips.push({ label: existing.label, reason: `update_ttl_ignored: ${updateDef.ttl}` });
2446
+ }
2447
+ db.updateBlock(updateDef.block_id, blockUpdates, updateDef.reason || "Auto-Reflect Update");
2448
+ result.updated++;
2449
+ result.updated_blocks.push({ label: existing.label, type: existing.type });
2450
+ if (updateDef.relations_add && Array.isArray(updateDef.relations_add)) {
2451
+ for (const rel of updateDef.relations_add) {
2452
+ if (!rel?.type || !rel?.target_id)
2453
+ continue;
2454
+ // triggered_by is a Pass 3 alias for prompted_by — translate it
2455
+ const relType = rel.type === "triggered_by" ? "prompted_by" : rel.type;
2456
+ if (!ALLOWED_RELS.has(relType)) {
2457
+ console.warn(`Auto-Reflect: relation type "${rel.type}" in relations_add on "${existing.label}" not in ALLOWED_RELS — dropped`);
2458
+ pipelineSkips.push({ label: existing.label, reason: `update_relation_type_not_allowed: ${rel.type} → ${rel.target_id}` });
2459
+ continue;
2460
+ }
2461
+ const targetBlock = db.getBlock(rel.target_id) ?? allBlocks.find((b) => b.label === rel.target_id) ?? null;
2462
+ if (!targetBlock)
2463
+ continue;
2464
+ if (!shouldSkipRelation(existing.id, targetBlock.id, relType, db)) {
2465
+ db.createRelation({ source_id: existing.id, target_id: targetBlock.id, type: relType, bidirectional: false });
2466
+ console.log(`Auto-Reflect: added ${relType} link on "${existing.label}" → "${targetBlock.label}"`);
2467
+ }
2468
+ }
2469
+ }
2470
+ const updContent = typeof existing.content === "string"
2471
+ ? (JSON.parse(existing.content) || {})
2472
+ : (existing.content || {});
2473
+ const updUnique = updContent.unique && typeof updContent.unique === "object" && Object.keys(updContent.unique).length > 0
2474
+ ? Object.entries(updContent.unique).map(([k, v]) => `${k}: ${v}`).join(", ")
2475
+ : undefined;
2476
+ _pendingRecentSaves.push({
2477
+ label: existing.label,
2478
+ essence: updateDef.essence || existing.essence || "",
2479
+ update_note: updateDef.reason || "fields updated",
2480
+ values: updUnique,
2481
+ });
2482
+ }
2483
+ }
2484
+ else {
2485
+ // ── resumeFrom 'pass4': restore result from blocks already written as 'pending' in DB ──
2486
+ // Pass 3 completed on a prior attempt; blocks exist in DB as status='pending'.
2487
+ // Restore result state so Pass 4 can wire relations, then activate blocks after.
2488
+ const pendingBlocksInDb = db.getAllBlocks().filter((b) => p3PendingBlockIds.includes(b.id));
2489
+ for (const b of pendingBlocksInDb) {
2490
+ result.saved++;
2491
+ result.saved_labels.push(b.label);
2492
+ result.created_blocks.push({
2493
+ label: b.label,
2494
+ type: b.type,
2495
+ quality: b.quality_score ?? 1,
2496
+ project: b.label.split('_')[0] ?? 'unknown',
2497
+ });
2498
+ const bContent = typeof b.content === "string" ? (JSON.parse(b.content) || {}) : (b.content || {});
2499
+ const bUnique = bContent.unique && typeof bContent.unique === "object" && Object.keys(bContent.unique).length > 0
2500
+ ? Object.entries(bContent.unique).map(([k, v]) => `${k}: ${v}`).join(", ")
2501
+ : undefined;
2502
+ _pendingRecentSaves.push({ label: b.label, essence: b.essence || "", values: bUnique });
2503
+ if (!allBlocks.find((x) => x.id === b.id))
2504
+ allBlocks.push(b);
2505
+ }
2506
+ console.log(`Auto-Reflect Pass 4 retry: restored ${result.saved} pending block(s) from DB`);
2507
+ } // end if (checkpoint?.resumeFrom !== 'pass4')
2508
+ // ── PASS 4: Connect ──
2509
+ if (result.saved > 0) {
2510
+ // ── INLINE DEDUP (recognize-before-write) ──
2511
+ // Merge this-turn's cross-turn duplicates BEFORE Pass 4 links them, so no
2512
+ // spurious `extends` edge forms between a block and its own restatement
2513
+ // (which the reviewer would then read as "elaboration, keep both" and
2514
+ // refuse to merge — the async-after-linking failure). Serves compounding:
2515
+ // one residue → one block. Default-off NODEDEX_INLINE_DEDUP; the async
2516
+ // AUDIT stays the cross-session backstop. Losers are archived, so the
2517
+ // freshBlocks re-query below naturally excludes them from Pass 4 / Pass 5.
2518
+ if (inlineDedupEnabled() && p3PendingBlockIds.length > 0) {
2519
+ try {
2520
+ const { flagsWritten, merges, routed, mergedAway, mergedAwayLabels } = await dedupNewBlocksInline(db, provider, p3PendingBlockIds);
2521
+ if (mergedAway.size > 0) {
2522
+ for (let i = p3PendingBlockIds.length - 1; i >= 0; i--) {
2523
+ if (mergedAway.has(p3PendingBlockIds[i]))
2524
+ p3PendingBlockIds.splice(i, 1);
2525
+ }
2526
+ result.saved_labels = result.saved_labels.filter((l) => !mergedAwayLabels.has(l));
2527
+ result.saved = Math.max(0, result.saved - mergedAway.size);
2528
+ }
2529
+ console.log(`Auto-Reflect inline-dedup: ${flagsWritten} flag(s), ${merges} merged, ${routed} routed-to-agent (before Pass 4)`);
2530
+ }
2531
+ catch (e) {
2532
+ console.warn(`Auto-Reflect inline-dedup failed (continuing to Pass 4): ${e?.message ?? e}`);
2533
+ }
2534
+ }
2535
+ const freshBlocks = db.getAllBlocks();
2536
+ const freshRels = db.getAllRelations(false);
2537
+ const savedLabelSet = new Set(result.saved_labels);
2538
+ // Pass 4's job is to link THIS-TURN's new blocks to the EXISTING graph from prior sessions.
2539
+ // The PROJECT GRAPH context must therefore exclude this-turn's just-saved blocks — otherwise
2540
+ // Pass 4 can match a new block against itself and emit self-referential supersedes.
2541
+ // (Also prevents stampReflectedAt from prematurely stamping the new blocks.)
2542
+ // Gap ⑤ (scale): feed Pass 4 a RETRIEVED SLICE instead of the whole-graph
2543
+ // dump — but only above the small-graph threshold (below it the dump is
2544
+ // cheaper than k retrievals). Default OFF; identical output contract.
2545
+ let freshContext;
2546
+ let _reflectedIds2;
2547
+ let p4SliceMode = false;
2548
+ if (pass4SliceEnabled() && freshBlocks.length >= pass4SliceMinGraph()) {
2549
+ p4SliceMode = true;
2550
+ const newBlocksRaw = freshBlocks.filter((b) => savedLabelSet.has(b.label));
2551
+ const slice = buildPass4Slice(db, newBlocksRaw);
2552
+ freshContext = slice.context;
2553
+ _reflectedIds2 = slice.reflectedIds;
2554
+ console.log(`Auto-Reflect Pass 4: slice mode — ${_reflectedIds2.length} candidate block(s) from ${freshBlocks.length} total`);
2555
+ }
2556
+ else {
2557
+ const contextBlocks = freshBlocks.filter((b) => !savedLabelSet.has(b.label));
2558
+ const built = buildProjectContext(contextBlocks, freshRels, allProjectPrefixes, loadedBlocks);
2559
+ freshContext = built.context;
2560
+ _reflectedIds2 = built.reflectedIds;
2561
+ }
2562
+ db.stampReflectedAt(_reflectedIds2);
2563
+ const freshBlockById = new Map(freshBlocks.map((b) => [b.id, b]));
2564
+ const p4RelMap = new Map();
2565
+ for (const r of freshRels) {
2566
+ if (r.status !== "active" || r.type === "part_of")
2567
+ continue;
2568
+ if (!p4RelMap.has(r.source_id))
2569
+ p4RelMap.set(r.source_id, []);
2570
+ p4RelMap.get(r.source_id).push({ type: r.type, targetId: r.target_id });
2571
+ }
2572
+ const newBlocksForP4 = freshBlocks
2573
+ .filter((b) => savedLabelSet.has(b.label))
2574
+ .map((b) => {
2575
+ let uniqueFields = "";
2576
+ try {
2577
+ const c = typeof b.content === "string" ? JSON.parse(b.content) : b.content;
2578
+ const u = c?.unique ?? {};
2579
+ const pairs = Object.entries(u)
2580
+ .filter(([, v]) => v && String(v).trim())
2581
+ .map(([k, v]) => `${k}: "${String(v).slice(0, 80)}"`)
2582
+ .slice(0, 4);
2583
+ if (pairs.length > 0)
2584
+ uniqueFields = ` | unique: { ${pairs.join(", ")} }`;
2585
+ }
2586
+ catch { /* skip */ }
2587
+ const rels = p4RelMap.get(b.id) ?? [];
2588
+ const chainLines = rels
2589
+ .filter((r) => ["prompted_by", "based_on", "supersedes", "extends", "derived_from"].includes(r.type))
2590
+ .slice(0, 4)
2591
+ .map((r) => {
2592
+ const linked = freshBlockById.get(r.targetId);
2593
+ if (!linked)
2594
+ return null;
2595
+ let linkedUnique = "";
2596
+ try {
2597
+ const lc = typeof linked.content === "string" ? JSON.parse(linked.content) : linked.content;
2598
+ const lu = lc?.unique ?? {};
2599
+ const lp = Object.entries(lu).filter(([, v]) => v).map(([k, v]) => `${k}:"${String(v).slice(0, 60)}"`).slice(0, 2);
2600
+ if (lp.length)
2601
+ linkedUnique = ` {${lp.join(", ")}}`;
2602
+ }
2603
+ catch { /* skip */ }
2604
+ return ` ${r.type}→ ${linked.label} — "${(linked.essence || "").slice(0, 80)}"${linkedUnique}`;
2605
+ })
2606
+ .filter(Boolean);
2607
+ return {
2608
+ id: b.id,
2609
+ label: b.label,
2610
+ type: b.type,
2611
+ essence: b.essence || "",
2612
+ uniqueFields,
2613
+ chain: chainLines,
2614
+ };
2615
+ });
2616
+ if (newBlocksForP4.length > 0) {
2617
+ // ── Batched emission (2026-07-03) ──────────────────────────────────────
2618
+ // ONE call per ≤NODEDEX_PASS4_BATCH new blocks (default 20), mirroring
2619
+ // fill_2b. The input-side slice was always capped; the OUTPUT grows with
2620
+ // the new-block count — 157 new blocks in one call blew the model's output
2621
+ // cap in the dogfood run (truncated twice → whole pass failed → every
2622
+ // cross-group conclusion orphaned). Batching bounds each call's output and
2623
+ // isolates failures: a truncated batch loses only its own links.
2624
+ // Relations still accumulate and apply AFTER all batches (same contract as
2625
+ // the old single call), so the rate-limit checkpoint semantics are
2626
+ // unchanged: nothing is applied on a mid-run rate limit, and the retry
2627
+ // re-runs the whole pass (createRelation is idempotent regardless).
2628
+ const p4Queue = chunkForPass4(newBlocksForP4);
2629
+ const p4BatchTotal = p4Queue.length;
2630
+ const p4Relations = [];
2631
+ let p4AnySuccess = false;
2632
+ let p4RateLimited = false;
2633
+ let p4Processed = 0;
2634
+ let p4Splits = 0;
2635
+ // A FAILED batch (truncation-shaped: result null, not rate-limited) is REQUEUED
2636
+ // BY BISECTION: output size scales with batch size, so halving and retrying both
2637
+ // halves is the retry that can actually succeed — unlike the provider's same-size
2638
+ // retry, which is a no-op at the model's output ceiling. Split floor of 4 (halves
2639
+ // of ≥2): a 2-3 block batch that still fails is not a size problem — skip it.
2640
+ // Split budget caps the extra calls so a systemically-failing provider can't loop.
2641
+ const P4_MAX_SPLITS = 8;
2642
+ const p4ThinkingParts = [];
2643
+ const _t4 = Date.now();
2644
+ while (p4Queue.length > 0) {
2645
+ const batch = p4Queue.shift();
2646
+ p4Processed++;
2647
+ // Slice mode: rebuild the candidate slice for THIS batch only — tighter,
2648
+ // more relevant context per call. Whole-graph mode: the context is the
2649
+ // existing graph (independent of the new blocks), so reuse it.
2650
+ let batchContext = freshContext;
2651
+ if (p4SliceMode && (p4BatchTotal > 1 || p4Splits > 0)) {
2652
+ const raw = batch.map((nb) => freshBlockById.get(nb.id)).filter((b) => !!b);
2653
+ batchContext = buildPass4Slice(db, raw).context;
2654
+ }
2655
+ const p4Budget = getThinkingBudget(batch.length <= 5 ? 1024 : 2048);
2656
+ const p4 = await callPass4LLM(provider, batch, batchContext, p4Budget, _sceneCard);
2657
+ if (!_pass4Provider || p4.model)
2658
+ _pass4Provider = { model: p4.model, attempts: p4.attempts };
2659
+ if (p4.rateLimited) {
2660
+ p4RateLimited = true;
2661
+ break;
2662
+ }
2663
+ if (p4.thinking)
2664
+ p4ThinkingParts.push(p4.thinking);
2665
+ const total = p4BatchTotal + p4Splits;
2666
+ if (p4.result) {
2667
+ p4AnySuccess = true;
2668
+ if (p4.result.relations?.length)
2669
+ p4Relations.push(...p4.result.relations);
2670
+ if (total > 1)
2671
+ console.log(`Auto-Reflect Pass 4: batch ${p4Processed}/${total} (${batch.length} block(s)) → ${p4.result.relations?.length ?? 0} relation(s)`);
2672
+ }
2673
+ else if (batch.length >= 4 && p4Splits < P4_MAX_SPLITS) {
2674
+ const mid = Math.ceil(batch.length / 2);
2675
+ p4Queue.unshift(batch.slice(0, mid), batch.slice(mid));
2676
+ p4Splits++;
2677
+ console.log(`Auto-Reflect Pass 4: batch ${p4Processed}/${total} (${batch.length} block(s)) failed → split ${mid}+${batch.length - mid}, requeued`);
2678
+ }
2679
+ else {
2680
+ console.log(`Auto-Reflect Pass 4: batch ${p4Processed}/${total} (${batch.length} block(s)) failed — skipped (${batch.length} block(s) left unlinked this run)`);
2681
+ }
2682
+ }
2683
+ _passWallMs.pass4 = Date.now() - _t4;
2684
+ if (p4RateLimited) {
2685
+ // Blocks are in DB as 'pending' — save checkpoint so Pass 4 retries with those blocks.
2686
+ // Do NOT activate pending blocks yet; they stay invisible until Pass 4 completes.
2687
+ console.log(`Auto-Reflect Pass 4: rate limited — ${p3PendingBlockIds.length} block(s) kept pending, re-queuing`);
2688
+ return { ...empty, checkpoint: {
2689
+ resumeFrom: 'pass4',
2690
+ pass0: { sceneCard: _sceneCard, raw: _pass0Raw },
2691
+ pass1Items: pass1?.items,
2692
+ pass2Classified: pass2?.classified,
2693
+ p3PendingBlockIds: p3PendingBlockIds.length > 0 ? p3PendingBlockIds : undefined,
2694
+ } };
2695
+ }
2696
+ const p4Merged = p4AnySuccess ? { relations: p4Relations } : null;
2697
+ _pass4Thinking = p4ThinkingParts.join("\n");
2698
+ _pass4Result = p4Merged;
2699
+ writeReflectLog({ pass1, pass2, pass3: analysis, pass4: p4Merged });
2700
+ if (p4Merged?.relations?.length) {
2701
+ const PASS4_ALLOWED = new Set(["extends", "supersedes", "superseded_by", "prompted_by", "based_on", "resolves"]);
2702
+ let linked = 0;
2703
+ for (const rel of p4Merged.relations) {
2704
+ if (!rel?.source_id || !rel?.type || !rel?.target_id)
2705
+ continue;
2706
+ if (!PASS4_ALLOWED.has(rel.type))
2707
+ continue;
2708
+ const src = freshBlocks.find((b) => b.label === rel.source_id || b.id === rel.source_id);
2709
+ const tgt = freshBlocks.find((b) => b.label === rel.target_id || b.id === rel.target_id);
2710
+ if (!src || !tgt || src.id === tgt.id)
2711
+ continue;
2712
+ // Skip project-type blocks — they are containers, not knowledge
2713
+ if (src.type === 'project' || tgt.type === 'project')
2714
+ continue;
2715
+ // Skip intra-batch: Pass 2 already wired same-batch relations
2716
+ if (savedLabelSet.has(src.label) && savedLabelSet.has(tgt.label))
2717
+ continue;
2718
+ const EXCLUDED_REL_PREFIXES = new Set(["agent-meta", "system"]);
2719
+ if (EXCLUDED_REL_PREFIXES.has((src.label || "").split("_")[0]) ||
2720
+ EXCLUDED_REL_PREFIXES.has((tgt.label || "").split("_")[0]))
2721
+ continue;
2722
+ if (shouldSkipRelation(src.id, tgt.id, rel.type, db))
2723
+ continue;
2724
+ // Skip if reverse relation already exists (prevents bidirectional cycles)
2725
+ if (db.getRelations(tgt.id).some(r => r.direction === "outgoing" && r.target_id === src.id))
2726
+ continue;
2727
+ db.createRelation({ source_id: src.id, target_id: tgt.id, type: rel.type, bidirectional: false });
2728
+ console.log(`Auto-Reflect Pass 4: "${src.label}" --[${rel.type}]--> "${tgt.label}"`);
2729
+ linked++;
2730
+ }
2731
+ if (linked > 0)
2732
+ console.log(`Auto-Reflect Pass 4: ${linked} relation(s) applied`);
2733
+ }
2734
+ }
2735
+ }
2736
+ // Activate pending blocks — Pass 4 has completed (or was skipped — no new blocks).
2737
+ // Blocks are now visible to users and appear in graph navigation.
2738
+ if (p3PendingBlockIds.length > 0) {
2739
+ const { activated, skippedArchived } = activatePendingBlocks(db, p3PendingBlockIds);
2740
+ console.log(`Auto-Reflect: activated ${activated}/${p3PendingBlockIds.length} pending block(s)` +
2741
+ (skippedArchived > 0 ? ` (${skippedArchived} archived in-turn by same-turn supersede — left as history)` : ""));
2742
+ }
2743
+ // ── PASS 5: Chain Assembly ──
2744
+ if (result.saved >= 2) {
2745
+ const freshBlocks5 = db.getAllBlocks();
2746
+ const freshRels5 = db.getAllRelations(false);
2747
+ const savedLabelSet5 = new Set(result.saved_labels);
2748
+ const freshBlockById5 = new Map(freshBlocks5.map((b) => [b.id, b]));
2749
+ const p5Blocks = freshBlocks5
2750
+ .filter((b) => savedLabelSet5.has(b.label))
2751
+ .map((b) => ({
2752
+ id: b.id,
2753
+ label: b.label,
2754
+ type: b.type,
2755
+ essence: b.essence || "",
2756
+ }));
2757
+ // Pass 5 sees the full causal-thread set (relation-sets.ts), same as the
2758
+ // chain_id clustering above — so supports-linked blocks (the dominant
2759
+ // evidential edge) are visible to assembly and can join a chain block,
2760
+ // not just the narrow prompted_by/based_on/supersedes spine. The Pass 5
2761
+ // prompt's connector list is aligned to match (pass5.ts).
2762
+ const p5Rels = freshRels5.filter((r) => r.status === "active" &&
2763
+ CAUSAL_TRAVERSAL_RELS.has(r.type) &&
2764
+ (savedLabelSet5.has(freshBlockById5.get(r.source_id)?.label ?? "") ||
2765
+ savedLabelSet5.has(freshBlockById5.get(r.target_id)?.label ?? "")));
2766
+ const _t5 = Date.now();
2767
+ const p5Response = await callPass5LLM(provider, p5Blocks, p5Rels);
2768
+ _passWallMs.pass5 = Date.now() - _t5;
2769
+ const p5 = p5Response.result;
2770
+ _pass5Result = p5; // capture for turn-log persistence (carries per-chain reasoning)
2771
+ _pass5Provider = { model: p5Response.model, attempts: p5Response.attempts }; // debt-4 §3: pass5 now has own provider slot
2772
+ if (p5?.chains?.length) {
2773
+ // Track all blk_ chain_ids assigned this pass for straggler sweep
2774
+ const assignedChainIds = new Map(); // blk_chainId → chain block id
2775
+ for (const chain of p5.chains) {
2776
+ let chainBlock = db.getBlock(chain.chain_label);
2777
+ const chainContent = {
2778
+ is_a: "chain",
2779
+ unique: { arc: chain.arc, ...(chain.conclusion ? { conclusion: chain.conclusion } : {}) },
2780
+ concepts: [],
2781
+ };
2782
+ if (!chainBlock) {
2783
+ chainBlock = db.createBlock({
2784
+ label: chain.chain_label,
2785
+ type: "chain",
2786
+ status: "active",
2787
+ essence: chain.chain_essence,
2788
+ content: chainContent,
2789
+ ttl: "permanent",
2790
+ source: "Auto-Reflect",
2791
+ created_by: geminiCreatedBy,
2792
+ });
2793
+ // Compute quality score — createBlock always initializes to 0
2794
+ if (chainBlock) {
2795
+ let qScore = 0;
2796
+ if (chain.chain_essence?.trim())
2797
+ qScore++;
2798
+ if (chainContent.is_a)
2799
+ qScore++;
2800
+ const uFields = Object.values(chainContent.unique).filter(v => v && String(v).trim());
2801
+ if (uFields.length >= 2)
2802
+ qScore++;
2803
+ db.updateBlock(chainBlock.id, { quality_score: Math.min(qScore, 5) });
2804
+ }
2805
+ }
2806
+ else {
2807
+ db.updateBlock(chainBlock.id, { essence: chain.chain_essence, content: chainContent });
2808
+ }
2809
+ if (chainBlock) {
2810
+ for (const memberLabel of chain.members) {
2811
+ const member = freshBlocks5.find((b) => b.label === memberLabel);
2812
+ if (member) {
2813
+ // chain_id column: backward-compat write. Single-attribution.
2814
+ // When Pass 5 emits overlapping chains, the LAST chain processed
2815
+ // wins this column (UI Kahn toposort, derive.ts, straggler sweep
2816
+ // still consume it). Pre-debt-4 this was the ONLY membership
2817
+ // record and overlapping arcs lost members silently (S1.3).
2818
+ db.updateBlock(member.id, { chain_id: chainBlock.id });
2819
+ // member_of relation: many-to-many. Preserves ALL memberships
2820
+ // across overlapping chains. Idempotent — createRelation
2821
+ // returns the existing row on duplicate insert. Per debt-4 §2.3.
2822
+ // The chain block's `members[]` field carries the CANONICAL
2823
+ // ORDERED narrative; this relation is the unordered fact.
2824
+ db.createRelation({
2825
+ source_id: member.id,
2826
+ target_id: chainBlock.id,
2827
+ type: "member_of",
2828
+ created_by: geminiCreatedBy,
2829
+ });
2830
+ assignedChainIds.set(member.id, chainBlock.id);
2831
+ }
2832
+ }
2833
+ const projectLabel = chain.chain_label.split("_")[0];
2834
+ const projectBlock = freshBlocks5.find((b) => b.label === projectLabel && b.type === "project");
2835
+ if (projectBlock && !chainBlock.project_id) {
2836
+ db.updateBlock(chainBlock.id, { project_id: projectBlock.id });
2837
+ }
2838
+ console.log(`Auto-Reflect Pass 5: chain "${chain.chain_label}" assembled (${chain.members.length} members)`);
2839
+ }
2840
+ }
2841
+ // Straggler sweep — Pass 5 LLM may omit connected blocks from members[].
2842
+ // Any freshly-saved block still holding a UUID chain_id (not "blk_" prefixed)
2843
+ // that has a causal relation to a confirmed chain member gets patched in.
2844
+ const freshRels5Final = db.getAllRelations(false).filter((r) => r.status === "active");
2845
+ const CAUSAL_TYPES = new Set(["prompted_by", "based_on", "extends", "supersedes"]);
2846
+ for (const b of freshBlocks5) {
2847
+ if (!savedLabelSet5.has(b.label))
2848
+ continue;
2849
+ if (!b.chain_id || String(b.chain_id).startsWith("blk_"))
2850
+ continue; // already patched or no chain
2851
+ // This block has a UUID chain_id — check if any causal relation connects it to a patched member
2852
+ const connectedChainId = (() => {
2853
+ for (const rel of freshRels5Final) {
2854
+ if (!CAUSAL_TYPES.has(rel.type))
2855
+ continue;
2856
+ if (rel.source_id !== b.id && rel.target_id !== b.id)
2857
+ continue;
2858
+ const otherId = rel.source_id === b.id ? rel.target_id : rel.source_id;
2859
+ if (assignedChainIds.has(otherId))
2860
+ return assignedChainIds.get(otherId);
2861
+ }
2862
+ return null;
2863
+ })();
2864
+ if (connectedChainId) {
2865
+ // Dual-write: chain_id column (backward-compat) + member_of
2866
+ // relation (many-to-many). Per debt-4 §2.3. Same idempotency as
2867
+ // the main Pass 5 loop — createRelation dedups by (source,target,type).
2868
+ db.updateBlock(b.id, { chain_id: connectedChainId });
2869
+ db.createRelation({
2870
+ source_id: b.id,
2871
+ target_id: connectedChainId,
2872
+ type: "member_of",
2873
+ created_by: geminiCreatedBy,
2874
+ });
2875
+ console.log(`Auto-Reflect Pass 5: straggler "${b.label}" swept into chain ${connectedChainId}`);
2876
+ }
2877
+ }
2878
+ }
2879
+ // UUID cleanup — any new block still holding a UUID chain_id after the straggler
2880
+ // sweep was never canonicalized by Pass 5 (no committed conclusion in its cluster).
2881
+ // Clear it: the block is standalone. A UUID chain_id pointing to nothing is worse
2882
+ // than no chain_id.
2883
+ // NOTE: freshBlocks5 was loaded before Pass 5 ran, so b.chain_id may be stale.
2884
+ // Re-read from DB to get the current value before deciding to clear.
2885
+ for (const b of freshBlocks5) {
2886
+ if (!savedLabelSet5.has(b.label))
2887
+ continue;
2888
+ const current = db.getBlock(b.id);
2889
+ if (!current?.chain_id || String(current.chain_id).startsWith("blk_"))
2890
+ continue;
2891
+ db.updateBlock(b.id, { chain_id: null });
2892
+ }
2893
+ }
2894
+ // flow_role: NO LONGER SET BY THE PIPELINE (removed 2026-05-18, commits c2412a6 + a92c44f).
2895
+ // Pass 2/3/5 no longer write it; deriveFlowRole() + applyFlowRoleOverrides() were deleted.
2896
+ // Pipeline-generated blocks have flow_role = null. Still written by:
2897
+ // - tools/derive.ts (workspace_derive): outcome on derived, cause on inputs
2898
+ // - tools/core.ts (workspace_remember): agent-supplied value
2899
+ // UI computes chain display order from prompted_by edges via Kahn's toposort.
2900
+ // Persist session state
2901
+ {
2902
+ let stateBlock = db.getBlock(stateLabel);
2903
+ if (!stateBlock) {
2904
+ stateBlock = db.createBlock({
2905
+ label: stateLabel, type: "process", status: "active",
2906
+ essence: `Session state${agentId ? ` for agent ${agentId.slice(0, 8)}` : ""}`,
2907
+ content: {}, ttl: "permanent", source: "Auto-Reflect",
2908
+ created_by: agentId || undefined,
2909
+ });
2910
+ if (stateBlock)
2911
+ stampQualityScore(db, stateBlock, []); // Bug 1 fix
2912
+ }
2913
+ if (stateBlock) {
2914
+ let sc = {};
2915
+ try {
2916
+ sc = JSON.parse(stateBlock.content);
2917
+ }
2918
+ catch { /* */ }
2919
+ if (_pendingRecentSaves.length > 0) {
2920
+ const existing = sc.gemini_recent_saves || [];
2921
+ sc.gemini_recent_saves = [..._pendingRecentSaves, ...existing].slice(0, 8);
2922
+ }
2923
+ sc.gemini_last_review = {
2924
+ thinking: geminiThinking || "",
2925
+ output: analysis,
2926
+ saved: result.saved,
2927
+ skipped: result.skipped,
2928
+ skip_reasons: analysis?.skip_reasons || [],
2929
+ ts: new Date().toISOString(),
2930
+ };
2931
+ const resolvedRefs = pass2.classified
2932
+ .filter((item) => item.resolved_ref)
2933
+ .map((item) => ({ reference: item.text.slice(0, 60), resolved_to: item.resolved_ref }));
2934
+ sc.prev_turn_context = {
2935
+ active_blocks: knownRoots.map((r) => r.label),
2936
+ entity_map: resolvedRefs,
2937
+ in_flight: "",
2938
+ };
2939
+ sc.total_reflect_sessions = (sc.total_reflect_sessions || 0) + 1;
2940
+ db.updateBlock(stateLabel, { content: JSON.stringify(sc) });
2941
+ }
2942
+ }
2943
+ // ── Per-turn debug log ──
2944
+ writeTurnLog({
2945
+ turn: _turnCounter,
2946
+ ts: new Date().toISOString(),
2947
+ // Per-pass provider trail: which model actually produced each pass + the attempt
2948
+ // sequence (primary→fallback escalation on truncation/429). This is what makes the
2949
+ // run-to-run non-determinism visible — without it, a slow divergent run is unattributable.
2950
+ providers: {
2951
+ pass0: _pass0Provider,
2952
+ pass1: _pass1Provider,
2953
+ pass_judge: _passJudgeProvider,
2954
+ pass2: _pass2Provider,
2955
+ pass3: _pass3Provider,
2956
+ pass4: _pass4Provider,
2957
+ pass5: _pass5Provider,
2958
+ },
2959
+ pass0: {
2960
+ scene_card_text: _sceneCard ?? null,
2961
+ raw: _pass0Raw,
2962
+ },
2963
+ pass1: pass1 ? {
2964
+ thinking: _pass1Thinking || undefined,
2965
+ item_count: pass1.items.length,
2966
+ items: pass1.items.map(i => ({
2967
+ id: i.id,
2968
+ provisional_type: i.provisional_type,
2969
+ source: i.source,
2970
+ text: i.text,
2971
+ excerpt: i.excerpt?.slice(0, 120),
2972
+ extends_id: i.extends_id,
2973
+ extraction_reasoning: i.extraction_reasoning,
2974
+ })),
2975
+ } : null,
2976
+ // JUDGE pass audit trail — present only when NODEDEX_WORTH_JUDGE_ENABLED=1 fired this turn.
2977
+ // dropped[] lets us content-verify each drop offline (item_id + reason_category + optional notes).
2978
+ // anchor_overrides[] records ids the judge wanted to drop but were saved because a kept item
2979
+ // extends them (the asymmetric-cost ref-cleanup rule).
2980
+ pass_judge: _passJudgeProvider ? {
2981
+ kept_count: _passJudgeKeptCount,
2982
+ dropped_count: _passJudgeDropped.length,
2983
+ dropped: _passJudgeDropped,
2984
+ anchor_overrides: _passJudgeAnchorOverrides,
2985
+ } : undefined,
2986
+ pass2: pass2 ? {
2987
+ thinking: _pass2Thinking || undefined,
2988
+ context: _pass2Context || undefined,
2989
+ item_count: pass2.classified.length,
2990
+ skipped: pass2.skipped?.length ? pass2.skipped : undefined,
2991
+ items: pass2.classified.map(i => ({
2992
+ id: i.id,
2993
+ type: i.type,
2994
+ text: i.text,
2995
+ unique: i.unique,
2996
+ triggered_by_items: i.triggered_by_items,
2997
+ based_on_items: i.based_on_items,
2998
+ extends_item: i.extends_item,
2999
+ supersedes_ref: i.supersedes_ref,
3000
+ review_reason: i.review_reason,
3001
+ classification_reasoning: i.classification_reasoning,
3002
+ // v2 COMPREHEND reasoning (2026-06-12): the conversion carries these on the
3003
+ // items, but this WHITELIST is what reaches the turn-log — omitting them
3004
+ // here silently re-lost the reasoning a second time (caught live: the
3005
+ // first fix updated the conversion, verified by unit test, and the log
3006
+ // still showed nothing — the writer was the second strip point).
3007
+ keep_reason: i.keep_reason,
3008
+ type_reasoning: i.type_reasoning,
3009
+ // Per-relation semantic wiring + its reasoning (Pass 2c, commit 450d631).
3010
+ // Persisted here because the graph relations table has no `reason` column —
3011
+ // the turn log is the only readable home for this debug instrumentation.
3012
+ relations: i.relations,
3013
+ note: i.note,
3014
+ })),
3015
+ } : null,
3016
+ pass3: analysis ? {
3017
+ thinking: geminiThinking || undefined,
3018
+ project_creates: analysis.project_creates || [],
3019
+ new_blocks: (analysis.new_blocks || []).map((b) => ({
3020
+ label: typeof b.label === "object"
3021
+ ? [b.label.project, b.label.subgroup, b.label.type, b.label.concept].filter(Boolean).join("_")
3022
+ : b.label,
3023
+ is_a: b.is_a,
3024
+ essence: b.essence,
3025
+ unique: b.unique,
3026
+ triggered_by: b.triggered_by,
3027
+ novelty_reason: b.novelty_reason,
3028
+ })),
3029
+ skip_reasons: analysis.skip_reasons || [],
3030
+ updates: (analysis.updates || []).map((u) => ({ block_id: u.block_id, reason: u.reason })),
3031
+ } : null,
3032
+ pass4: (_pass4Thinking || _pass4Result) ? {
3033
+ thinking: _pass4Thinking || undefined,
3034
+ relations: _pass4Result?.relations ?? [],
3035
+ } : undefined,
3036
+ // Pass 5 chain assembly. Each chain carries its per-chain `reasoning`
3037
+ // (commit 450d631: WHY-these-members + WHY-this-conclusion). Chain BLOCKS
3038
+ // store only arc/conclusion/essence, so the turn log is the only readable
3039
+ // home for the reasoning — the debug payoff the deep test relies on.
3040
+ pass5: _pass5Result?.chains?.length ? {
3041
+ chains: _pass5Result.chains,
3042
+ } : undefined,
3043
+ pipeline_skips: pipelineSkips.length > 0 ? pipelineSkips : undefined,
3044
+ // Pass 2 split-orchestrator audit (per PASS2-SPLIT-DESIGN.md §7).
3045
+ // Surfaces: 2a/2b/2c throughput, seam α verdicts, re-fill salvage
3046
+ // outcomes, quarantine writes. Present only when the split path ran
3047
+ // this turn (NODEDEX_PASS2_SPLIT=1). Undefined on monolith path — the
3048
+ // turn-log reader can use presence/absence to detect which path ran.
3049
+ pass2_split_audit: _pass2SplitAudit,
3050
+ // Per-pass $$ cost telemetry. Extracted to cost-breakdown.ts for unit
3051
+ // testability — see cost-breakdown.test.ts for the three meaningful
3052
+ // states (ran:false / ran:true+priced / ran:true+null) verified.
3053
+ // Per PASS2-SPLIT-DESIGN.md §7 + debt-4 §3 + S1.1 (checkpoint NULL fix).
3054
+ cost_breakdown: buildCostBreakdown({
3055
+ pass0: _pass0Provider,
3056
+ pass1: _pass1Provider,
3057
+ pass_judge: _passJudgeProvider,
3058
+ pass2: _pass2Provider,
3059
+ pass3: _pass3Provider,
3060
+ pass4: _pass4Provider,
3061
+ pass5: _pass5Provider,
3062
+ // Stage C ran upstream in arc-pipeline.ts; its provider trail is
3063
+ // pinned on the checkpoint (slice-1.2 design contract). When Stage C
3064
+ // gracefully degraded (LLM returned null), arcEntityResolution.model
3065
+ // stays undefined → ran:false here, matching the pass5 pattern.
3066
+ // Followup #2 from project-slice1-verified-2026-05-31 (cost slot).
3067
+ pass_c_resolve: checkpoint?.arcEntityResolution?.model
3068
+ ? { model: checkpoint.arcEntityResolution.model, attempts: checkpoint.arcEntityResolution.attempts }
3069
+ : undefined,
3070
+ }, reflectTokenStats),
3071
+ // debt-4 Stage A — per-pass WALL TIME. Covers what cost_breakdown's $$
3072
+ // doesn't: where TIME goes. Thinking-budget-heavy passes (Pass 3 at
3073
+ // 4096, Pass 2c at dynamic 1024-8192) dominate time while output tokens
3074
+ // dominate cost — the two metrics correlate but aren't identical.
3075
+ // Populated only for passes that actually ran this turn (omitted on
3076
+ // checkpoint-resume non-contributions, consistent with cost_breakdown's
3077
+ // ran:false semantics).
3078
+ // v2 front-half stage timings (COMPREHEND / judge / 2b fill / justify /
3079
+ // crosslink / integrate) arrive via the checkpoint — the front-half runs
3080
+ // before this function. Merged here so ONE field answers "where did the
3081
+ // wall time go" for both halves (2026-06-12: the front-half was the
3082
+ // unmeasured majority of arc wall time).
3083
+ pass_wall_ms: checkpoint?.v2WallMs ? { ...checkpoint.v2WallMs, ..._passWallMs } : _passWallMs,
3084
+ // v2 front-half per-stage COST (USD), the cost twin of the wall timings —
3085
+ // closes the "front-half spend invisible in cost_breakdown" gap. Attributed
3086
+ // from the usage ledger over each stage's window (v2-integrate.ts). Only
3087
+ // present on v2 runs; undefined on v1/checkpoint-less turns.
3088
+ v2_front_cost_usd: checkpoint?.v2FrontCostUsd,
3089
+ // debt-4 Stage A — embedding telemetry. ~100+ sequential embedding API
3090
+ // calls per moderate turn was a HIDDEN time tax (~15-20% of per-turn
3091
+ // wall time) invisible to cost_breakdown. Now surfaced. Per-turn DELTAS
3092
+ // (calls/ms/chars accumulated globally; we subtract the start snapshot).
3093
+ // Stage B (embedding batching) will use this as the baseline to prove
3094
+ // its saving against.
3095
+ embedding_stats: {
3096
+ calls: embeddingStats.calls - _embStart.calls,
3097
+ ms_total: embeddingStats.ms_total - _embStart.ms_total,
3098
+ input_chars: embeddingStats.input_chars - _embStart.input_chars,
3099
+ },
3100
+ result: {
3101
+ saved: result.saved,
3102
+ saved_labels: result.saved_labels,
3103
+ skipped: result.skipped,
3104
+ updated: result.updated,
3105
+ },
3106
+ });
3107
+ return result;
3108
+ }
3109
+ catch (e) {
3110
+ console.error("Auto-Reflect: pipeline error", e);
3111
+ return empty;
3112
+ }
3113
+ }
3114
+ //# sourceMappingURL=pipeline.js.map