@zuzuucodes/cli 1.4.0 → 1.6.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 (211) hide show
  1. package/bin/zuzuu.mjs +20 -4
  2. package/package.json +1 -1
  3. package/web-app/dist/auth.js +91 -0
  4. package/web-app/dist/server.js +16 -79
  5. package/web-app/dist/zuzuu-cli.js +124 -0
  6. package/web-app/dist/{zuzuu-api.js → zuzuu-routes.js} +158 -133
  7. package/web-app/web-dist/assets/CommandPalette-DhBdR7X3.js +45 -0
  8. package/web-app/web-dist/assets/DiffTab-CqxwSjI2.js +1 -0
  9. package/web-app/web-dist/assets/EditorPane-94QPFR9R.js +41 -0
  10. package/web-app/web-dist/assets/MonacoFile-D76epTrG.js +1 -0
  11. package/web-app/web-dist/assets/angular-html-BVBpGdXr.js +1 -0
  12. package/web-app/web-dist/assets/{angular-ts-CD_OonCa.js → angular-ts-BfdufMKP.js} +1 -1
  13. package/web-app/web-dist/assets/{apl-uOGC3x4e.js → apl-DWBSSoBH.js} +1 -1
  14. package/web-app/web-dist/assets/{astro-B6ybQmWG.js → astro-3LtMP0Sq.js} +1 -1
  15. package/web-app/web-dist/assets/{blade-B1QGRlVx.js → blade-llJRbbtR.js} +1 -1
  16. package/web-app/web-dist/assets/c-Wt1voDr2.js +1 -0
  17. package/web-app/web-dist/assets/{cobol-BgqgtYWn.js → cobol-x_HIyl2P.js} +1 -1
  18. package/web-app/web-dist/assets/{coffee-0wIRKYlr.js → coffee-CThvmt4R.js} +1 -1
  19. package/web-app/web-dist/assets/cpp-NtAeskI3.js +1 -0
  20. package/web-app/web-dist/assets/{crystal-CyTK3qFN.js → crystal-DNu_sX0G.js} +1 -1
  21. package/web-app/web-dist/assets/css-DJp_X0uY.js +1 -0
  22. package/web-app/web-dist/assets/{cssMode-B9jnrWOz.js → cssMode-ByQBaInt.js} +1 -1
  23. package/web-app/web-dist/assets/dist-DQqjtuhV.js +153 -0
  24. package/web-app/web-dist/assets/{edge-CvML9pwC.js → edge-ozw5tpLl.js} +1 -1
  25. package/web-app/web-dist/assets/{editor.api2-BmGoRSl4.js → editor.api2-C7skgoRB.js} +1 -1
  26. package/web-app/web-dist/assets/{elixir-CrjqTiSc.js → elixir-VhA6FeZt.js} +1 -1
  27. package/web-app/web-dist/assets/{elm-C4JtJ0Au.js → elm-dREJmIFz.js} +1 -1
  28. package/web-app/web-dist/assets/{erb-Cmeb-29V.js → erb-CIg6G69l.js} +1 -1
  29. package/web-app/web-dist/assets/{freemarker2-B5LAi19B.js → freemarker2-CBBwP9JV.js} +1 -1
  30. package/web-app/web-dist/assets/{git-rebase-CXqdToiP.js → git-rebase-B44mJPta.js} +1 -1
  31. package/web-app/web-dist/assets/{glimmer-js-Kq-kdTyV.js → glimmer-js-vH_gHG0-.js} +1 -1
  32. package/web-app/web-dist/assets/{glimmer-ts-D0RKLJNf.js → glimmer-ts--abOzSAQ.js} +1 -1
  33. package/web-app/web-dist/assets/glsl-Dv5r7kPw.js +1 -0
  34. package/web-app/web-dist/assets/graphql-CB4jsw2E.js +1 -0
  35. package/web-app/web-dist/assets/{hack-trjVF3Po.js → hack-DvEYX148.js} +1 -1
  36. package/web-app/web-dist/assets/haml-zE6W3STP.js +1 -0
  37. package/web-app/web-dist/assets/{handlebars-B8_x7Zx7.js → handlebars-CzBR2SDs.js} +1 -1
  38. package/web-app/web-dist/assets/{handlebars-g7ZhGhI_.js → handlebars-tXdfxEd6.js} +1 -1
  39. package/web-app/web-dist/assets/html-C8UlPnhE.js +1 -0
  40. package/web-app/web-dist/assets/{html-CfvRMgoC.js → html-DgPn1QYH.js} +1 -1
  41. package/web-app/web-dist/assets/{html-derivative-BYX_F_XH.js → html-derivative-CY6NRz-J.js} +1 -1
  42. package/web-app/web-dist/assets/{htmlMode-Bi8vSvwb.js → htmlMode-BtdIDgA2.js} +1 -1
  43. package/web-app/web-dist/assets/{http-BIVDpHT-.js → http-Cyd7bS_S.js} +1 -1
  44. package/web-app/web-dist/assets/{hurl-CFsshMju.js → hurl-CWPsiEpf.js} +1 -1
  45. package/web-app/web-dist/assets/index-B27_WOhS.css +2 -0
  46. package/web-app/web-dist/assets/index-De6DWTZM.js +7 -0
  47. package/web-app/web-dist/assets/java-CGc3VwQr.js +1 -0
  48. package/web-app/web-dist/assets/{javascript-Bxx2wV4w.js → javascript-5m05n-Be.js} +1 -1
  49. package/web-app/web-dist/assets/javascript-CUt1pgmJ.js +1 -0
  50. package/web-app/web-dist/assets/{jinja-_ZS5zWwe.js → jinja-CD-Z-FLd.js} +1 -1
  51. package/web-app/web-dist/assets/{jison-D8mMEpcs.js → jison-imPNup1l.js} +1 -1
  52. package/web-app/web-dist/assets/json-Bg9ijW3F.js +1 -0
  53. package/web-app/web-dist/assets/{jsonMode-C6ELX5GM.js → jsonMode-BG32YnTY.js} +1 -1
  54. package/web-app/web-dist/assets/jsx-CY6oMTks.js +1 -0
  55. package/web-app/web-dist/assets/{julia-D4h2DZrs.js → julia-Dc3O-irA.js} +1 -1
  56. package/web-app/web-dist/assets/{just-bMqQi3xg.js → just-BhOq_Kbv.js} +1 -1
  57. package/web-app/web-dist/assets/{latex-DThYi3CX.js → latex-Cu4Y1d5w.js} +1 -1
  58. package/web-app/web-dist/assets/lib-KIOQTlcs.js +1 -0
  59. package/web-app/web-dist/assets/{liquid-CUjzzP4r.js → liquid-3ZnQzTbs.js} +1 -1
  60. package/web-app/web-dist/assets/{liquid-CesB-zzl.js → liquid-CvXMrjlQ.js} +1 -1
  61. package/web-app/web-dist/assets/{lspLanguageFeatures-gTnJsses.js → lspLanguageFeatures-6KXALSrl.js} +1 -1
  62. package/web-app/web-dist/assets/lua-BjLEUjKY.js +1 -0
  63. package/web-app/web-dist/assets/{marko-yoGoLK2m.js → marko-DvhNOisQ.js} +1 -1
  64. package/web-app/web-dist/assets/{mdc-BvtXU6eH.js → mdc-Bm9TpL1X.js} +1 -1
  65. package/web-app/web-dist/assets/{mdx-DrXGQbNB.js → mdx-DffTEkNE.js} +1 -1
  66. package/web-app/web-dist/assets/{monaco-setup-CsR6EfHe.js → monaco-setup-DM3A5_VI.js} +3 -3
  67. package/web-app/web-dist/assets/{nginx-DoUz032F.js → nginx-Bhc82uuv.js} +1 -1
  68. package/web-app/web-dist/assets/{nim-B0Pl8B4R.js → nim-DXTVBFnF.js} +1 -1
  69. package/web-app/web-dist/assets/{perl-D2tfAALb.js → perl-C7veXV9z.js} +1 -1
  70. package/web-app/web-dist/assets/{php-BImCcX5X.js → php-BRiuMnnr.js} +1 -1
  71. package/web-app/web-dist/assets/{pug-BcnpC8P_.js → pug-C5hz5LQ7.js} +1 -1
  72. package/web-app/web-dist/assets/{python-ypRCBnvu.js → python-DyLAD3Wt.js} +1 -1
  73. package/web-app/web-dist/assets/{qml-DFDAunHY.js → qml-BdUV3aTS.js} +1 -1
  74. package/web-app/web-dist/assets/r-8R7vtdQc.js +1 -0
  75. package/web-app/web-dist/assets/{razor-aqrhpwqZ.js → razor-C49xQTPQ.js} +1 -1
  76. package/web-app/web-dist/assets/{razor-1_376SZM.js → razor-DRL52XO2.js} +1 -1
  77. package/web-app/web-dist/assets/react-vendor-CCIEwYL0.js +9 -0
  78. package/web-app/web-dist/assets/regexp-Omp9DhTb.js +1 -0
  79. package/web-app/web-dist/assets/{rst-2vG6f11Y.js → rst-BHX71KW9.js} +1 -1
  80. package/web-app/web-dist/assets/{ruby-Dj6bCFXR.js → ruby-B--HzjGU.js} +1 -1
  81. package/web-app/web-dist/assets/{sas-BhVZ4qL2.js → sas-DrLaYOK_.js} +1 -1
  82. package/web-app/web-dist/assets/scss-DdSxiZKl.js +1 -0
  83. package/web-app/web-dist/assets/shellscript-DwcUjJBL.js +1 -0
  84. package/web-app/web-dist/assets/{shellsession-CyO2fnhB.js → shellsession-CPZkydE6.js} +1 -1
  85. package/web-app/web-dist/assets/{soy-DIkw6E88.js → soy-Br5FhD7c.js} +1 -1
  86. package/web-app/web-dist/assets/sql-DNssxck8.js +1 -0
  87. package/web-app/web-dist/assets/{stata-DvkM932O.js → stata-DXn1tqOr.js} +1 -1
  88. package/web-app/web-dist/assets/{surrealql-B4-Q8tqV.js → surrealql-IeLNQw0f.js} +1 -1
  89. package/web-app/web-dist/assets/{svelte-p6yBy-Ki.js → svelte-DOdLCIlh.js} +1 -1
  90. package/web-app/web-dist/assets/{templ-C7EkuiZr.js → templ-CIwIngms.js} +1 -1
  91. package/web-app/web-dist/assets/{tex-DkmD8uFC.js → tex-D8QMumu5.js} +1 -1
  92. package/web-app/web-dist/assets/{ts-tags-U-hncHg4.js → ts-tags-BMVY4q-l.js} +1 -1
  93. package/web-app/web-dist/assets/{tsMode-a8OvovQd.js → tsMode-BndVBac5.js} +1 -1
  94. package/web-app/web-dist/assets/tsx-5Eka4NBX.js +1 -0
  95. package/web-app/web-dist/assets/{twig-CU0OP-IA.js → twig-C8o_5mgw.js} +1 -1
  96. package/web-app/web-dist/assets/{typescript-DnLjiKtn.js → typescript-B1w9vqKF.js} +1 -1
  97. package/web-app/web-dist/assets/typescript-DOu2WMV5.js +1 -0
  98. package/web-app/web-dist/assets/{vue-Db7nY3ba.js → vue-BU18DNDL.js} +1 -1
  99. package/web-app/web-dist/assets/{vue-html-BvAbiAw1.js → vue-html-BeluIYX0.js} +1 -1
  100. package/web-app/web-dist/assets/{vue-vine-BEaIQIlA.js → vue-vine-DGUAbOCX.js} +1 -1
  101. package/web-app/web-dist/assets/{xml-an4Nuuqq.js → xml-D8uAlVv5.js} +1 -1
  102. package/web-app/web-dist/assets/xml-DIqSwXR3.js +1 -0
  103. package/web-app/web-dist/assets/{xsl-D3NQgH22.js → xsl-Ct_-YIAy.js} +1 -1
  104. package/web-app/web-dist/assets/xterm-B1ffpRuj.js +36 -0
  105. package/web-app/web-dist/assets/xterm-addons-psDEiUMC.js +136 -0
  106. package/web-app/web-dist/assets/{yaml-Diiu6O9P.js → yaml-Bb7jXyQv.js} +1 -1
  107. package/web-app/web-dist/assets/yaml-DTtCYNlS.js +1 -0
  108. package/web-app/web-dist/index.html +6 -3
  109. package/zuzuu/actions/convert.mjs +10 -9
  110. package/zuzuu/actions/dispatch.mjs +12 -7
  111. package/zuzuu/actions/inbox.mjs +5 -5
  112. package/zuzuu/actions/manifest.mjs +48 -30
  113. package/zuzuu/actions/schema.mjs +9 -3
  114. package/zuzuu/actions/trail.mjs +1 -1
  115. package/zuzuu/commands/act-author.mjs +23 -13
  116. package/zuzuu/commands/act.mjs +4 -6
  117. package/zuzuu/commands/capture.mjs +2 -2
  118. package/zuzuu/commands/code.mjs +2 -2
  119. package/zuzuu/commands/digest.mjs +2 -2
  120. package/zuzuu/commands/distill.mjs +15 -16
  121. package/zuzuu/commands/doctor.mjs +41 -19
  122. package/zuzuu/commands/enable.mjs +1 -1
  123. package/zuzuu/commands/eval.mjs +3 -36
  124. package/zuzuu/commands/explain.mjs +4 -4
  125. package/zuzuu/commands/faculty.mjs +158 -0
  126. package/zuzuu/commands/generation.mjs +5 -8
  127. package/zuzuu/commands/hook.mjs +14 -12
  128. package/zuzuu/commands/inbox.mjs +1 -6
  129. package/zuzuu/commands/init.mjs +18 -4
  130. package/zuzuu/commands/knowledge.mjs +1 -1
  131. package/zuzuu/commands/migrations/home.mjs +96 -0
  132. package/zuzuu/commands/migrations/index.mjs +48 -0
  133. package/zuzuu/commands/migrations/items.mjs +360 -0
  134. package/zuzuu/commands/migrations/proposals.mjs +100 -0
  135. package/zuzuu/commands/proposals.mjs +131 -0
  136. package/zuzuu/commands/review.mjs +13 -227
  137. package/zuzuu/commands/session.mjs +8 -2
  138. package/zuzuu/commands/sessions.mjs +159 -0
  139. package/zuzuu/commands/status.mjs +3 -3
  140. package/zuzuu/commands/trace.mjs +1 -1
  141. package/zuzuu/{capture-core.mjs → core/capture-core.mjs} +3 -3
  142. package/zuzuu/{store.mjs → core/store.mjs} +1 -1
  143. package/zuzuu/digest/compose.mjs +96 -0
  144. package/zuzuu/eval/score.mjs +14 -1
  145. package/zuzuu/faculties/actions/index.mjs +283 -0
  146. package/zuzuu/faculties/guardrails/index.mjs +320 -0
  147. package/zuzuu/faculties/instructions/index.mjs +288 -0
  148. package/zuzuu/faculties/knowledge/index.mjs +185 -0
  149. package/zuzuu/faculties/memory/index.mjs +124 -0
  150. package/zuzuu/faculty/envelope.mjs +290 -0
  151. package/zuzuu/faculty/generation/read.mjs +206 -0
  152. package/zuzuu/faculty/generation/write.mjs +207 -0
  153. package/zuzuu/faculty/items.mjs +81 -0
  154. package/zuzuu/faculty/module.mjs +74 -0
  155. package/zuzuu/faculty/pending.mjs +63 -0
  156. package/zuzuu/faculty/registry.mjs +204 -18
  157. package/zuzuu/faculty/render.mjs +59 -0
  158. package/zuzuu/faculty/trail.mjs +1 -1
  159. package/zuzuu/guardrails/engine.mjs +137 -0
  160. package/zuzuu/{scaffold.mjs → home/scaffold.mjs} +110 -39
  161. package/zuzuu/knowledge/items.mjs +56 -91
  162. package/zuzuu/live/install.mjs +1 -1
  163. package/zuzuu/live/live-store.mjs +2 -2
  164. package/zuzuu/live/reconcile.mjs +2 -2
  165. package/zuzuu/sessions/git.mjs +47 -0
  166. package/zuzuu/{session-git.mjs → sessions/session-git.mjs} +5 -43
  167. package/web-app/web-dist/assets/DiffTab-BuWonUNJ.js +0 -1
  168. package/web-app/web-dist/assets/MonacoFile-CL3DhFKG.js +0 -1
  169. package/web-app/web-dist/assets/angular-html-CmT26mqM.js +0 -1
  170. package/web-app/web-dist/assets/c-BvoqrSVH.js +0 -1
  171. package/web-app/web-dist/assets/cpp-BXsk94m0.js +0 -1
  172. package/web-app/web-dist/assets/css-Z8oOGxII.js +0 -1
  173. package/web-app/web-dist/assets/dist-ChcDQ_7s.js +0 -153
  174. package/web-app/web-dist/assets/glsl-KwyfU2aa.js +0 -1
  175. package/web-app/web-dist/assets/graphql-DSeOUAa2.js +0 -1
  176. package/web-app/web-dist/assets/haml-azVoxQRV.js +0 -1
  177. package/web-app/web-dist/assets/html-D_7P5S4m.js +0 -1
  178. package/web-app/web-dist/assets/index--5yy8RbA.js +0 -267
  179. package/web-app/web-dist/assets/index-BVG4hgk7.css +0 -2
  180. package/web-app/web-dist/assets/java-D4RbCvBe.js +0 -1
  181. package/web-app/web-dist/assets/javascript-Cb010CKM.js +0 -1
  182. package/web-app/web-dist/assets/json-DWgqV4D1.js +0 -1
  183. package/web-app/web-dist/assets/jsx-CZjSJa1f.js +0 -1
  184. package/web-app/web-dist/assets/lua-TGj_6NzO.js +0 -1
  185. package/web-app/web-dist/assets/r-fCpuAR7u.js +0 -1
  186. package/web-app/web-dist/assets/regexp-B4yxx-Ty.js +0 -1
  187. package/web-app/web-dist/assets/scss-QdjMO_xV.js +0 -1
  188. package/web-app/web-dist/assets/shellscript-BnlgeVVx.js +0 -1
  189. package/web-app/web-dist/assets/sql-DGnQv6iD.js +0 -1
  190. package/web-app/web-dist/assets/tsx-MJ0-9sYG.js +0 -1
  191. package/web-app/web-dist/assets/typescript-C17ZkDe8.js +0 -1
  192. package/web-app/web-dist/assets/xml-CA9lHFQV.js +0 -1
  193. package/web-app/web-dist/assets/yaml-CwRYMJka.js +0 -1
  194. package/zuzuu/actions/adapter.mjs +0 -130
  195. package/zuzuu/commands/migrate.mjs +0 -225
  196. package/zuzuu/digest.mjs +0 -149
  197. package/zuzuu/faculty/generation.mjs +0 -392
  198. package/zuzuu/guardrails/adapter.mjs +0 -134
  199. package/zuzuu/guardrails.mjs +0 -89
  200. package/zuzuu/instructions/adapter.mjs +0 -93
  201. package/zuzuu/knowledge/adapter.mjs +0 -99
  202. package/zuzuu/memory/adapter.mjs +0 -121
  203. package/zuzuu/miners/actions.mjs +0 -118
  204. package/zuzuu/miners/guardrails.mjs +0 -179
  205. package/zuzuu/miners/instructions.mjs +0 -157
  206. package/zuzuu/miners/knowledge.mjs +0 -25
  207. package/zuzuu/miners/memory.mjs +0 -27
  208. package/zuzuu/miners/registry.mjs +0 -31
  209. /package/web-app/web-dist/assets/{chunk-QTnfLwEv.js → rolldown-runtime-QTnfLwEv.js} +0 -0
  210. /package/zuzuu/{session.mjs → core/session.mjs} +0 -0
  211. /package/zuzuu/{inject.mjs → home/inject.mjs} +0 -0
