@zuzuucodes/cli 1.5.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 (198) hide show
  1. package/bin/zuzuu.mjs +12 -3
  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} +46 -116
  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-Dx3ub8Pk.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-DM6oHc7c.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-DflaUwqW.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-wbBeb0oN.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-DRwkDcoK.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/trail.mjs +1 -1
  110. package/zuzuu/commands/act.mjs +1 -1
  111. package/zuzuu/commands/capture.mjs +2 -2
  112. package/zuzuu/commands/code.mjs +2 -2
  113. package/zuzuu/commands/digest.mjs +2 -2
  114. package/zuzuu/commands/distill.mjs +15 -16
  115. package/zuzuu/commands/doctor.mjs +39 -4
  116. package/zuzuu/commands/enable.mjs +1 -1
  117. package/zuzuu/commands/eval.mjs +3 -36
  118. package/zuzuu/commands/faculty.mjs +102 -19
  119. package/zuzuu/commands/generation.mjs +3 -4
  120. package/zuzuu/commands/hook.mjs +7 -7
  121. package/zuzuu/commands/inbox.mjs +1 -6
  122. package/zuzuu/commands/init.mjs +5 -4
  123. package/zuzuu/commands/knowledge.mjs +1 -1
  124. package/zuzuu/commands/migrations/home.mjs +96 -0
  125. package/zuzuu/commands/migrations/index.mjs +48 -0
  126. package/zuzuu/commands/{migrate.mjs → migrations/items.mjs} +34 -246
  127. package/zuzuu/commands/migrations/proposals.mjs +100 -0
  128. package/zuzuu/commands/proposals.mjs +131 -0
  129. package/zuzuu/commands/review.mjs +13 -227
  130. package/zuzuu/commands/session.mjs +8 -2
  131. package/zuzuu/commands/sessions.mjs +159 -0
  132. package/zuzuu/commands/status.mjs +3 -3
  133. package/zuzuu/commands/trace.mjs +1 -1
  134. package/zuzuu/{capture-core.mjs → core/capture-core.mjs} +3 -3
  135. package/zuzuu/{store.mjs → core/store.mjs} +1 -1
  136. package/zuzuu/digest/compose.mjs +96 -0
  137. package/zuzuu/eval/score.mjs +14 -1
  138. package/zuzuu/faculties/actions/index.mjs +283 -0
  139. package/zuzuu/faculties/guardrails/index.mjs +320 -0
  140. package/zuzuu/faculties/instructions/index.mjs +288 -0
  141. package/zuzuu/faculties/knowledge/index.mjs +185 -0
  142. package/zuzuu/{memory/adapter.mjs → faculties/memory/index.mjs} +37 -9
  143. package/zuzuu/faculty/generation/read.mjs +206 -0
  144. package/zuzuu/faculty/generation/write.mjs +207 -0
  145. package/zuzuu/faculty/items.mjs +11 -5
  146. package/zuzuu/faculty/module.mjs +74 -0
  147. package/zuzuu/faculty/pending.mjs +63 -0
  148. package/zuzuu/faculty/registry.mjs +204 -18
  149. package/zuzuu/faculty/render.mjs +59 -0
  150. package/zuzuu/faculty/trail.mjs +1 -1
  151. package/zuzuu/{guardrails.mjs → guardrails/engine.mjs} +1 -1
  152. package/zuzuu/{scaffold.mjs → home/scaffold.mjs} +12 -2
  153. package/zuzuu/live/live-store.mjs +2 -2
  154. package/zuzuu/live/reconcile.mjs +2 -2
  155. package/zuzuu/sessions/git.mjs +47 -0
  156. package/zuzuu/{session-git.mjs → sessions/session-git.mjs} +5 -43
  157. package/web-app/web-dist/assets/DiffTab-BpGp1akx.js +0 -1
  158. package/web-app/web-dist/assets/MonacoFile-CqbVacUZ.js +0 -1
  159. package/web-app/web-dist/assets/angular-html-CmT26mqM.js +0 -1
  160. package/web-app/web-dist/assets/c-BvoqrSVH.js +0 -1
  161. package/web-app/web-dist/assets/cpp-BXsk94m0.js +0 -1
  162. package/web-app/web-dist/assets/css-Z8oOGxII.js +0 -1
  163. package/web-app/web-dist/assets/dist-C6R6xoyX.js +0 -153
  164. package/web-app/web-dist/assets/glsl-KwyfU2aa.js +0 -1
  165. package/web-app/web-dist/assets/graphql-DSeOUAa2.js +0 -1
  166. package/web-app/web-dist/assets/haml-azVoxQRV.js +0 -1
  167. package/web-app/web-dist/assets/html-D_7P5S4m.js +0 -1
  168. package/web-app/web-dist/assets/index-DHpC851f.js +0 -268
  169. package/web-app/web-dist/assets/index-O-t1gyMG.css +0 -2
  170. package/web-app/web-dist/assets/java-D4RbCvBe.js +0 -1
  171. package/web-app/web-dist/assets/javascript-Cb010CKM.js +0 -1
  172. package/web-app/web-dist/assets/json-DWgqV4D1.js +0 -1
  173. package/web-app/web-dist/assets/jsx-CZjSJa1f.js +0 -1
  174. package/web-app/web-dist/assets/lua-TGj_6NzO.js +0 -1
  175. package/web-app/web-dist/assets/r-fCpuAR7u.js +0 -1
  176. package/web-app/web-dist/assets/regexp-B4yxx-Ty.js +0 -1
  177. package/web-app/web-dist/assets/scss-QdjMO_xV.js +0 -1
  178. package/web-app/web-dist/assets/shellscript-BnlgeVVx.js +0 -1
  179. package/web-app/web-dist/assets/sql-DGnQv6iD.js +0 -1
  180. package/web-app/web-dist/assets/tsx-MJ0-9sYG.js +0 -1
  181. package/web-app/web-dist/assets/typescript-C17ZkDe8.js +0 -1
  182. package/web-app/web-dist/assets/xml-CA9lHFQV.js +0 -1
  183. package/web-app/web-dist/assets/yaml-CwRYMJka.js +0 -1
  184. package/zuzuu/actions/adapter.mjs +0 -122
  185. package/zuzuu/digest.mjs +0 -154
  186. package/zuzuu/faculty/generation.mjs +0 -398
  187. package/zuzuu/guardrails/adapter.mjs +0 -103
  188. package/zuzuu/instructions/adapter.mjs +0 -93
  189. package/zuzuu/knowledge/adapter.mjs +0 -99
  190. package/zuzuu/miners/actions.mjs +0 -112
  191. package/zuzuu/miners/guardrails.mjs +0 -176
  192. package/zuzuu/miners/instructions.mjs +0 -157
  193. package/zuzuu/miners/knowledge.mjs +0 -25
  194. package/zuzuu/miners/memory.mjs +0 -27
  195. package/zuzuu/miners/registry.mjs +0 -31
  196. /package/web-app/web-dist/assets/{chunk-QTnfLwEv.js → rolldown-runtime-QTnfLwEv.js} +0 -0
  197. /package/zuzuu/{session.mjs → core/session.mjs} +0 -0
  198. /package/zuzuu/{inject.mjs → home/inject.mjs} +0 -0
