@oriro/orirocli 0.1.8 → 0.1.9

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 (350) hide show
  1. package/ATTRIBUTION.md +8 -0
  2. package/LICENSE +21 -0
  3. package/package.json +1 -1
  4. package/skills/21stdev/SKILL.md +64 -0
  5. package/skills/graphify/SKILL.md +619 -0
  6. package/skills/graphify/__init__.py +28 -0
  7. package/skills/graphify/__main__.py +4582 -0
  8. package/skills/graphify/affected.py +154 -0
  9. package/skills/graphify/always_on/agents-md.md +12 -0
  10. package/skills/graphify/always_on/antigravity-rules.md +14 -0
  11. package/skills/graphify/always_on/claude-md.md +9 -0
  12. package/skills/graphify/always_on/gemini-md.md +9 -0
  13. package/skills/graphify/always_on/kiro-steering.md +5 -0
  14. package/skills/graphify/always_on/vscode-instructions.md +17 -0
  15. package/skills/graphify/analyze.py +724 -0
  16. package/skills/graphify/benchmark.py +155 -0
  17. package/skills/graphify/build.py +487 -0
  18. package/skills/graphify/cache.py +417 -0
  19. package/skills/graphify/callflow_html.py +2020 -0
  20. package/skills/graphify/cluster.py +272 -0
  21. package/skills/graphify/command-kilo.md +15 -0
  22. package/skills/graphify/dedup.py +429 -0
  23. package/skills/graphify/detect.py +1379 -0
  24. package/skills/graphify/diagnostics.py +390 -0
  25. package/skills/graphify/export.py +1408 -0
  26. package/skills/graphify/extract.py +11570 -0
  27. package/skills/graphify/global_graph.py +159 -0
  28. package/skills/graphify/google_workspace.py +223 -0
  29. package/skills/graphify/hooks.py +457 -0
  30. package/skills/graphify/ingest.py +331 -0
  31. package/skills/graphify/llm.py +1896 -0
  32. package/skills/graphify/manifest.py +4 -0
  33. package/skills/graphify/mcp_ingest.py +392 -0
  34. package/skills/graphify/multigraph_compat.py +212 -0
  35. package/skills/graphify/pg_introspect.py +142 -0
  36. package/skills/graphify/prs.py +748 -0
  37. package/skills/graphify/querylog.py +70 -0
  38. package/skills/graphify/report.py +218 -0
  39. package/skills/graphify/scip_ingest.py +363 -0
  40. package/skills/graphify/security.py +336 -0
  41. package/skills/graphify/semantic_cleanup.py +319 -0
  42. package/skills/graphify/serve.py +1309 -0
  43. package/skills/graphify/skill-aider.md +1246 -0
  44. package/skills/graphify/skill-amp.md +613 -0
  45. package/skills/graphify/skill-claw.md +616 -0
  46. package/skills/graphify/skill-codex.md +613 -0
  47. package/skills/graphify/skill-copilot.md +616 -0
  48. package/skills/graphify/skill-devin.md +1372 -0
  49. package/skills/graphify/skill-droid.md +613 -0
  50. package/skills/graphify/skill-kilo.md +625 -0
  51. package/skills/graphify/skill-kiro.md +615 -0
  52. package/skills/graphify/skill-opencode.md +608 -0
  53. package/skills/graphify/skill-pi.md +615 -0
  54. package/skills/graphify/skill-trae.md +614 -0
  55. package/skills/graphify/skill-vscode.md +612 -0
  56. package/skills/graphify/skill-windows.md +651 -0
  57. package/skills/graphify/skills/amp/references/add-watch.md +56 -0
  58. package/skills/graphify/skills/amp/references/exports.md +71 -0
  59. package/skills/graphify/skills/amp/references/extraction-spec.md +68 -0
  60. package/skills/graphify/skills/amp/references/github-and-merge.md +46 -0
  61. package/skills/graphify/skills/amp/references/hooks.md +33 -0
  62. package/skills/graphify/skills/amp/references/query.md +249 -0
  63. package/skills/graphify/skills/amp/references/transcribe.md +48 -0
  64. package/skills/graphify/skills/amp/references/update.md +179 -0
  65. package/skills/graphify/skills/claude/references/add-watch.md +56 -0
  66. package/skills/graphify/skills/claude/references/exports.md +71 -0
  67. package/skills/graphify/skills/claude/references/extraction-spec.md +68 -0
  68. package/skills/graphify/skills/claude/references/github-and-merge.md +46 -0
  69. package/skills/graphify/skills/claude/references/hooks.md +33 -0
  70. package/skills/graphify/skills/claude/references/query.md +103 -0
  71. package/skills/graphify/skills/claude/references/transcribe.md +48 -0
  72. package/skills/graphify/skills/claude/references/update.md +179 -0
  73. package/skills/graphify/skills/claw/references/add-watch.md +56 -0
  74. package/skills/graphify/skills/claw/references/exports.md +71 -0
  75. package/skills/graphify/skills/claw/references/extraction-spec.md +29 -0
  76. package/skills/graphify/skills/claw/references/github-and-merge.md +46 -0
  77. package/skills/graphify/skills/claw/references/hooks.md +33 -0
  78. package/skills/graphify/skills/claw/references/query.md +249 -0
  79. package/skills/graphify/skills/claw/references/transcribe.md +48 -0
  80. package/skills/graphify/skills/claw/references/update.md +179 -0
  81. package/skills/graphify/skills/codex/references/add-watch.md +56 -0
  82. package/skills/graphify/skills/codex/references/exports.md +71 -0
  83. package/skills/graphify/skills/codex/references/extraction-spec.md +29 -0
  84. package/skills/graphify/skills/codex/references/github-and-merge.md +46 -0
  85. package/skills/graphify/skills/codex/references/hooks.md +33 -0
  86. package/skills/graphify/skills/codex/references/query.md +249 -0
  87. package/skills/graphify/skills/codex/references/transcribe.md +48 -0
  88. package/skills/graphify/skills/codex/references/update.md +179 -0
  89. package/skills/graphify/skills/copilot/references/add-watch.md +56 -0
  90. package/skills/graphify/skills/copilot/references/exports.md +71 -0
  91. package/skills/graphify/skills/copilot/references/extraction-spec.md +68 -0
  92. package/skills/graphify/skills/copilot/references/github-and-merge.md +46 -0
  93. package/skills/graphify/skills/copilot/references/hooks.md +33 -0
  94. package/skills/graphify/skills/copilot/references/query.md +249 -0
  95. package/skills/graphify/skills/copilot/references/transcribe.md +48 -0
  96. package/skills/graphify/skills/copilot/references/update.md +179 -0
  97. package/skills/graphify/skills/droid/references/add-watch.md +56 -0
  98. package/skills/graphify/skills/droid/references/exports.md +71 -0
  99. package/skills/graphify/skills/droid/references/extraction-spec.md +68 -0
  100. package/skills/graphify/skills/droid/references/github-and-merge.md +46 -0
  101. package/skills/graphify/skills/droid/references/hooks.md +33 -0
  102. package/skills/graphify/skills/droid/references/query.md +249 -0
  103. package/skills/graphify/skills/droid/references/transcribe.md +48 -0
  104. package/skills/graphify/skills/droid/references/update.md +179 -0
  105. package/skills/graphify/skills/kilo/references/add-watch.md +56 -0
  106. package/skills/graphify/skills/kilo/references/exports.md +71 -0
  107. package/skills/graphify/skills/kilo/references/extraction-spec.md +68 -0
  108. package/skills/graphify/skills/kilo/references/github-and-merge.md +46 -0
  109. package/skills/graphify/skills/kilo/references/hooks.md +33 -0
  110. package/skills/graphify/skills/kilo/references/query.md +249 -0
  111. package/skills/graphify/skills/kilo/references/transcribe.md +48 -0
  112. package/skills/graphify/skills/kilo/references/update.md +179 -0
  113. package/skills/graphify/skills/kiro/references/add-watch.md +56 -0
  114. package/skills/graphify/skills/kiro/references/exports.md +71 -0
  115. package/skills/graphify/skills/kiro/references/extraction-spec.md +29 -0
  116. package/skills/graphify/skills/kiro/references/github-and-merge.md +46 -0
  117. package/skills/graphify/skills/kiro/references/hooks.md +33 -0
  118. package/skills/graphify/skills/kiro/references/query.md +249 -0
  119. package/skills/graphify/skills/kiro/references/transcribe.md +48 -0
  120. package/skills/graphify/skills/kiro/references/update.md +179 -0
  121. package/skills/graphify/skills/opencode/references/add-watch.md +56 -0
  122. package/skills/graphify/skills/opencode/references/exports.md +71 -0
  123. package/skills/graphify/skills/opencode/references/extraction-spec.md +68 -0
  124. package/skills/graphify/skills/opencode/references/github-and-merge.md +46 -0
  125. package/skills/graphify/skills/opencode/references/hooks.md +33 -0
  126. package/skills/graphify/skills/opencode/references/query.md +249 -0
  127. package/skills/graphify/skills/opencode/references/transcribe.md +48 -0
  128. package/skills/graphify/skills/opencode/references/update.md +179 -0
  129. package/skills/graphify/skills/pi/references/add-watch.md +56 -0
  130. package/skills/graphify/skills/pi/references/exports.md +71 -0
  131. package/skills/graphify/skills/pi/references/extraction-spec.md +29 -0
  132. package/skills/graphify/skills/pi/references/github-and-merge.md +46 -0
  133. package/skills/graphify/skills/pi/references/hooks.md +33 -0
  134. package/skills/graphify/skills/pi/references/query.md +249 -0
  135. package/skills/graphify/skills/pi/references/transcribe.md +48 -0
  136. package/skills/graphify/skills/pi/references/update.md +179 -0
  137. package/skills/graphify/skills/trae/references/add-watch.md +56 -0
  138. package/skills/graphify/skills/trae/references/exports.md +71 -0
  139. package/skills/graphify/skills/trae/references/extraction-spec.md +68 -0
  140. package/skills/graphify/skills/trae/references/github-and-merge.md +46 -0
  141. package/skills/graphify/skills/trae/references/hooks.md +35 -0
  142. package/skills/graphify/skills/trae/references/query.md +249 -0
  143. package/skills/graphify/skills/trae/references/transcribe.md +48 -0
  144. package/skills/graphify/skills/trae/references/update.md +179 -0
  145. package/skills/graphify/skills/vscode/references/add-watch.md +56 -0
  146. package/skills/graphify/skills/vscode/references/exports.md +71 -0
  147. package/skills/graphify/skills/vscode/references/extraction-spec.md +68 -0
  148. package/skills/graphify/skills/vscode/references/github-and-merge.md +46 -0
  149. package/skills/graphify/skills/vscode/references/hooks.md +33 -0
  150. package/skills/graphify/skills/vscode/references/query.md +249 -0
  151. package/skills/graphify/skills/vscode/references/transcribe.md +48 -0
  152. package/skills/graphify/skills/vscode/references/update.md +179 -0
  153. package/skills/graphify/skills/windows/references/add-watch.md +56 -0
  154. package/skills/graphify/skills/windows/references/exports.md +71 -0
  155. package/skills/graphify/skills/windows/references/extraction-spec.md +68 -0
  156. package/skills/graphify/skills/windows/references/github-and-merge.md +46 -0
  157. package/skills/graphify/skills/windows/references/hooks.md +33 -0
  158. package/skills/graphify/skills/windows/references/query.md +249 -0
  159. package/skills/graphify/skills/windows/references/transcribe.md +48 -0
  160. package/skills/graphify/skills/windows/references/update.md +179 -0
  161. package/skills/graphify/symbol_resolution.py +538 -0
  162. package/skills/graphify/transcribe.py +184 -0
  163. package/skills/graphify/tree_html.py +582 -0
  164. package/skills/graphify/validate.py +72 -0
  165. package/skills/graphify/watch.py +898 -0
  166. package/skills/graphify/wiki.py +282 -0
  167. package/skills/impeccable/SKILL.md +186 -0
  168. package/skills/impeccable/agents/impeccable_asset_producer.toml +92 -0
  169. package/skills/impeccable/agents/impeccable_manual_edit_applier.toml +95 -0
  170. package/skills/impeccable/agents/openai.yaml +4 -0
  171. package/skills/impeccable/reference/adapt.md +311 -0
  172. package/skills/impeccable/reference/animate.md +201 -0
  173. package/skills/impeccable/reference/audit.md +133 -0
  174. package/skills/impeccable/reference/bolder.md +113 -0
  175. package/skills/impeccable/reference/brand.md +108 -0
  176. package/skills/impeccable/reference/clarify.md +288 -0
  177. package/skills/impeccable/reference/codex.md +105 -0
  178. package/skills/impeccable/reference/colorize.md +257 -0
  179. package/skills/impeccable/reference/craft.md +123 -0
  180. package/skills/impeccable/reference/critique.md +790 -0
  181. package/skills/impeccable/reference/delight.md +302 -0
  182. package/skills/impeccable/reference/distill.md +111 -0
  183. package/skills/impeccable/reference/document.md +429 -0
  184. package/skills/impeccable/reference/extract.md +69 -0
  185. package/skills/impeccable/reference/harden.md +347 -0
  186. package/skills/impeccable/reference/init.md +172 -0
  187. package/skills/impeccable/reference/interaction-design.md +189 -0
  188. package/skills/impeccable/reference/layout.md +161 -0
  189. package/skills/impeccable/reference/live.md +720 -0
  190. package/skills/impeccable/reference/onboard.md +234 -0
  191. package/skills/impeccable/reference/optimize.md +258 -0
  192. package/skills/impeccable/reference/overdrive.md +130 -0
  193. package/skills/impeccable/reference/polish.md +241 -0
  194. package/skills/impeccable/reference/product.md +60 -0
  195. package/skills/impeccable/reference/quieter.md +99 -0
  196. package/skills/impeccable/reference/shape.md +165 -0
  197. package/skills/impeccable/reference/typeset.md +279 -0
  198. package/skills/impeccable/scripts/cleanup-deprecated.mjs +284 -0
  199. package/skills/impeccable/scripts/command-metadata.json +94 -0
  200. package/skills/impeccable/scripts/context-signals.mjs +225 -0
  201. package/skills/impeccable/scripts/context.mjs +266 -0
  202. package/skills/impeccable/scripts/critique-storage.mjs +242 -0
  203. package/skills/impeccable/scripts/design-parser.mjs +835 -0
  204. package/skills/impeccable/scripts/detect-csp.mjs +198 -0
  205. package/skills/impeccable/scripts/detect.mjs +21 -0
  206. package/skills/impeccable/scripts/detector/browser/injected/index.mjs +1733 -0
  207. package/skills/impeccable/scripts/detector/cli/main.mjs +244 -0
  208. package/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +4618 -0
  209. package/skills/impeccable/scripts/detector/detect-antipatterns.mjs +43 -0
  210. package/skills/impeccable/scripts/detector/engines/browser/detect-url.mjs +252 -0
  211. package/skills/impeccable/scripts/detector/engines/regex/detect-text.mjs +535 -0
  212. package/skills/impeccable/scripts/detector/engines/static-html/css-cascade.mjs +986 -0
  213. package/skills/impeccable/scripts/detector/engines/static-html/detect-html.mjs +208 -0
  214. package/skills/impeccable/scripts/detector/engines/visual/screenshot-contrast.mjs +189 -0
  215. package/skills/impeccable/scripts/detector/findings.mjs +12 -0
  216. package/skills/impeccable/scripts/detector/node/file-system.mjs +198 -0
  217. package/skills/impeccable/scripts/detector/profile/profiler.mjs +166 -0
  218. package/skills/impeccable/scripts/detector/registry/antipatterns.mjs +419 -0
  219. package/skills/impeccable/scripts/detector/rules/checks.mjs +2384 -0
  220. package/skills/impeccable/scripts/detector/shared/color.mjs +124 -0
  221. package/skills/impeccable/scripts/detector/shared/constants.mjs +101 -0
  222. package/skills/impeccable/scripts/detector/shared/page.mjs +7 -0
  223. package/skills/impeccable/scripts/impeccable-paths.mjs +126 -0
  224. package/skills/impeccable/scripts/is-generated.mjs +69 -0
  225. package/skills/impeccable/scripts/live-accept.mjs +812 -0
  226. package/skills/impeccable/scripts/live-browser-session.js +123 -0
  227. package/skills/impeccable/scripts/live-browser.js +10295 -0
  228. package/skills/impeccable/scripts/live-commit-manual-edits.mjs +1241 -0
  229. package/skills/impeccable/scripts/live-complete.mjs +75 -0
  230. package/skills/impeccable/scripts/live-completion.mjs +19 -0
  231. package/skills/impeccable/scripts/live-copy-edit-agent.mjs +683 -0
  232. package/skills/impeccable/scripts/live-discard-manual-edits.mjs +51 -0
  233. package/skills/impeccable/scripts/live-event-validation.mjs +137 -0
  234. package/skills/impeccable/scripts/live-inject.mjs +557 -0
  235. package/skills/impeccable/scripts/live-insert-ui.mjs +458 -0
  236. package/skills/impeccable/scripts/live-insert.mjs +272 -0
  237. package/skills/impeccable/scripts/live-manual-edit-evidence.mjs +363 -0
  238. package/skills/impeccable/scripts/live-manual-edits-buffer.mjs +152 -0
  239. package/skills/impeccable/scripts/live-poll.mjs +379 -0
  240. package/skills/impeccable/scripts/live-resume.mjs +94 -0
  241. package/skills/impeccable/scripts/live-server.mjs +2326 -0
  242. package/skills/impeccable/scripts/live-session-store.mjs +289 -0
  243. package/skills/impeccable/scripts/live-status.mjs +61 -0
  244. package/skills/impeccable/scripts/live-svelte-component.mjs +826 -0
  245. package/skills/impeccable/scripts/live-sveltekit-adapter.mjs +274 -0
  246. package/skills/impeccable/scripts/live-ui-core.mjs +179 -0
  247. package/skills/impeccable/scripts/live-vocabulary.mjs +36 -0
  248. package/skills/impeccable/scripts/live-wrap.mjs +894 -0
  249. package/skills/impeccable/scripts/live.mjs +246 -0
  250. package/skills/impeccable/scripts/modern-screenshot.umd.js +14 -0
  251. package/skills/impeccable/scripts/palette.mjs +633 -0
  252. package/skills/impeccable/scripts/pin.mjs +214 -0
  253. package/skills/uipm-ui-styling/LICENSE.txt +202 -0
  254. package/skills/uipm-ui-styling/SKILL.md +328 -0
  255. package/skills/uipm-ui-styling/canvas-fonts/ArsenalSC-OFL.txt +93 -0
  256. package/skills/uipm-ui-styling/canvas-fonts/ArsenalSC-Regular.ttf +0 -0
  257. package/skills/uipm-ui-styling/canvas-fonts/BigShoulders-Bold.ttf +0 -0
  258. package/skills/uipm-ui-styling/canvas-fonts/BigShoulders-OFL.txt +93 -0
  259. package/skills/uipm-ui-styling/canvas-fonts/BigShoulders-Regular.ttf +0 -0
  260. package/skills/uipm-ui-styling/canvas-fonts/Boldonse-OFL.txt +93 -0
  261. package/skills/uipm-ui-styling/canvas-fonts/Boldonse-Regular.ttf +0 -0
  262. package/skills/uipm-ui-styling/canvas-fonts/BricolageGrotesque-Bold.ttf +0 -0
  263. package/skills/uipm-ui-styling/canvas-fonts/BricolageGrotesque-OFL.txt +93 -0
  264. package/skills/uipm-ui-styling/canvas-fonts/BricolageGrotesque-Regular.ttf +0 -0
  265. package/skills/uipm-ui-styling/canvas-fonts/CrimsonPro-Bold.ttf +0 -0
  266. package/skills/uipm-ui-styling/canvas-fonts/CrimsonPro-Italic.ttf +0 -0
  267. package/skills/uipm-ui-styling/canvas-fonts/CrimsonPro-OFL.txt +93 -0
  268. package/skills/uipm-ui-styling/canvas-fonts/CrimsonPro-Regular.ttf +0 -0
  269. package/skills/uipm-ui-styling/canvas-fonts/DMMono-OFL.txt +93 -0
  270. package/skills/uipm-ui-styling/canvas-fonts/DMMono-Regular.ttf +0 -0
  271. package/skills/uipm-ui-styling/canvas-fonts/EricaOne-OFL.txt +94 -0
  272. package/skills/uipm-ui-styling/canvas-fonts/EricaOne-Regular.ttf +0 -0
  273. package/skills/uipm-ui-styling/canvas-fonts/GeistMono-Bold.ttf +0 -0
  274. package/skills/uipm-ui-styling/canvas-fonts/GeistMono-OFL.txt +93 -0
  275. package/skills/uipm-ui-styling/canvas-fonts/GeistMono-Regular.ttf +0 -0
  276. package/skills/uipm-ui-styling/canvas-fonts/Gloock-OFL.txt +93 -0
  277. package/skills/uipm-ui-styling/canvas-fonts/Gloock-Regular.ttf +0 -0
  278. package/skills/uipm-ui-styling/canvas-fonts/IBMPlexMono-Bold.ttf +0 -0
  279. package/skills/uipm-ui-styling/canvas-fonts/IBMPlexMono-OFL.txt +93 -0
  280. package/skills/uipm-ui-styling/canvas-fonts/IBMPlexMono-Regular.ttf +0 -0
  281. package/skills/uipm-ui-styling/canvas-fonts/IBMPlexSerif-Bold.ttf +0 -0
  282. package/skills/uipm-ui-styling/canvas-fonts/IBMPlexSerif-BoldItalic.ttf +0 -0
  283. package/skills/uipm-ui-styling/canvas-fonts/IBMPlexSerif-Italic.ttf +0 -0
  284. package/skills/uipm-ui-styling/canvas-fonts/IBMPlexSerif-Regular.ttf +0 -0
  285. package/skills/uipm-ui-styling/canvas-fonts/InstrumentSans-Bold.ttf +0 -0
  286. package/skills/uipm-ui-styling/canvas-fonts/InstrumentSans-BoldItalic.ttf +0 -0
  287. package/skills/uipm-ui-styling/canvas-fonts/InstrumentSans-Italic.ttf +0 -0
  288. package/skills/uipm-ui-styling/canvas-fonts/InstrumentSans-OFL.txt +93 -0
  289. package/skills/uipm-ui-styling/canvas-fonts/InstrumentSans-Regular.ttf +0 -0
  290. package/skills/uipm-ui-styling/canvas-fonts/InstrumentSerif-Italic.ttf +0 -0
  291. package/skills/uipm-ui-styling/canvas-fonts/InstrumentSerif-Regular.ttf +0 -0
  292. package/skills/uipm-ui-styling/canvas-fonts/Italiana-OFL.txt +93 -0
  293. package/skills/uipm-ui-styling/canvas-fonts/Italiana-Regular.ttf +0 -0
  294. package/skills/uipm-ui-styling/canvas-fonts/JetBrainsMono-Bold.ttf +0 -0
  295. package/skills/uipm-ui-styling/canvas-fonts/JetBrainsMono-OFL.txt +93 -0
  296. package/skills/uipm-ui-styling/canvas-fonts/JetBrainsMono-Regular.ttf +0 -0
  297. package/skills/uipm-ui-styling/canvas-fonts/Jura-Light.ttf +0 -0
  298. package/skills/uipm-ui-styling/canvas-fonts/Jura-Medium.ttf +0 -0
  299. package/skills/uipm-ui-styling/canvas-fonts/Jura-OFL.txt +93 -0
  300. package/skills/uipm-ui-styling/canvas-fonts/LibreBaskerville-OFL.txt +93 -0
  301. package/skills/uipm-ui-styling/canvas-fonts/LibreBaskerville-Regular.ttf +0 -0
  302. package/skills/uipm-ui-styling/canvas-fonts/Lora-Bold.ttf +0 -0
  303. package/skills/uipm-ui-styling/canvas-fonts/Lora-BoldItalic.ttf +0 -0
  304. package/skills/uipm-ui-styling/canvas-fonts/Lora-Italic.ttf +0 -0
  305. package/skills/uipm-ui-styling/canvas-fonts/Lora-OFL.txt +93 -0
  306. package/skills/uipm-ui-styling/canvas-fonts/Lora-Regular.ttf +0 -0
  307. package/skills/uipm-ui-styling/canvas-fonts/NationalPark-Bold.ttf +0 -0
  308. package/skills/uipm-ui-styling/canvas-fonts/NationalPark-OFL.txt +93 -0
  309. package/skills/uipm-ui-styling/canvas-fonts/NationalPark-Regular.ttf +0 -0
  310. package/skills/uipm-ui-styling/canvas-fonts/NothingYouCouldDo-OFL.txt +93 -0
  311. package/skills/uipm-ui-styling/canvas-fonts/NothingYouCouldDo-Regular.ttf +0 -0
  312. package/skills/uipm-ui-styling/canvas-fonts/Outfit-Bold.ttf +0 -0
  313. package/skills/uipm-ui-styling/canvas-fonts/Outfit-OFL.txt +93 -0
  314. package/skills/uipm-ui-styling/canvas-fonts/Outfit-Regular.ttf +0 -0
  315. package/skills/uipm-ui-styling/canvas-fonts/PixelifySans-Medium.ttf +0 -0
  316. package/skills/uipm-ui-styling/canvas-fonts/PixelifySans-OFL.txt +93 -0
  317. package/skills/uipm-ui-styling/canvas-fonts/PoiretOne-OFL.txt +93 -0
  318. package/skills/uipm-ui-styling/canvas-fonts/PoiretOne-Regular.ttf +0 -0
  319. package/skills/uipm-ui-styling/canvas-fonts/RedHatMono-Bold.ttf +0 -0
  320. package/skills/uipm-ui-styling/canvas-fonts/RedHatMono-OFL.txt +93 -0
  321. package/skills/uipm-ui-styling/canvas-fonts/RedHatMono-Regular.ttf +0 -0
  322. package/skills/uipm-ui-styling/canvas-fonts/Silkscreen-OFL.txt +93 -0
  323. package/skills/uipm-ui-styling/canvas-fonts/Silkscreen-Regular.ttf +0 -0
  324. package/skills/uipm-ui-styling/canvas-fonts/SmoochSans-Medium.ttf +0 -0
  325. package/skills/uipm-ui-styling/canvas-fonts/SmoochSans-OFL.txt +93 -0
  326. package/skills/uipm-ui-styling/canvas-fonts/Tektur-Medium.ttf +0 -0
  327. package/skills/uipm-ui-styling/canvas-fonts/Tektur-OFL.txt +93 -0
  328. package/skills/uipm-ui-styling/canvas-fonts/Tektur-Regular.ttf +0 -0
  329. package/skills/uipm-ui-styling/canvas-fonts/WorkSans-Bold.ttf +0 -0
  330. package/skills/uipm-ui-styling/canvas-fonts/WorkSans-BoldItalic.ttf +0 -0
  331. package/skills/uipm-ui-styling/canvas-fonts/WorkSans-Italic.ttf +0 -0
  332. package/skills/uipm-ui-styling/canvas-fonts/WorkSans-OFL.txt +93 -0
  333. package/skills/uipm-ui-styling/canvas-fonts/WorkSans-Regular.ttf +0 -0
  334. package/skills/uipm-ui-styling/canvas-fonts/YoungSerif-OFL.txt +93 -0
  335. package/skills/uipm-ui-styling/canvas-fonts/YoungSerif-Regular.ttf +0 -0
  336. package/skills/uipm-ui-styling/references/canvas-design-system.md +320 -0
  337. package/skills/uipm-ui-styling/references/shadcn-accessibility.md +471 -0
  338. package/skills/uipm-ui-styling/references/shadcn-components.md +424 -0
  339. package/skills/uipm-ui-styling/references/shadcn-theming.md +373 -0
  340. package/skills/uipm-ui-styling/references/tailwind-customization.md +483 -0
  341. package/skills/uipm-ui-styling/references/tailwind-responsive.md +382 -0
  342. package/skills/uipm-ui-styling/references/tailwind-utilities.md +455 -0
  343. package/skills/uipm-ui-styling/scripts/.coverage +0 -0
  344. package/skills/uipm-ui-styling/scripts/requirements.txt +17 -0
  345. package/skills/uipm-ui-styling/scripts/shadcn_add.py +292 -0
  346. package/skills/uipm-ui-styling/scripts/tailwind_config_gen.py +456 -0
  347. package/skills/uipm-ui-styling/scripts/tests/coverage-ui.json +1 -0
  348. package/skills/uipm-ui-styling/scripts/tests/requirements.txt +3 -0
  349. package/skills/uipm-ui-styling/scripts/tests/test_shadcn_add.py +266 -0
  350. package/skills/uipm-ui-styling/scripts/tests/test_tailwind_config_gen.py +336 -0