@@ -0,0 +1,283 @@
1
+ // zuzuu/faculties/actions/index.mjs — the Actions faculty module.
2
+ //
3
+ // Consolidates the adapter (the inbox gate over dir-shaped proposals, WS2-T3),
4
+ // the miner (recurring Bash 2-gram sequences → runbook proposals, WS5-T2) and
5
+ // the digest section behind the Faculty Module contract. Substrate code stays
6
+ // in zuzuu/actions/ — this module is the contract face.
7
+ //
8
+ // Actions payloads are DIRECTORIES (ACTION.md + sibling scripts), not JSON.
9
+ // Strategy (lowest-risk): the inbox stays a dir; this adapter emits/reads a
10
+ // spine-shaped proposal RECORD that REFERENCES the dir
11
+ // (payload = { slug, kind, dir:'inbox/<slug>' }). The gate resolves a single
12
+ // record via `getProposal`, lists pending via `listProposals`, and — because
13
+ // the payload is dir-shaped — archives rejections via `rejectDir` (a dir move
14
+ // into actions/proposals/archive/, not a JSON archive).
15
+
16
+ import { join } from 'node:path';
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { listActions, allActions, inboxDir, actionsDir, isSafeSlug } from '../../actions/manifest.mjs';
19
+ import { activateAction, rejectAction } from '../../actions/inbox.mjs';
20
+ import { parseEnvelope, validateEnvelope, serializeEnvelope, PAYLOAD_SCHEMAS } from '../../faculty/envelope.mjs';
21
+ import { slugify } from '../../knowledge/items.mjs';
22
+
23
+ const name = 'actions';
24
+
25
+ export const manifest = {
26
+ id: 'actions',
27
+ title: 'Actions',
28
+ tagline: 'how to DO things — runbooks + runnable scripts',
29
+ version: '1.0.0',
30
+ contract: 1,
31
+ kinds: ['runbook', 'script'],
32
+ itemsDir: '.',
33
+ schema: 'schema.json',
34
+ hooks: { miner: true, digest: true, eval: false, gate: false },
35
+ ui: { icon: 'play', accent: 'success', teaching: 'Reusable runbooks and scripts, mined from how you actually work and approved by you.' },
36
+ };
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // adapter contract
40
+ // ---------------------------------------------------------------------------
41
+
42
+ /** Build a spine-shaped proposal record for one proposed action. */
43
+ function recordFor(a) {
44
+ return {
45
+ id: a.slug,
46
+ faculty: name,
47
+ kind: 'action',
48
+ status: 'pending',
49
+ source: 'agent',
50
+ payload: { slug: a.slug, kind: a.kind, dir: `inbox/${a.slug}` },
51
+ // carry render hints alongside the payload (cheap, dir read already done)
52
+ title: a.title,
53
+ promptSnippet: a.promptSnippet,
54
+ analysis: {},
55
+ evidence: {},
56
+ provenance: [],
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Pending action proposals (dirs in .zuzuu/actions/inbox/), surfaced as
62
+ * spine-shaped records so the gate can render/approve/reject them uniformly.
63
+ */
64
+ function listProposals(agentDir) {
65
+ return listActions(inboxDir(agentDir)).map(recordFor);
66
+ }
67
+
68
+ /** Resolve a single proposed action by slug → spine-shaped record, or null. */
69
+ function getProposal(agentDir, slug) {
70
+ if (!isSafeSlug(slug)) return null;
71
+ return listProposals(agentDir).find((p) => p.id === slug) ?? null;
72
+ }
73
+
74
+ /**
75
+ * Ingest is a pass-through for Actions: proposing scaffolds a dir
76
+ * (zuzuu act propose / act-author). Kept for adapter-contract symmetry.
77
+ */
78
+ function ingest(_agentDir, raw) {
79
+ return { payload: raw?.payload ?? raw ?? {}, analysis: {} };
80
+ }
81
+
82
+ /**
83
+ * Validate a proposed action's ACTION.md envelope (id matches the dir; the
84
+ * payload validates against the actions schema). Missing ACTION.md → accept
85
+ * (slug fallback, mirrors the historical missing-manifest tolerance).
86
+ * @returns {{ok:boolean, errors:string[], warnings:string[]}}
87
+ */
88
+ export function validate(agentDir, payload) {
89
+ const slug = payload?.slug;
90
+ if (!isSafeSlug(slug)) return { ok: false, errors: [`invalid slug '${slug}'`], warnings: [] };
91
+ const manPath = join(inboxDir(agentDir), slug, 'ACTION.md');
92
+ if (!existsSync(manPath)) return { ok: true, errors: [], warnings: [] };
93
+ const { ok, item, errors: parseErrors } = parseEnvelope(readFileSync(manPath, 'utf8'));
94
+ if (!ok) return { ok: false, errors: [`ACTION.md is not a valid envelope: ${parseErrors[0]}`], warnings: [] };
95
+ if (item.id && item.id !== slug) return { ok: false, errors: [`ACTION.md id '${item.id}' ≠ dir '${slug}'`], warnings: [] };
96
+ if (item.faculty !== 'actions') return { ok: false, errors: [`ACTION.md faculty must be 'actions' (got '${item.faculty}')`], warnings: [] };
97
+ const v = validateEnvelope(item, PAYLOAD_SCHEMAS.actions);
98
+ return { ok: v.ok, errors: v.errors, warnings: [] };
99
+ }
100
+
101
+ /**
102
+ * Apply an approved action proposal: activate it (move inbox/<slug> → <slug>).
103
+ * Preserves the "already exists" guard from activateAction.
104
+ * @returns {{ok:boolean, action:string, itemIds:string[], warnings:string[]}}
105
+ */
106
+ function apply(agentDir, proposal) {
107
+ const slug = proposal?.payload?.slug ?? proposal?.id;
108
+ const r = activateAction(agentDir, slug);
109
+ if (!r.ok) return { ok: false, action: r.error, itemIds: [], warnings: [] };
110
+ return { ok: true, action: `activated ${slug}`, itemIds: [slug], warnings: [] };
111
+ }
112
+
113
+ export const applyProposal = apply;
114
+
115
+ /**
116
+ * Reject path: dir-shaped, so the gate calls this instead of the JSON archive.
117
+ * Moves inbox/<slug> → actions/proposals/archive/<slug> (archive, not delete).
118
+ */
119
+ function rejectDir(agentDir, slug, _reason = '') {
120
+ return rejectAction(agentDir, slug);
121
+ }
122
+
123
+ /**
124
+ * Render a proposed action for the human gate. `card` mirrors the current review
125
+ * card (slug ── kind, then the prompt snippet); `line` is the one-line list form.
126
+ * @returns {{line:string, card:string}}
127
+ */
128
+ function render(proposal) {
129
+ const slug = proposal?.id ?? proposal?.payload?.slug ?? '';
130
+ const kind = proposal?.payload?.kind ?? proposal?.kind ?? 'action';
131
+ const snippet = proposal?.promptSnippet ?? '';
132
+ return {
133
+ line: `${slug} [${kind}] ${snippet}`,
134
+ card: `${slug} ── ${kind}\n ${snippet}`,
135
+ };
136
+ }
137
+
138
+ export const adapter = { name, ingest, validate, apply, render, listProposals, getProposal, rejectDir };
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // miner (WS5-T2 — recurring Bash 2-grams → runbook proposals, unchanged)
142
+ // ---------------------------------------------------------------------------
143
+
144
+ // Must match the constant in knowledge/distill.mjs (adjacent Bash separator).
145
+ const SEQ_SEP = ' && ';
146
+
147
+ /**
148
+ * Derive a safe slug from a raw sequence string (bounded, safe chars only).
149
+ * e.g. "npm ci && npm test" → "npm-ci-npm-test" (max 50 chars).
150
+ */
151
+ function slugFromSequence(seq) {
152
+ const raw = slugify(seq.replace(/ && /g, ' '), 50);
153
+ // slugify already returns safe chars [a-z0-9-]; isSafeSlug allows upper too,
154
+ // but we keep lower for readability. Force-safe just in case.
155
+ return raw || 'action-sequence';
156
+ }
157
+
158
+ /**
159
+ * Aggregate recurring Bash 2-gram sequences from mined sessions.
160
+ *
161
+ * @param {Array<{sessionId:string, sequences:string[]}>} sessions
162
+ * The per-session mineTranscript output array.
163
+ * @param {object} opts
164
+ * @param {number} [opts.minSeqCount=3] min total occurrences across all sessions
165
+ * @param {number} [opts.minSeqSessions=2] min distinct sessions the sequence appears in
166
+ * @returns {Array<{payload:{slug,title,steps,promptSnippet,sequence}, evidence:{occurrences,sessions,sequence}}>}
167
+ */
168
+ export function aggregate(sessions, { minSeqCount = 3, minSeqSessions = 2 } = {}) {
169
+ // Count occurrences per sequence string, tracking distinct session ids.
170
+ const stats = new Map(); // rawSeq → { count, sessions: Set<sessionId> }
171
+ for (const s of sessions) {
172
+ if (!Array.isArray(s.sequences)) continue;
173
+ for (const seq of s.sequences) {
174
+ const st = stats.get(seq) ?? { count: 0, sessions: new Set() };
175
+ st.count++;
176
+ st.sessions.add(s.sessionId);
177
+ stats.set(seq, st);
178
+ }
179
+ }
180
+
181
+ const candidates = [];
182
+ for (const [seq, st] of stats) {
183
+ if (st.count < minSeqCount || st.sessions.size < minSeqSessions) continue;
184
+ const steps = seq.split(SEQ_SEP);
185
+ const slug = slugFromSequence(seq);
186
+ // Make sure the slug is safe; if not, skip rather than emit a bad slug.
187
+ if (!isSafeSlug(slug)) continue;
188
+ const title = `Run sequence: ${steps.join(' → ')}`;
189
+ const promptSnippet = `Runs: ${steps.join(' then ')}`;
190
+ candidates.push({
191
+ payload: { slug, title, steps, promptSnippet, sequence: seq },
192
+ evidence: { occurrences: st.count, sessions: st.sessions.size, sequence: seq },
193
+ });
194
+ }
195
+ return candidates;
196
+ }
197
+
198
+ /**
199
+ * Write a runbook action proposal into actions/inbox/<slug>/ for each candidate.
200
+ * Idempotent: skips if inbox/<slug>/ OR active actions/<slug>/ already exists.
201
+ *
202
+ * @param {string} agentDir
203
+ * @param {ReturnType<typeof aggregate>} aggregated
204
+ * @returns {number} count of new proposals written
205
+ */
206
+ export function propose(agentDir, aggregated) {
207
+ const actDir = actionsDir(agentDir);
208
+ const ibDir = inboxDir(agentDir);
209
+ let count = 0;
210
+ for (const c of aggregated) {
211
+ const { slug, title, steps, promptSnippet } = c.payload;
212
+ const inboxSlug = join(ibDir, slug);
213
+ const activeSlug = join(actDir, slug);
214
+ // Idempotent: skip if already proposed or already active.
215
+ if (existsSync(inboxSlug) || existsSync(activeSlug)) continue;
216
+
217
+ mkdirSync(inboxSlug, { recursive: true });
218
+
219
+ // ACTION.md — a runbook envelope (no run.mjs; the body IS the procedure).
220
+ // The body's first line is the digest one-liner (promptSnippet).
221
+ const stepsBlock = steps.map((cmd, i) => `${i + 1}. \`${cmd}\``).join('\n');
222
+ writeFileSync(join(inboxSlug, 'ACTION.md'), serializeEnvelope({
223
+ id: slug,
224
+ faculty: 'actions',
225
+ kind: 'runbook',
226
+ title,
227
+ status: 'active',
228
+ created_at: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
229
+ provenance: [],
230
+ payload: {},
231
+ body: `${promptSnippet}\n\nRecurring command sequence detected from session traces.\n\n## Steps\n\n${stepsBlock}`,
232
+ }));
233
+
234
+ count++;
235
+ }
236
+ return count;
237
+ }
238
+
239
+ export const miner = { faculty: name, aggregate, propose };
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // digest section (moved from the pre-module digest, byte-identical output)
243
+ // ---------------------------------------------------------------------------
244
+
245
+ /**
246
+ * The Actions digest section: available actions under the shared char budget.
247
+ * Renders NOTHING when no actions exist (preserved pre-module behaviour).
248
+ * @param {string} agentDir
249
+ * @param {{limit:number, charBudget:number, priorLines:string[]}} ctx
250
+ * @returns {{lines: string[], data: object}}
251
+ */
252
+ export function digestSection(agentDir, { limit, charBudget, priorLines }) {
253
+ let actions;
254
+ try {
255
+ const list = allActions(agentDir);
256
+ actions = { count: list.length, shown: list.slice(0, limit).map((a) => ({ slug: a.slug, kind: a.kind, promptSnippet: a.promptSnippet })) };
257
+ } catch {
258
+ actions = { count: 0, shown: [] };
259
+ }
260
+ if (!actions.count) return { lines: [], data: { ...actions, renderedCount: 0 } };
261
+ const lines = ['## Actions'];
262
+ lines.push(`${actions.count} available; run with \`zuzuu act <slug>\`:`);
263
+ let shownA = 0;
264
+ for (const a of actions.shown) {
265
+ const line = `- ${a.slug} · ${a.promptSnippet}`;
266
+ if ([...priorLines, ...lines].join('\n').length + line.length > charBudget && shownA > 0) break;
267
+ lines.push(line);
268
+ shownA++;
269
+ }
270
+ const droppedA = actions.count - shownA;
271
+ if (droppedA > 0) lines.push(`- … (${droppedA} more — \`zuzuu act list\`)`);
272
+ // mirror the Knowledge contract: shown reflects what actually rendered
273
+ return { lines, data: { ...actions, shown: actions.shown.slice(0, shownA), renderedCount: shownA } };
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // session signals (the observability surface — `zuzuu session inspect`)
278
+ // ---------------------------------------------------------------------------
279
+
280
+ /** Counts of the mined-signal superset slices this faculty grows from. */
281
+ export function sessionSignals(signals = {}) {
282
+ return { sequences: signals.sequences?.length ?? 0 };
283
+ }
@@ -0,0 +1,320 @@
1
+ // zuzuu/faculties/guardrails/index.mjs — the Guardrails faculty module.
2
+ //
3
+ // Consolidates the adapter (rule proposals → envelope items), the miner
4
+ // (repeated destructive-command failures → ask-only rules, WS5-T3), the digest
5
+ // section and the GATE hook (the only faculty with one — the enforced
6
+ // PreToolUse engine) behind the Faculty Module contract. The rule engine
7
+ // itself stays in zuzuu/guardrails/engine.mjs (the gate's hot path imports it
8
+ // directly too); this module is the contract face.
9
+ //
10
+ // A guardrails proposal payload is a single rule record:
11
+ // { id, action: deny|ask|allow, tool, pattern, reason, body? }
12
+ //
13
+ // MANDATORY MINER SAFETY PROPERTIES (enforced in aggregate):
14
+ // 1. action is ALWAYS 'ask' — never 'deny'. Auto-proposed rules only escalate
15
+ // to the human prompt, they never hard-block.
16
+ // 2. Patterns are LITERAL-ESCAPED from the observed command — never a broad/
17
+ // free regex. escapeRegex() handles this.
18
+ // 3. Cross-session corroboration required — a destructive command must fail
19
+ // ≥minFailures (default 3) times across ≥minSessions (default 2) DISTINCT
20
+ // sessions. A single session — no matter how many failures — produces
21
+ // NOTHING.
22
+
23
+ import { join } from 'node:path';
24
+ import { writeFacultyItem } from '../../faculty/items.mjs';
25
+ import { deriveTitle } from '../../faculty/envelope.mjs';
26
+ import { makeProposal, writeProposal, listProposals, isArchivedResolved } from '../../faculty/proposal.mjs';
27
+ import { loadRules, evaluate } from '../../guardrails/engine.mjs';
28
+ import { slugify } from '../../knowledge/items.mjs';
29
+
30
+ const name = 'guardrails';
31
+
32
+ export const manifest = {
33
+ id: 'guardrails',
34
+ title: 'Guardrails',
35
+ tagline: 'what NOT to do — enforced rules on every tool call',
36
+ version: '1.0.0',
37
+ contract: 1,
38
+ kinds: ['rule'],
39
+ itemsDir: 'items',
40
+ schema: 'schema.json',
41
+ hooks: { miner: true, digest: true, eval: false, gate: true },
42
+ ui: { icon: 'shield', accent: 'danger', teaching: 'Hard rules enforced by the zuzuu gate on every tool call — a refusal here is policy, not preference.' },
43
+ };
44
+
45
+ const VALID_ACTIONS = new Set(['deny', 'ask', 'allow']);
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // adapter contract
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * Ingest a raw rule object. Pass-through: rule fields are the payload.
53
+ * @param {string} agentDir
54
+ * @param {object} raw — expected shape: { id, action, tool, pattern, reason }
55
+ * or { payload: { ... } } from the spine
56
+ */
57
+ function ingest(_agentDir, raw) {
58
+ const payload = raw?.payload ?? raw ?? {};
59
+ return { payload, analysis: {}, dedupeKey: payload.id };
60
+ }
61
+
62
+ /**
63
+ * Validate a rule payload.
64
+ * @returns {{ok:boolean, errors:string[], warnings:string[]}}
65
+ */
66
+ export function validate(_agentDir, payload) {
67
+ const errors = [];
68
+ if (!payload?.id || typeof payload.id !== 'string' || !payload.id.trim()) {
69
+ errors.push('rule id is required (non-empty string slug)');
70
+ }
71
+ if (!VALID_ACTIONS.has(payload?.action)) {
72
+ errors.push(`action must be one of deny|ask|allow (got '${payload?.action}')`);
73
+ }
74
+ if (!payload?.tool || typeof payload.tool !== 'string') {
75
+ errors.push('tool is required (exact tool name or \'*\')');
76
+ }
77
+ if (typeof payload?.pattern !== 'string' || !payload.pattern) {
78
+ errors.push('pattern is required (a non-empty regex string)');
79
+ } else {
80
+ try {
81
+ new RegExp(payload.pattern); // eslint-disable-line no-new
82
+ } catch (e) {
83
+ errors.push(`pattern does not compile as a RegExp: ${e.message}`);
84
+ }
85
+ }
86
+ if (!payload?.reason || !String(payload.reason).trim()) {
87
+ errors.push('reason is required (non-empty)');
88
+ }
89
+ return { ok: errors.length === 0, errors, warnings: [] };
90
+ }
91
+
92
+ /**
93
+ * Apply an approved rule proposal: write the envelope item (upsert by id).
94
+ * @returns {{ok:boolean, action:string, itemIds:string[]}}
95
+ */
96
+ function apply(agentDir, proposal) {
97
+ const rule = proposal?.payload ?? {};
98
+ const id = rule.id;
99
+ writeFacultyItem(agentDir, {
100
+ id,
101
+ faculty: name,
102
+ kind: 'rule',
103
+ title: deriveTitle(rule.reason, id),
104
+ status: 'active',
105
+ created_at: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
106
+ provenance: Array.isArray(proposal?.provenance) ? proposal.provenance : [],
107
+ payload: { action: rule.action, tool: rule.tool || '*', pattern: rule.pattern, reason: rule.reason },
108
+ body: rule.body ?? '',
109
+ });
110
+ return { ok: true, action: `added rule ${id}`, itemIds: [id] };
111
+ }
112
+
113
+ export const applyProposal = apply;
114
+
115
+ /**
116
+ * Render a rule proposal for the human gate.
117
+ * @returns {{line:string, card:string}}
118
+ */
119
+ function render(proposal) {
120
+ const r = proposal?.payload ?? {};
121
+ const summary = `${r.action ?? '?'} ${r.tool ?? '*'} /${r.pattern ?? ''}/ — ${r.reason ?? ''}`;
122
+ return {
123
+ line: `${r.id ?? ''} [rule] ${summary}`,
124
+ card: summary,
125
+ };
126
+ }
127
+
128
+ export const adapter = { name, ingest, validate, apply, render };
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // gate hook — the ONLY faculty with one; same fail-open law as the engine
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /**
135
+ * Evaluate one tool call against this home's rule items.
136
+ * @param {string} agentDir
137
+ * @param {{tool:string, input:any}} toolCall
138
+ * @returns {null | {action:'deny'|'ask'|'allow', rule:string, reason:string}}
139
+ * null = no rule matched / engine trouble → host's normal flow (fail-open)
140
+ */
141
+ export function gate(agentDir, toolCall) {
142
+ try {
143
+ const loaded = loadRules(join(agentDir, 'guardrails'));
144
+ if (!loaded.ok) return null;
145
+ return evaluate(loaded.rules, toolCall);
146
+ } catch {
147
+ return null; // fail open
148
+ }
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // miner (WS5-T3 — destructive-failure clusters → ask-only rules, unchanged)
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /**
156
+ * Escape all regex metacharacters in `s` so that `new RegExp(escapeRegex(s))`
157
+ * matches exactly the string `s` and nothing broader.
158
+ *
159
+ * @param {string} s
160
+ * @returns {string}
161
+ */
162
+ export function escapeRegex(s) {
163
+ // Standard set of regex metacharacters that need escaping.
164
+ return String(s).replace(/[.*+?^${}()|[\]\\\/\-]/g, '\\$&');
165
+ }
166
+
167
+ /** Normalise a command string (trim + collapse whitespace). */
168
+ const norm = (cmd) => String(cmd).trim().replace(/\s+/g, ' ').slice(0, 200);
169
+
170
+ /** Derive a guardrails-miner id for a command. */
171
+ function guardId(cmd) {
172
+ return 'guard-' + slugify(cmd, 50);
173
+ }
174
+
175
+ /** Ids of live rule items (guardrails/items/*.md); absent/unreadable → none. */
176
+ function liveRuleIds(agentDir) {
177
+ try {
178
+ return new Set(loadRules(join(agentDir, 'guardrails')).rules.map((r) => r.id));
179
+ } catch {
180
+ return new Set();
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Group destructiveFailures by normalised command; emit a candidate ONLY when
186
+ * both the occurrence count and distinct-session count meet their thresholds.
187
+ *
188
+ * SAFETY: a single-session cluster, no matter how large, produces NOTHING.
189
+ *
190
+ * @param {Array<{sessionId:string, destructiveFailures:{cmd:string,tool:string}[]}>} sessions
191
+ * @param {object} opts
192
+ * @param {number} [opts.minFailures=3] min total failures across all sessions
193
+ * @param {number} [opts.minSessions=2] min distinct sessions with ≥1 failure each
194
+ * @returns {Array<{payload:{id,action,tool,pattern,reason}, evidence:{occurrences,sessions}}>}
195
+ */
196
+ export function aggregate(sessions, { minFailures = 3, minSessions = 2 } = {}) {
197
+ // cmd (normalized) → { count: number, sessions: Set<sessionId>, tool: string }
198
+ const stats = new Map();
199
+
200
+ for (const s of sessions) {
201
+ if (!Array.isArray(s.destructiveFailures)) continue;
202
+ for (const { cmd, tool } of s.destructiveFailures) {
203
+ const key = norm(cmd);
204
+ const st = stats.get(key) ?? { count: 0, sessions: new Set(), tool: tool ?? 'Bash' };
205
+ st.count++;
206
+ st.sessions.add(s.sessionId);
207
+ // Keep first observed tool name (they should all be 'Bash' for destructive cmds).
208
+ stats.set(key, st);
209
+ }
210
+ }
211
+
212
+ const candidates = [];
213
+ for (const [cmd, st] of stats) {
214
+ // SAFETY: enforce BOTH thresholds — cross-session gate is the key one.
215
+ if (st.count < minFailures) continue;
216
+ if (st.sessions.size < minSessions) continue; // ← single-session always rejected here
217
+
218
+ const id = guardId(cmd);
219
+ const pattern = escapeRegex(cmd);
220
+ const tool = st.tool ?? 'Bash';
221
+
222
+ candidates.push({
223
+ payload: {
224
+ id,
225
+ // SAFETY: ALWAYS 'ask', never 'deny'.
226
+ action: 'ask',
227
+ tool,
228
+ pattern,
229
+ reason: `auto-proposed: '${cmd}' failed repeatedly across sessions — confirm before running`,
230
+ },
231
+ evidence: {
232
+ occurrences: st.count,
233
+ sessions: st.sessions.size,
234
+ },
235
+ });
236
+ }
237
+
238
+ return candidates;
239
+ }
240
+
241
+ /**
242
+ * Write a guardrails proposal into .zuzuu/guardrails/proposals/ for each candidate.
243
+ * Idempotent:
244
+ * - skips if a guardrails proposal with the same payload.id already exists
245
+ * - skips if a live rule item (guardrails/items/<id>.md) already exists
246
+ * - skips if the id is already resolved in proposals/archive/ — a rejection
247
+ * is remembered; re-distilling never resurrects it
248
+ *
249
+ * The proposals flow through `zuzuu review` → guardrails adapter on approval.
250
+ *
251
+ * @param {string} agentDir
252
+ * @param {ReturnType<typeof aggregate>} aggregated
253
+ * @returns {number} count of new proposals written
254
+ */
255
+ export function propose(agentDir, aggregated) {
256
+ // Load existing proposals (ids already pending).
257
+ const existing = listProposals(agentDir, 'guardrails');
258
+ const existingIds = new Set(existing.map((p) => p.payload?.id).filter(Boolean));
259
+
260
+ // Live rule items (ids already applied).
261
+ const rulesIds = liveRuleIds(agentDir);
262
+
263
+ let count = 0;
264
+ for (const c of aggregated) {
265
+ const { payload, evidence } = c;
266
+
267
+ // Idempotent: skip if already proposed or already a live rule.
268
+ if (existingIds.has(payload.id)) continue;
269
+ if (rulesIds.has(payload.id)) continue;
270
+
271
+ const proposal = makeProposal({
272
+ faculty: 'guardrails',
273
+ kind: 'rule',
274
+ source: 'distill',
275
+ payload,
276
+ evidence,
277
+ });
278
+
279
+ // A rejection is remembered: never resurrect an archive-resolved id.
280
+ if (isArchivedResolved(agentDir, 'guardrails', proposal.id)) continue;
281
+
282
+ writeProposal(agentDir, proposal);
283
+ count++;
284
+ }
285
+
286
+ return count;
287
+ }
288
+
289
+ export const miner = { faculty: name, aggregate, propose };
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // digest section (moved from the pre-module digest, byte-identical output)
293
+ // ---------------------------------------------------------------------------
294
+
295
+ /**
296
+ * The Guardrails digest section: rule count + the enforcement reminder.
297
+ * @param {string} agentDir
298
+ * @returns {{lines: string[], data: object}}
299
+ */
300
+ export function digestSection(agentDir) {
301
+ let guardrails;
302
+ try {
303
+ const loaded = loadRules(join(agentDir, 'guardrails'));
304
+ guardrails = { ok: loaded.ok, count: loaded.ok ? loaded.rules.length : 0, skipped: loaded.skipped?.length ?? 0 };
305
+ } catch {
306
+ guardrails = { ok: false, count: 0, skipped: 0 };
307
+ }
308
+ const lines = ['## Guardrails'];
309
+ lines.push(guardrails.count ? `${guardrails.count} rule(s) — the enforced gate is on; refusals are policy.` : 'no rules configured.');
310
+ return { lines, data: guardrails };
311
+ }
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // session signals (the observability surface — `zuzuu session inspect`)
315
+ // ---------------------------------------------------------------------------
316
+
317
+ /** Counts of the mined-signal superset slices this faculty grows from. */
318
+ export function sessionSignals(signals = {}) {
319
+ return { destructiveFailures: signals.destructiveFailures?.length ?? 0 };
320
+ }