@@ -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
+ }
@@ -0,0 +1,288 @@
1
+ // zuzuu/faculties/instructions/index.mjs — the Instructions faculty module.
2
+ //
3
+ // Consolidates the adapter (steering-amendment proposals → envelope items),
4
+ // the miner (recurring corrective user turns → amendment proposals, WS5-T4)
5
+ // and the digest section (the steering text the agent grounds on) behind the
6
+ // Faculty Module contract.
7
+ //
8
+ // An instructions proposal payload is a steering amendment:
9
+ // { id?, text } — a line or paragraph of steering
10
+ //
11
+ // apply: writes the amendment as a Faculty Standard envelope item under
12
+ // .zuzuu/instructions/items/<id>.md (kind: amendment; body = the text).
13
+ // The pinned steering itself lives at items/steering.md; future
14
+ // amendments are MORE items, never edits to steering. Idempotent: a
15
+ // text already present in any instructions item is not duplicated.
16
+
17
+ import { listFacultyItems, writeFacultyItem } from '../../faculty/items.mjs';
18
+ import { deriveTitle } from '../../faculty/envelope.mjs';
19
+ import { makeProposal, writeProposal, listProposals, isArchivedResolved } from '../../faculty/proposal.mjs';
20
+ import { slugify } from '../../knowledge/items.mjs';
21
+
22
+ const name = 'instructions';
23
+
24
+ export const manifest = {
25
+ id: 'instructions',
26
+ title: 'Instructions',
27
+ tagline: 'who to BE — steering and project conventions',
28
+ version: '1.0.0',
29
+ contract: 1,
30
+ kinds: ['steering', 'amendment'],
31
+ itemsDir: 'items',
32
+ schema: 'schema.json',
33
+ hooks: { miner: true, digest: true, eval: false, gate: false },
34
+ ui: { icon: 'compass', accent: 'warning', teaching: 'The pinned steering the agent is told at session start; corrections graduate into amendments here.' },
35
+ };
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // adapter contract
39
+ // ---------------------------------------------------------------------------
40
+
41
+ /**
42
+ * Ingest a raw amendment object. Pass-through: the payload IS the amendment.
43
+ */
44
+ function ingest(_agentDir, raw) {
45
+ const payload = raw?.payload ?? raw ?? {};
46
+ return { payload, analysis: {} };
47
+ }
48
+
49
+ /**
50
+ * Validate an amendment payload.
51
+ * @returns {{ok:boolean, errors:string[], warnings:string[]}}
52
+ */
53
+ export function validate(_agentDir, payload) {
54
+ const errors = [];
55
+ if (!payload?.text || !String(payload.text).trim()) {
56
+ errors.push('text is required (non-empty steering amendment)');
57
+ }
58
+ return { ok: errors.length === 0, errors, warnings: [] };
59
+ }
60
+
61
+ /**
62
+ * Apply an approved amendment: write an amendment item (idempotent on
63
+ * identical text — won't duplicate steering already present in any item).
64
+ * @returns {{ok:boolean, action:string, itemIds:string[]}}
65
+ */
66
+ function apply(agentDir, proposal) {
67
+ const text = String(proposal?.payload?.text ?? '').trim();
68
+
69
+ // Idempotence: skip if the exact text already lives in an instructions item
70
+ const { items } = listFacultyItems(agentDir, 'instructions');
71
+ if (items.some((i) => String(i.body ?? '').includes(text))) {
72
+ return { ok: true, action: 'amended instructions (already present)', itemIds: [] };
73
+ }
74
+
75
+ const id = proposal?.payload?.id || slugify(text, 50);
76
+ writeFacultyItem(agentDir, {
77
+ id,
78
+ faculty: name,
79
+ kind: 'amendment',
80
+ title: deriveTitle(text, id),
81
+ status: 'active',
82
+ created_at: new Date().toISOString().replace(/\.\d+Z$/, 'Z'),
83
+ provenance: Array.isArray(proposal?.provenance) ? proposal.provenance : [],
84
+ payload: { scope: 'project' },
85
+ body: text,
86
+ });
87
+
88
+ return { ok: true, action: 'amended instructions', itemIds: [id] };
89
+ }
90
+
91
+ export const applyProposal = apply;
92
+
93
+ /**
94
+ * Render an amendment proposal for the human gate.
95
+ * @returns {{line:string, card:string}}
96
+ */
97
+ function render(proposal) {
98
+ const text = proposal?.payload?.text ?? '';
99
+ const preview = text.slice(0, 80).replace(/\n/g, ' ');
100
+ return {
101
+ line: `[amendment] ${preview}`,
102
+ card: text,
103
+ };
104
+ }
105
+
106
+ export const adapter = { name, ingest, validate, apply, render };
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // miner (WS5-T4 — recurring corrective turns → amendment proposals, unchanged)
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /**
113
+ * Normalise a correction text for grouping:
114
+ * lowercase, collapse whitespace, truncate to 200 chars.
115
+ *
116
+ * v1 grouping: near-identical normalised text (exact key match). Simple and
117
+ * deterministic; a fuzzy grouper can be earned later.
118
+ *
119
+ * @param {string} text
120
+ * @returns {string}
121
+ */
122
+ function normText(text) {
123
+ return String(text).toLowerCase().replace(/\s+/g, ' ').trim().slice(0, 200);
124
+ }
125
+
126
+ /**
127
+ * Derive a proposal id fragment from a normalised text key.
128
+ * Keep it stable, short, and filesystem-safe.
129
+ */
130
+ function instrId(normKey) {
131
+ // Use a slugified version of the first 60 chars of the normalised text,
132
+ // prefixed to make collisions with other faculties impossible.
133
+ const slug = normKey.slice(0, 60).replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 50) || 'instr';
134
+ return 'instr-' + slug;
135
+ }
136
+
137
+ /**
138
+ * Group correctionTurns from mined sessions; propose when a similar correction
139
+ * recurs across ≥minSessions (default 2) distinct sessions.
140
+ *
141
+ * @param {Array<{sessionId:string, correctionTurns:{text:string}[]}>} sessions
142
+ * @param {object} opts
143
+ * @param {number} [opts.minSessions=2] min distinct sessions with the same normalised correction
144
+ * @returns {Array<{payload:{text:string}, evidence:{occurrences:number, sessions:number}}>}
145
+ */
146
+ export function aggregate(sessions, { minSessions = 2 } = {}) {
147
+ // normalised text → { count, sessions: Set<sessionId>, rawText: string }
148
+ const stats = new Map();
149
+
150
+ for (const s of sessions) {
151
+ if (!Array.isArray(s.correctionTurns)) continue;
152
+ // Track distinct normalised texts per session to avoid double-counting
153
+ // the same session for the same correction text.
154
+ const seenInSession = new Set();
155
+ for (const { text } of s.correctionTurns) {
156
+ const key = normText(text);
157
+ if (!key) continue;
158
+ const st = stats.get(key) ?? { count: 0, sessions: new Set(), rawText: text };
159
+ st.count++;
160
+ st.sessions.add(s.sessionId);
161
+ seenInSession.add(key);
162
+ stats.set(key, st);
163
+ }
164
+ }
165
+
166
+ const candidates = [];
167
+ for (const [, st] of stats) {
168
+ if (st.sessions.size < minSessions) continue;
169
+
170
+ // Phrase the raw correction as an instruction for the steering amendment.
171
+ // The corrective turn text already reads like user guidance; use it directly
172
+ // (trimmed to 500 chars to match mineTranscript's cap).
173
+ const amendmentText = st.rawText.slice(0, 500).trim();
174
+ const id = instrId(normText(amendmentText));
175
+
176
+ candidates.push({
177
+ payload: { id, text: amendmentText },
178
+ evidence: { occurrences: st.count, sessions: st.sessions.size },
179
+ });
180
+ }
181
+
182
+ return candidates;
183
+ }
184
+
185
+ /**
186
+ * Write an instructions proposal into .zuzuu/instructions/proposals/ for each
187
+ * candidate.
188
+ *
189
+ * Idempotent:
190
+ * - skips if an instructions proposal with the same derived id already exists
191
+ * - skips if the text is already present in an instructions item (steering
192
+ * or a prior amendment)
193
+ * - skips if the id is already resolved in proposals/archive/ — a rejection
194
+ * is remembered; re-distilling never resurrects it
195
+ *
196
+ * @param {string} agentDir
197
+ * @param {ReturnType<typeof aggregate>} aggregated
198
+ * @returns {number} count of new proposals written
199
+ */
200
+ export function propose(agentDir, aggregated) {
201
+ // Collect ids of existing pending proposals for this faculty.
202
+ const existing = listProposals(agentDir, 'instructions');
203
+ const existingIds = new Set(existing.map((p) => p.payload?.id).filter(Boolean));
204
+
205
+ // Read the instructions items (steering + amendments) to skip applied text.
206
+ let appliedText = '';
207
+ try {
208
+ appliedText = listFacultyItems(agentDir, 'instructions').items.map((i) => i.body ?? '').join('\n');
209
+ } catch { appliedText = ''; }
210
+
211
+ let count = 0;
212
+ for (const c of aggregated) {
213
+ const { payload, evidence } = c;
214
+
215
+ // Idempotent: skip if already proposed.
216
+ if (existingIds.has(payload.id)) continue;
217
+
218
+ // Idempotent: skip if text already present in an instructions item.
219
+ if (appliedText.includes(payload.text)) continue;
220
+
221
+ const proposal = makeProposal({
222
+ faculty: 'instructions',
223
+ kind: 'block',
224
+ source: 'distill',
225
+ payload,
226
+ evidence,
227
+ });
228
+
229
+ // A rejection is remembered: never resurrect an archive-resolved id.
230
+ if (isArchivedResolved(agentDir, 'instructions', proposal.id)) continue;
231
+
232
+ writeProposal(agentDir, proposal);
233
+ count++;
234
+ }
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
+ const PLACEHOLDER_MARK = '<!-- Fill in:';
246
+
247
+ const INTERVIEW = [
248
+ 'Project steering is empty. Before substantive work, interview your human',
249
+ '(what is this project, its conventions, its priorities), draft the steering item',
250
+ '.zuzuu/instructions/items/steering.md from their answers, and get their approval.',
251
+ ].join(' ');
252
+
253
+ /** Read the instructions items (steering first, then amendments); classify
254
+ * empty vs steering text. Items are Faculty Standard envelopes (W24). */
255
+ function readInstructions(agentDir) {
256
+ let items = [];
257
+ try {
258
+ items = listFacultyItems(agentDir, 'instructions').items;
259
+ } catch { /* missing or unreadable → treat as empty */ }
260
+ // steering pins the top; amendments follow in id order (already sorted)
261
+ items.sort((a, b) => (a.kind === 'steering' ? -1 : 1) - (b.kind === 'steering' ? -1 : 1));
262
+ const bodies = items
263
+ .map((i) => String(i.body ?? ''))
264
+ .map((raw) => (raw.includes(PLACEHOLDER_MARK) ? '' : raw.replace(/^#.*$/gm, '').trim() && raw.trim()))
265
+ .filter(Boolean);
266
+ const text = bodies.join('\n\n');
267
+ return { empty: !text, text };
268
+ }
269
+
270
+ /**
271
+ * The Instructions digest section: the pinned steering (or the interview
272
+ * prompt when empty). Always renders — grounding starts here.
273
+ * @param {string} agentDir
274
+ * @returns {{lines: string[], data: object}}
275
+ */
276
+ export function digestSection(agentDir) {
277
+ const instr = readInstructions(agentDir);
278
+ return { lines: ['## Instructions', instr.empty ? INTERVIEW : instr.text], data: instr };
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------
282
+ // session signals (the observability surface — `zuzuu session inspect`)
283
+ // ---------------------------------------------------------------------------
284
+
285
+ /** Counts of the mined-signal superset slices this faculty grows from. */
286
+ export function sessionSignals(signals = {}) {
287
+ return { correctionTurns: signals.correctionTurns?.length ?? 0 };
288
+ }