@@ -0,0 +1,898 @@
1
+ # monitor a folder and auto-trigger --update when files change
2
+ from __future__ import annotations
3
+ import contextlib
4
+ import json
5
+ import os
6
+ import re
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+
11
+ _GRAPHIFY_OUT = os.environ.get("GRAPHIFY_OUT", "graphify-out")
12
+ _PENDING_FILENAME = ".pending_changes"
13
+ _PENDING_DRAIN_MAX_PASSES = 20
14
+
15
+
16
+ def _queue_pending(out_dir: Path, changed_paths: list[Path]) -> None:
17
+ """Append ``changed_paths`` to ``out_dir/.pending_changes`` (one per line).
18
+
19
+ Used by a post-commit hook process that cannot acquire ``_rebuild_lock``
20
+ so its change set is not silently dropped (#1059). The lock-holding
21
+ process drains this file before and after its rebuild and merges the
22
+ contents with its own change set.
23
+
24
+ Opened in append mode so concurrent writers do not clobber each other on
25
+ POSIX; each ``write()`` of a small payload is effectively atomic. A
26
+ trailing newline is always written so partial-line corruption stays
27
+ confined to the offending entry and is skipped on drain.
28
+ """
29
+ if not changed_paths:
30
+ return
31
+ out_dir.mkdir(parents=True, exist_ok=True)
32
+ pending = out_dir / _PENDING_FILENAME
33
+ payload = "".join(f"{os.fspath(p)}\n" for p in changed_paths)
34
+ with open(pending, "a", encoding="utf-8") as fh:
35
+ fh.write(payload)
36
+
37
+
38
+ def _drain_pending(out_dir: Path) -> list[Path]:
39
+ """Read + unlink ``out_dir/.pending_changes`` and return deduplicated paths.
40
+
41
+ Returns an empty list if the file does not exist. Empty/whitespace lines
42
+ are silently skipped so a partial concurrent write that left only a
43
+ fragment cannot poison the merge.
44
+ """
45
+ pending = out_dir / _PENDING_FILENAME
46
+ if not pending.exists():
47
+ return []
48
+ try:
49
+ raw = pending.read_text(encoding="utf-8")
50
+ except OSError:
51
+ return []
52
+ # Unlink BEFORE returning so a crash between read and process retains the
53
+ # data in the next caller's view via the lines we are about to return —
54
+ # i.e. losing the file after reading is fine, losing it before would be a
55
+ # bug. Use missing_ok to tolerate a racing drain on platforms where
56
+ # rename/unlink may interleave.
57
+ with contextlib.suppress(FileNotFoundError):
58
+ pending.unlink()
59
+ seen: set[str] = set()
60
+ out: list[Path] = []
61
+ for line in raw.splitlines():
62
+ s = line.strip()
63
+ if not s or s in seen:
64
+ continue
65
+ seen.add(s)
66
+ out.append(Path(s))
67
+ return out
68
+
69
+
70
+ def _merge_changed_paths(*sources: "list[Path] | None") -> list[Path]:
71
+ """Concatenate path lists, preserving order and dropping duplicates.
72
+
73
+ Used to combine a hook process's own ``changed_paths`` with the drained
74
+ contents of ``.pending_changes`` so the lock-holding rebuild covers
75
+ every queued commit's worth of files (#1059).
76
+ """
77
+ seen: set[str] = set()
78
+ out: list[Path] = []
79
+ for src in sources:
80
+ if not src:
81
+ continue
82
+ for p in src:
83
+ key = os.fspath(p)
84
+ if key in seen:
85
+ continue
86
+ seen.add(key)
87
+ out.append(p)
88
+ return out
89
+
90
+
91
+ @contextlib.contextmanager
92
+ def _rebuild_lock(out_dir: Path, *, blocking: bool = False):
93
+ """Per-repo advisory lock around a rebuild.
94
+
95
+ Yields True if acquired, False if another rebuild is already running and
96
+ ``blocking`` is False. Uses fcntl.flock so the lock is released
97
+ automatically if the process is killed (no stale-lock cleanup needed).
98
+
99
+ While the lock is held, ``.rebuild.lock`` contains the owning PID followed
100
+ by a newline so external pollers (publish scripts, etc.) can read it.
101
+ On successful release the file is unlinked so downstream tooling that
102
+ waits for the lock to clear by polling for its absence unblocks promptly.
103
+
104
+ Falls back to a no-op yield(True) on platforms without fcntl (Windows).
105
+ """
106
+ try:
107
+ import fcntl
108
+ except ImportError:
109
+ yield True
110
+ return
111
+
112
+ out_dir.mkdir(parents=True, exist_ok=True)
113
+ lock_path = out_dir / ".rebuild.lock"
114
+ # "a+" creates the file if missing without truncating an existing holder's
115
+ # PID payload — important because another process may have already written
116
+ # its PID before we attempt the flock.
117
+ fh = open(lock_path, "a+", encoding="utf-8")
118
+ acquired = False
119
+ try:
120
+ flags = fcntl.LOCK_EX if blocking else (fcntl.LOCK_EX | fcntl.LOCK_NB)
121
+ try:
122
+ fcntl.flock(fh.fileno(), flags)
123
+ except BlockingIOError:
124
+ yield False
125
+ return
126
+ acquired = True
127
+ # Replace any prior owner's PID with ours so external readers see a
128
+ # single parseable line, not a digit-concatenation across rebuilds.
129
+ try:
130
+ fh.seek(0)
131
+ fh.truncate()
132
+ fh.write(f"{os.getpid()}\n")
133
+ fh.flush()
134
+ except OSError:
135
+ pass
136
+ yield True
137
+ finally:
138
+ if acquired:
139
+ try:
140
+ fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
141
+ except OSError:
142
+ pass
143
+ fh.close()
144
+ # Signal "rebuild done" by removing the lock file. Only the holder
145
+ # unlinks; a non-acquiring caller leaves the existing lock in place.
146
+ if acquired:
147
+ with contextlib.suppress(OSError):
148
+ lock_path.unlink()
149
+
150
+
151
+ def _apply_resource_limits() -> None:
152
+ """Best-effort nice + memory cap. Called from inline hook scripts.
153
+
154
+ GRAPHIFY_REBUILD_MEMORY_LIMIT_MB caps RSS-ish memory. Uses RLIMIT_DATA on
155
+ macOS (RLIMIT_AS is unreliable under Apple's libmalloc) and RLIMIT_AS on
156
+ Linux. Silently skips if the platform doesn't support it.
157
+ """
158
+ try:
159
+ os.nice(10)
160
+ except (OSError, AttributeError):
161
+ pass
162
+ mb = os.environ.get("GRAPHIFY_REBUILD_MEMORY_LIMIT_MB", "").strip()
163
+ if not mb:
164
+ return
165
+ try:
166
+ limit = int(mb) * 1024 * 1024
167
+ except ValueError:
168
+ return
169
+ try:
170
+ import resource
171
+ which = resource.RLIMIT_DATA if sys.platform == "darwin" else resource.RLIMIT_AS
172
+ soft, hard = resource.getrlimit(which)
173
+ new_hard = hard if hard != resource.RLIM_INFINITY and hard < limit else limit
174
+ resource.setrlimit(which, (limit, new_hard))
175
+ except (ImportError, ValueError, OSError):
176
+ pass
177
+
178
+
179
+ def _git_head() -> str | None:
180
+ """Return current git HEAD commit hash, or None outside a repo."""
181
+ import subprocess as _sp
182
+ try:
183
+ r = _sp.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True, timeout=3)
184
+ return r.stdout.strip() if r.returncode == 0 else None
185
+ except Exception:
186
+ return None
187
+
188
+
189
+ from graphify.detect import (
190
+ CODE_EXTENSIONS,
191
+ DOC_EXTENSIONS,
192
+ PAPER_EXTENSIONS,
193
+ IMAGE_EXTENSIONS,
194
+ _load_graphifyignore,
195
+ _is_ignored,
196
+ )
197
+
198
+ _WATCHED_EXTENSIONS = CODE_EXTENSIONS | DOC_EXTENSIONS | PAPER_EXTENSIONS | IMAGE_EXTENSIONS
199
+ _CODE_EXTENSIONS = CODE_EXTENSIONS
200
+
201
+
202
+ def _report_root_label(watch_path: Path) -> str:
203
+ if watch_path.is_absolute():
204
+ return watch_path.name or str(watch_path)
205
+ return Path.cwd().name if watch_path == Path(".") else str(watch_path)
206
+
207
+
208
+ def _relativize_source_files(payload: dict, root: Path) -> None:
209
+ for bucket in ("nodes", "edges", "hyperedges"):
210
+ for item in payload.get(bucket, []):
211
+ source = item.get("source_file")
212
+ if not source:
213
+ continue
214
+ source_path = Path(source)
215
+ if not source_path.is_absolute():
216
+ continue
217
+ try:
218
+ item["source_file"] = source_path.resolve().relative_to(root).as_posix()
219
+ except ValueError:
220
+ continue
221
+
222
+
223
+ def _node_community_map(graph_data: dict) -> dict[str, int]:
224
+ out: dict[str, int] = {}
225
+ for node in graph_data.get("nodes", []):
226
+ node_id = node.get("id")
227
+ cid = node.get("community")
228
+ if node_id is None or cid is None:
229
+ continue
230
+ try:
231
+ out[str(node_id)] = int(cid)
232
+ except (TypeError, ValueError):
233
+ print(
234
+ f"[graphify watch] Skipping node with invalid community id: "
235
+ f"node_id={node_id!r} community={cid!r}",
236
+ file=sys.stderr,
237
+ )
238
+ continue
239
+ return out
240
+
241
+
242
+ def _canonical_graph_for_compare(graph_data: dict) -> dict:
243
+ canonical = dict(graph_data)
244
+ canonical.pop("built_at_commit", None)
245
+ for key in ("nodes", "links", "edges", "hyperedges"):
246
+ if key in canonical and isinstance(canonical[key], list):
247
+ canonical[key] = sorted(
248
+ canonical[key],
249
+ key=lambda item: json.dumps(item, sort_keys=True, ensure_ascii=False, default=str),
250
+ )
251
+ return canonical
252
+
253
+
254
+ def _canonical_topology_for_compare(graph_data: dict) -> dict:
255
+ canonical = dict(graph_data)
256
+ canonical.pop("built_at_commit", None)
257
+
258
+ nodes = canonical.get("nodes")
259
+ if isinstance(nodes, list):
260
+ norm_nodes = []
261
+ for node in nodes:
262
+ if not isinstance(node, dict):
263
+ continue
264
+ n = dict(node)
265
+ n.pop("community", None)
266
+ n.pop("norm_label", None)
267
+ norm_nodes.append(n)
268
+ canonical["nodes"] = sorted(
269
+ norm_nodes,
270
+ key=lambda item: json.dumps(item, sort_keys=True, ensure_ascii=False, default=str),
271
+ )
272
+
273
+ for key in ("links", "edges"):
274
+ items = canonical.get(key)
275
+ if not isinstance(items, list):
276
+ continue
277
+ norm_edges = []
278
+ for edge in items:
279
+ if not isinstance(edge, dict):
280
+ continue
281
+ e = dict(edge)
282
+ # to_json writes _src/_tgt as the canonical directed endpoints and
283
+ # overwrites source/target with them before serialising, so the
284
+ # on-disk graph has no _src/_tgt. The candidate topology (fresh from
285
+ # node_link_data) still has them. Popping and reassigning here makes
286
+ # both sides comparable: existing gets no-op pops (None), candidate
287
+ # gets source/target overwritten from _src/_tgt — same result.
288
+ true_src = e.pop("_src", None)
289
+ true_tgt = e.pop("_tgt", None)
290
+ if true_src is not None and true_tgt is not None:
291
+ e["source"] = true_src
292
+ e["target"] = true_tgt
293
+ e.pop("confidence_score", None)
294
+ norm_edges.append(e)
295
+ canonical[key] = sorted(
296
+ norm_edges,
297
+ key=lambda item: json.dumps(item, sort_keys=True, ensure_ascii=False, default=str),
298
+ )
299
+
300
+ hyperedges = canonical.get("hyperedges")
301
+ if isinstance(hyperedges, list):
302
+ canonical["hyperedges"] = sorted(
303
+ hyperedges,
304
+ key=lambda item: json.dumps(item, sort_keys=True, ensure_ascii=False, default=str),
305
+ )
306
+
307
+ return canonical
308
+
309
+
310
+ def _topology_from_graph(G) -> dict:
311
+ from networkx.readwrite import json_graph
312
+ try:
313
+ data = json_graph.node_link_data(G, edges="links")
314
+ except TypeError:
315
+ data = json_graph.node_link_data(G)
316
+ data["hyperedges"] = getattr(G, "graph", {}).get("hyperedges", [])
317
+ return data
318
+
319
+
320
+ def _check_shrink(
321
+ force: bool,
322
+ existing_data: dict,
323
+ new_data: dict,
324
+ tmp: "Path | None" = None,
325
+ *,
326
+ had_explicit_deletions: bool = False,
327
+ ) -> bool:
328
+ """Return True (ok to proceed) or False (shrink refused).
329
+
330
+ When False, cleans up *tmp* if provided and prints a warning to stderr.
331
+
332
+ The shrink-guard exists to catch SILENT shrinkage from failed extraction
333
+ chunks (a half-written semantic pass leaving thousands of nodes
334
+ unaccounted for). When ``had_explicit_deletions`` is True, the caller
335
+ has declared which files were removed (e.g. the post-commit hook saw
336
+ a ``D`` in ``git diff --name-only``) and a smaller graph is the expected
337
+ outcome — skip the guard so legitimate refactors don't require ``--force``.
338
+ """
339
+ if force or not existing_data or had_explicit_deletions:
340
+ return True
341
+ existing_n = len(existing_data.get("nodes", []))
342
+ new_n = len(new_data.get("nodes", []))
343
+ if new_n < existing_n:
344
+ if tmp is not None:
345
+ tmp.unlink(missing_ok=True)
346
+ print(
347
+ f"[graphify] WARNING: new graph has {new_n} nodes but existing "
348
+ f"graph.json has {existing_n}. Refusing to overwrite — you may be "
349
+ f"missing chunk files from a previous session. "
350
+ f"Pass --force to override.",
351
+ file=sys.stderr,
352
+ )
353
+ return False
354
+ return True
355
+
356
+
357
+ def _report_for_compare(report_text: str) -> str:
358
+ return re.sub(r"^- Built from commit: `[^`]+`\n?", "", report_text, flags=re.MULTILINE)
359
+
360
+
361
+ def _json_text(data: dict) -> str:
362
+ return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
363
+
364
+
365
+ def _rebuild_code(
366
+ watch_path: Path,
367
+ *,
368
+ changed_paths: list[Path] | None = None,
369
+ follow_symlinks: bool = False,
370
+ force: bool = False,
371
+ no_cluster: bool = False,
372
+ acquire_lock: bool = True,
373
+ block_on_lock: bool = False,
374
+ ) -> bool:
375
+ """Re-run AST extraction + build + optional cluster + report for code files. No LLM needed.
376
+
377
+ When ``force`` is True the node-count safety check in ``to_json`` is bypassed
378
+ so the rebuilt graph overwrites graph.json even if it has fewer nodes.
379
+ Use this after refactors that legitimately delete code.
380
+
381
+ When ``changed_paths`` is provided, only those files are re-extracted; nodes
382
+ for unchanged files are preserved from the existing graph. Deleted paths
383
+ in ``changed_paths`` (paths that no longer exist on disk) are dropped from
384
+ the preserved set. When ``changed_paths`` is None the full code corpus is
385
+ re-extracted (used by the watcher and post-checkout hook).
386
+
387
+ ``acquire_lock`` (default True) takes a non-blocking per-repo flock around
388
+ the rebuild so concurrent post-commit hooks across multiple repos do not
389
+ pile up. Returns False with a log line if the lock is held. Pass
390
+ ``block_on_lock=True`` to wait instead of skip (used by the interactive
391
+ ``graphify update`` CLI).
392
+
393
+ ``no_cluster`` skips community detection and writes raw merged extraction
394
+ JSON to graphify-out/graph.json (mirrors ``extract --no-cluster``).
395
+
396
+ Returns True on success, False on error or skipped-due-to-lock.
397
+ """
398
+ out = watch_path / _GRAPHIFY_OUT
399
+ if acquire_lock:
400
+ # #1059: incremental (changed_paths is not None) hooks must not drop
401
+ # their change set when another rebuild is already running. Queue
402
+ # before attempting the lock so a non-blocking failure still records
403
+ # the work; the lock-holder drains the queue and merges it in. Full-
404
+ # corpus rebuilds skip the queue entirely — they already cover every
405
+ # file, so there is nothing to merge.
406
+ if changed_paths is not None and not block_on_lock:
407
+ _queue_pending(out, list(changed_paths))
408
+ with _rebuild_lock(out, blocking=block_on_lock) as got:
409
+ if not got:
410
+ print("[graphify watch] Rebuild already in progress for "
411
+ f"{watch_path.resolve()} - changes queued.")
412
+ return False
413
+ # Lock acquired. Drain anything queued by earlier contenders
414
+ # (including, importantly, the paths we just queued ourselves)
415
+ # and merge with our own change set so a single rebuild covers
416
+ # everything outstanding.
417
+ if changed_paths is not None:
418
+ merged = _merge_changed_paths(changed_paths, _drain_pending(out))
419
+ else:
420
+ # Full-corpus rebuild supersedes any queued incremental work.
421
+ _drain_pending(out)
422
+ merged = None
423
+ ok = _rebuild_code(
424
+ watch_path,
425
+ changed_paths=merged,
426
+ follow_symlinks=follow_symlinks,
427
+ force=force,
428
+ no_cluster=no_cluster,
429
+ acquire_lock=False,
430
+ )
431
+ # Late-arrival drain: another hook may have queued work while we
432
+ # were rebuilding. Loop up to _PENDING_DRAIN_MAX_PASSES times so a
433
+ # storm of commits eventually quiesces without livelocking. A full
434
+ # rebuild already saw everything, so skip this for changed_paths is None.
435
+ if merged is not None:
436
+ for _ in range(_PENDING_DRAIN_MAX_PASSES):
437
+ late = _drain_pending(out)
438
+ if not late:
439
+ break
440
+ ok = _rebuild_code(
441
+ watch_path,
442
+ changed_paths=late,
443
+ follow_symlinks=follow_symlinks,
444
+ force=force,
445
+ no_cluster=no_cluster,
446
+ acquire_lock=False,
447
+ ) and ok
448
+ return ok
449
+
450
+ watch_root = watch_path.resolve()
451
+ project_root = Path.cwd().resolve() if not watch_path.is_absolute() else watch_root
452
+ report_root = _report_root_label(watch_path)
453
+ try:
454
+ from graphify.extract import extract, _get_extractor
455
+ from graphify.detect import detect
456
+ from graphify.build import build_from_json, _norm_source_file as _nsf
457
+ from graphify.cluster import cluster, remap_communities_to_previous, score_all
458
+ from graphify.analyze import god_nodes, surprising_connections, suggest_questions
459
+ from graphify.report import generate
460
+ from graphify.export import to_json, to_html
461
+ from graphify.security import check_graph_file_size_cap
462
+
463
+ detected = detect(watch_path, follow_symlinks=follow_symlinks)
464
+ code_files = [Path(f) for f in detected['files']['code']]
465
+
466
+ # Include document files that have AST extractors (e.g. .md, .mdx, .qmd)
467
+ for doc_file in detected['files'].get('document', []):
468
+ p = Path(doc_file)
469
+ if _get_extractor(p) is not None:
470
+ code_files.append(p)
471
+
472
+ if not code_files:
473
+ print("[graphify watch] No code files found - nothing to rebuild.")
474
+ return False
475
+
476
+ # Incremental path: when the caller passed an explicit change list,
477
+ # extract only changed-and-still-existing files. Deleted paths are
478
+ # tracked separately so their stale nodes can be evicted below.
479
+ deleted_paths: set[str] = set()
480
+ if changed_paths is not None:
481
+ code_set = {p.resolve() for p in code_files}
482
+ wanted: list[Path] = []
483
+ for raw in changed_paths:
484
+ cand = (watch_root / raw).resolve() if not raw.is_absolute() else raw.resolve()
485
+ if cand.exists() and cand in code_set:
486
+ wanted.append(cand)
487
+ else:
488
+ # File was deleted, renamed away, or filtered out by detect
489
+ # (e.g. .gitignore, vendored). Either way, evict any
490
+ # preserved nodes that still claim this source path.
491
+ deleted_paths.add(_nsf(str(cand), str(project_root)) or str(cand))
492
+ if not wanted and not deleted_paths:
493
+ print("[graphify watch] No tracked code files in change set - skipping rebuild.")
494
+ return True
495
+ extract_targets = wanted
496
+ else:
497
+ extract_targets = code_files
498
+
499
+ commit = _git_head()
500
+ result = extract(extract_targets, cache_root=watch_root) if extract_targets else {
501
+ "nodes": [], "edges": [], "hyperedges": [],
502
+ "input_tokens": 0, "output_tokens": 0,
503
+ }
504
+
505
+ # Preserve semantic nodes/edges from a previous full run.
506
+ # AST-only rebuild replaces nodes for changed files; everything else is kept.
507
+ # Filter by node ID membership in the new AST output, not by file_type —
508
+ # INFERRED/AMBIGUOUS nodes extracted from code files also carry file_type="code"
509
+ # and would be wrongly dropped by a file_type-based filter.
510
+ # When the caller supplied changed_paths, also evict preserved nodes whose
511
+ # source_file matches a path that was changed (re-extracted) or deleted —
512
+ # otherwise the old nodes for those files would survive forever.
513
+ existing_graph = out / "graph.json"
514
+ existing_graph_data: dict = {}
515
+ if existing_graph.exists():
516
+ try:
517
+ check_graph_file_size_cap(existing_graph)
518
+ existing = json.loads(existing_graph.read_text(encoding="utf-8"))
519
+ existing_graph_data = existing
520
+ new_ast_ids = {n["id"] for n in result["nodes"]}
521
+ _relativize_source_files(existing, project_root)
522
+ evict_sources: set[str] = set(deleted_paths)
523
+ if changed_paths is not None:
524
+ for p in extract_targets:
525
+ evict_sources.add(_nsf(str(p), str(project_root)) or str(p))
526
+ else:
527
+ # Full re-extraction: reconcile against current code files to
528
+ # evict nodes from files deleted since the last run (#1007).
529
+ _root_str = str(project_root)
530
+ current_sources = {
531
+ _nsf(str(p.relative_to(project_root)), _root_str)
532
+ for p in code_files
533
+ if p.is_relative_to(project_root)
534
+ }
535
+ for n in existing.get("nodes", []):
536
+ sf = n.get("source_file")
537
+ if not sf:
538
+ continue
539
+ if Path(sf).suffix.lower() not in _CODE_EXTENSIONS:
540
+ continue
541
+ norm = _nsf(sf, _root_str)
542
+ if norm not in current_sources:
543
+ evict_sources.add(sf)
544
+ evict_sources.add(norm)
545
+ deleted_paths.add(norm)
546
+ # On a full re-extraction every code file is re-extracted, so
547
+ # new_ast_ids is the complete current AST set. Any AST-marked node
548
+ # missing from it is stale and must be dropped even if its source
549
+ # file still exists (a symbol removed from a surviving file, #1116).
550
+ # Gate on full_rebuild: in incremental mode an AST node from an
551
+ # unchanged file is legitimately absent from new_ast_ids. Semantic
552
+ # nodes lack the "_origin" marker, so they are never dropped here —
553
+ # only by the deleted-file eviction in evict_sources above.
554
+ full_rebuild = changed_paths is None
555
+ preserved_nodes = [
556
+ n for n in existing.get("nodes", [])
557
+ if n["id"] not in new_ast_ids
558
+ and not (full_rebuild and n.get("_origin") == "ast")
559
+ and (not evict_sources or n.get("source_file") not in evict_sources)
560
+ ]
561
+ all_ids = new_ast_ids | {n["id"] for n in preserved_nodes}
562
+ preserved_edges = [
563
+ e for e in existing.get("links", existing.get("edges", []))
564
+ if e.get("source") in all_ids and e.get("target") in all_ids
565
+ ]
566
+ result = {
567
+ "nodes": result["nodes"] + preserved_nodes,
568
+ "edges": result["edges"] + preserved_edges,
569
+ "hyperedges": existing.get("hyperedges", []),
570
+ "input_tokens": 0,
571
+ "output_tokens": 0,
572
+ }
573
+ except Exception:
574
+ pass # corrupt graph.json - proceed with AST-only
575
+
576
+ _relativize_source_files(result, project_root)
577
+ out.mkdir(exist_ok=True)
578
+ # Write the user-supplied path rather than the resolved absolute form
579
+ # so a committed ``graphify-out/.graphify_root`` is portable across
580
+ # clones and CI runners (#777). When ``watch_path`` is ``.`` (the
581
+ # common case for ``graphify update``), this writes ``.`` and the
582
+ # subsequent re-run resolves it against the caller's CWD.
583
+ (out / ".graphify_root").write_text(str(watch_path), encoding="utf-8")
584
+
585
+ if no_cluster:
586
+ # Normalise to "links" key so schema is consistent with the full clustered path.
587
+ candidate_graph_data = {
588
+ **{k: v for k, v in result.items() if k != "edges"},
589
+ "links": result.get("edges", []),
590
+ }
591
+ candidate_graph_text = _json_text(candidate_graph_data)
592
+ same_graph = False
593
+ if existing_graph.exists():
594
+ try:
595
+ check_graph_file_size_cap(existing_graph)
596
+ existing_payload = json.loads(existing_graph.read_text(encoding="utf-8"))
597
+ same_graph = (
598
+ json.dumps(_canonical_graph_for_compare(existing_payload), sort_keys=True, ensure_ascii=False)
599
+ == json.dumps(_canonical_graph_for_compare(candidate_graph_data), sort_keys=True, ensure_ascii=False)
600
+ )
601
+ except Exception:
602
+ same_graph = False
603
+ if not same_graph:
604
+ if not _check_shrink(
605
+ force, existing_graph_data, candidate_graph_data,
606
+ had_explicit_deletions=bool(deleted_paths),
607
+ ):
608
+ return False
609
+ existing_graph.write_text(candidate_graph_text, encoding="utf-8")
610
+
611
+ try:
612
+ from graphify.detect import save_manifest
613
+ save_manifest(detected["files"], kind="ast", root=project_root)
614
+ except Exception:
615
+ pass
616
+
617
+ # clear stale needs_update flag if present
618
+ flag = out / "needs_update"
619
+ if flag.exists():
620
+ flag.unlink()
621
+
622
+ if same_graph:
623
+ print("[graphify watch] No code-graph changes detected (--no-cluster); outputs left untouched.")
624
+ else:
625
+ print(
626
+ "[graphify watch] Rebuilt (no clustering): "
627
+ f"{len(result.get('nodes', []))} nodes, {len(result.get('edges', []))} edges"
628
+ )
629
+ print(f"[graphify watch] graph.json updated in {out}")
630
+ return True
631
+
632
+ detection = {
633
+ "files": {"code": [str(f) for f in code_files], "document": [], "paper": [], "image": []},
634
+ "total_files": len(code_files),
635
+ "total_words": detected.get("total_words", 0),
636
+ }
637
+
638
+ G = build_from_json(result)
639
+ candidate_topology = _topology_from_graph(G)
640
+ if existing_graph_data:
641
+ try:
642
+ same_topology = (
643
+ json.dumps(_canonical_topology_for_compare(existing_graph_data), sort_keys=True, ensure_ascii=False)
644
+ == json.dumps(_canonical_topology_for_compare(candidate_topology), sort_keys=True, ensure_ascii=False)
645
+ )
646
+ except Exception:
647
+ same_topology = False
648
+ if same_topology:
649
+ try:
650
+ from graphify.detect import save_manifest
651
+ save_manifest(detected["files"], kind="ast", root=project_root)
652
+ except Exception:
653
+ pass
654
+ flag = out / "needs_update"
655
+ if flag.exists():
656
+ flag.unlink()
657
+ print("[graphify watch] No code-graph topology changes detected; outputs left untouched.")
658
+ return True
659
+
660
+ communities = cluster(G)
661
+ previous_node_community = _node_community_map(existing_graph_data)
662
+ if previous_node_community:
663
+ communities = remap_communities_to_previous(communities, previous_node_community)
664
+ cohesion = score_all(G, communities)
665
+ gods = god_nodes(G)
666
+ surprises = surprising_connections(G, communities)
667
+ labels_file = out / ".graphify_labels.json"
668
+ try:
669
+ raw = json.loads(labels_file.read_text(encoding="utf-8")) if labels_file.exists() else {}
670
+ labels = {int(k): v for k, v in raw.items() if int(k) in communities}
671
+ except Exception:
672
+ raw = {}
673
+ labels = {}
674
+ for cid in communities:
675
+ if cid not in labels:
676
+ labels[cid] = "Community " + str(cid)
677
+ questions = suggest_questions(G, communities, labels)
678
+ report = generate(G, communities, cohesion, labels, gods, surprises, detection,
679
+ {"input": 0, "output": 0}, report_root, suggested_questions=questions,
680
+ built_at_commit=commit)
681
+ report_path = out / "GRAPH_REPORT.md"
682
+ labels_json = json.dumps({str(k): v for k, v in sorted(labels.items())}, ensure_ascii=False, indent=2) + "\n"
683
+ graph_tmp = out / ".graph.tmp.json"
684
+ json_written = to_json(G, communities, str(graph_tmp), force=True, built_at_commit=commit)
685
+ if not json_written:
686
+ return False
687
+ candidate_graph_data = json.loads(graph_tmp.read_text(encoding="utf-8"))
688
+ same_graph = False
689
+ same_report = False
690
+ if existing_graph.exists():
691
+ try:
692
+ check_graph_file_size_cap(existing_graph)
693
+ existing_payload = json.loads(existing_graph.read_text(encoding="utf-8"))
694
+ same_graph = (
695
+ json.dumps(_canonical_graph_for_compare(existing_payload), sort_keys=True, ensure_ascii=False)
696
+ == json.dumps(_canonical_graph_for_compare(candidate_graph_data), sort_keys=True, ensure_ascii=False)
697
+ )
698
+ except Exception:
699
+ same_graph = False
700
+ if report_path.exists():
701
+ old_report = report_path.read_text(encoding="utf-8")
702
+ same_report = _report_for_compare(old_report) == _report_for_compare(report)
703
+ no_change = same_graph and same_report
704
+ if no_change:
705
+ graph_tmp.unlink(missing_ok=True)
706
+ print("[graphify watch] No code-graph changes detected; graph.json/GRAPH_REPORT.md left untouched.")
707
+ else:
708
+ if not _check_shrink(
709
+ force, existing_graph_data, candidate_graph_data,
710
+ tmp=graph_tmp,
711
+ had_explicit_deletions=bool(deleted_paths),
712
+ ):
713
+ return False
714
+ from graphify.export import backup_if_protected as _backup
715
+ _backup(out)
716
+ graph_tmp.replace(existing_graph)
717
+ report_path.write_text(report, encoding="utf-8")
718
+ labels_file.write_text(labels_json, encoding="utf-8")
719
+
720
+ try:
721
+ from graphify.detect import save_manifest
722
+ save_manifest(detected["files"], kind="ast", root=project_root)
723
+ except Exception:
724
+ pass
725
+
726
+ # to_html raises ValueError for graphs > MAX_NODES_FOR_VIZ (5000).
727
+ # Wrap so core outputs (graph.json + GRAPH_REPORT.md) always land.
728
+ html_written = False
729
+ if not no_change:
730
+ try:
731
+ to_html(G, communities, str(out / "graph.html"), community_labels=labels or None)
732
+ html_written = True
733
+ except ValueError as viz_err:
734
+ print(f"[graphify watch] Skipped graph.html: {viz_err}")
735
+ stale = out / "graph.html"
736
+ if stale.exists():
737
+ stale.unlink()
738
+
739
+ # Regenerate callflow HTML if the user previously generated one —
740
+ # opt-in by existence so users who never ran callflow-html aren't affected.
741
+ callflow_files = list(out.glob("*-callflow.html"))
742
+ if callflow_files and not no_change:
743
+ try:
744
+ from graphify.callflow_html import write_callflow_html
745
+ for cf in callflow_files:
746
+ write_callflow_html(
747
+ graph=out / "graph.json",
748
+ report=out / "GRAPH_REPORT.md",
749
+ labels=out / ".graphify_labels.json",
750
+ output=cf,
751
+ verbose=False,
752
+ )
753
+ except Exception as cf_err:
754
+ print(f"[graphify watch] callflow HTML update skipped: {cf_err}")
755
+
756
+ # clear stale needs_update flag if present
757
+ flag = out / "needs_update"
758
+ if flag.exists():
759
+ flag.unlink()
760
+
761
+ if not no_change:
762
+ print(f"[graphify watch] Rebuilt: {G.number_of_nodes()} nodes, "
763
+ f"{G.number_of_edges()} edges, {len(communities)} communities")
764
+ products = "graph.json" + (", graph.html" if html_written else "") + " and GRAPH_REPORT.md"
765
+ if callflow_files:
766
+ products += f", {len(callflow_files)} callflow HTML"
767
+ print(f"[graphify watch] {products} updated in {out}")
768
+ return True
769
+
770
+ except Exception as exc:
771
+ print(f"[graphify watch] Rebuild failed: {exc}")
772
+ return False
773
+
774
+
775
+ def check_update(watch_path: Path) -> bool:
776
+ """Check for pending semantic update flag and notify the user if set.
777
+
778
+ Cron-safe: always returns True so cron jobs do not alarm.
779
+ Non-code file changes (docs, papers, images) require LLM-backed
780
+ re-extraction via `/graphify --update` — this function only signals
781
+ that the update is needed.
782
+ """
783
+ flag = Path(watch_path) / _GRAPHIFY_OUT / "needs_update"
784
+ if flag.exists():
785
+ print(f"[graphify check-update] Pending non-code changes in {watch_path}.")
786
+ print("[graphify check-update] Run `/graphify --update` to apply semantic re-extraction.")
787
+ return True
788
+
789
+
790
+ def _notify_only(watch_path: Path) -> None:
791
+ """Write a flag file and print a notification (fallback for non-code-only corpora)."""
792
+ flag = watch_path / _GRAPHIFY_OUT / "needs_update"
793
+ flag.parent.mkdir(parents=True, exist_ok=True)
794
+ flag.write_text("1", encoding="utf-8")
795
+ print(f"\n[graphify watch] New or changed files detected in {watch_path}")
796
+ print("[graphify watch] Non-code files changed - semantic re-extraction requires LLM.")
797
+ print("[graphify watch] Run `/graphify --update` in Claude Code to update the graph.")
798
+ print(f"[graphify watch] Flag written to {flag}")
799
+
800
+
801
+ def _has_non_code(changed_paths: list[Path]) -> bool:
802
+ return any(p.suffix.lower() not in _CODE_EXTENSIONS for p in changed_paths)
803
+
804
+
805
+ def watch(watch_path: Path, debounce: float = 3.0) -> None:
806
+ """
807
+ Watch watch_path for new or modified files and auto-update the graph.
808
+
809
+ For code-only changes: re-runs AST extraction + rebuild immediately (no LLM).
810
+ For doc/paper/image changes: writes a needs_update flag and notifies the user
811
+ to run /graphify --update (LLM extraction required).
812
+
813
+ debounce: seconds to wait after the last change before triggering (avoids
814
+ running on every keystroke when many files are saved at once).
815
+ """
816
+ try:
817
+ from watchdog.observers import Observer
818
+ from watchdog.observers.polling import PollingObserver
819
+ from watchdog.events import FileSystemEventHandler
820
+ except ImportError as e:
821
+ raise ImportError("watchdog not installed. Run: pip install watchdog") from e
822
+
823
+ last_trigger: float = 0.0
824
+ pending: bool = False
825
+ changed: set[Path] = set()
826
+
827
+ # Load .graphifyignore patterns ONCE at startup so the handler does not
828
+ # re-parse the file on every filesystem event. Watchdog's handler runs on
829
+ # the observer thread and is invoked for every event the OS delivers
830
+ # (Time Machine writes, Docker/Colima VM I/O, Spotlight indexing, …) —
831
+ # without this short-circuit a busy volume can saturate a CPU core
832
+ # discarding events one extension at a time. (gh-928)
833
+ watch_root_for_ignore = watch_path.resolve()
834
+ ignore_patterns = _load_graphifyignore(watch_root_for_ignore)
835
+
836
+ class Handler(FileSystemEventHandler):
837
+ def on_any_event(self, event):
838
+ nonlocal last_trigger, pending
839
+ if event.is_directory:
840
+ return
841
+ path = Path(event.src_path)
842
+ # Check .graphifyignore BEFORE the extension/dotfile/out filters so
843
+ # the cheapest short-circuit for users with broad ignore patterns
844
+ # (node_modules/, .venv/, build/, …) fires first. _is_ignored
845
+ # tolerates absolute paths outside watch_root via its internal
846
+ # relative_to guard, so a stray symlinked event won't raise.
847
+ if ignore_patterns and _is_ignored(path, watch_root_for_ignore, ignore_patterns):
848
+ return
849
+ if path.suffix.lower() not in _WATCHED_EXTENSIONS:
850
+ return
851
+ if any(part.startswith(".") for part in path.parts):
852
+ return
853
+ if _GRAPHIFY_OUT in path.parts:
854
+ return
855
+ last_trigger = time.monotonic()
856
+ pending = True
857
+ changed.add(path)
858
+
859
+ handler = Handler()
860
+ # Use polling observer on macOS — FSEvents can miss rapid saves in some editors
861
+ observer = PollingObserver() if sys.platform == "darwin" else Observer()
862
+ observer.schedule(handler, str(watch_path), recursive=True)
863
+ observer.start()
864
+
865
+ print(f"[graphify watch] Watching {watch_path.resolve()} - press Ctrl+C to stop")
866
+ print(f"[graphify watch] Code changes rebuild graph automatically. "
867
+ f"Doc/image changes require /graphify --update.")
868
+ print(f"[graphify watch] Debounce: {debounce}s")
869
+
870
+ try:
871
+ while True:
872
+ time.sleep(0.5)
873
+ if pending and (time.monotonic() - last_trigger) >= debounce:
874
+ pending = False
875
+ batch = list(changed)
876
+ changed.clear()
877
+ print(f"\n[graphify watch] {len(batch)} file(s) changed")
878
+ has_non_code = _has_non_code(batch)
879
+ has_code = any(p.suffix.lower() in _CODE_EXTENSIONS for p in batch)
880
+ if has_code:
881
+ _rebuild_code(watch_path)
882
+ if has_non_code:
883
+ _notify_only(watch_path)
884
+ except KeyboardInterrupt:
885
+ print("\n[graphify watch] Stopped.")
886
+ finally:
887
+ observer.stop()
888
+ observer.join()
889
+
890
+
891
+ if __name__ == "__main__":
892
+ import argparse
893
+ parser = argparse.ArgumentParser(description="Watch a folder and auto-update the graphify graph")
894
+ parser.add_argument("path", nargs="?", default=".", help="Folder to watch (default: .)")
895
+ parser.add_argument("--debounce", type=float, default=3.0,
896
+ help="Seconds to wait after last change before updating (default: 3)")
897
+ args = parser.parse_args()
898
+ watch(Path(args.path), debounce=args.debounce)