@cratis/pi 0.0.1 → 2.0.2

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 (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -37
  3. package/package/corpus/agents/backend-developer.md +125 -0
  4. package/package/corpus/agents/code-reviewer.md +165 -0
  5. package/package/corpus/agents/coordinator.md +163 -0
  6. package/package/corpus/agents/frontend-developer.md +246 -0
  7. package/package/corpus/agents/orchestrator.md +196 -0
  8. package/package/corpus/agents/performance-reviewer.md +109 -0
  9. package/package/corpus/agents/planner.md +145 -0
  10. package/package/corpus/agents/repository-investigation-reviewer.md +45 -0
  11. package/package/corpus/agents/repository-investigator.md +50 -0
  12. package/package/corpus/agents/security-reviewer.md +118 -0
  13. package/package/corpus/agents/slice-implementer.md +59 -0
  14. package/package/corpus/agents/spec-writer.md +149 -0
  15. package/package/corpus/harnesses/pi/extensions/cratis-hooks/index.ts +213 -0
  16. package/package/corpus/harnesses/pi/extensions/cratis-rules/index.ts +27 -0
  17. package/package/corpus/harnesses/pi/extensions/package.json +4 -0
  18. package/package/corpus/harnesses/pi/extensions/subagent/agents.ts +167 -0
  19. package/package/corpus/harnesses/pi/extensions/subagent/index.ts +352 -0
  20. package/package/corpus/hooks/README.md +434 -0
  21. package/package/corpus/hooks/agent-stop.md +49 -0
  22. package/package/corpus/hooks/pre-commit.md +47 -0
  23. package/package/corpus/hooks/scripts/cratis-guard-writes.sh +87 -0
  24. package/package/corpus/hooks/scripts/cratis-nuget-pins.txt +11 -0
  25. package/package/corpus/hooks/scripts/cratis-pattern-scan.sh +197 -0
  26. package/package/corpus/hooks/scripts/cratis-patterns.json +84 -0
  27. package/package/corpus/hooks/scripts/cratis-quality-gate.sh +219 -0
  28. package/package/corpus/hooks/scripts/hook-lib.sh +152 -0
  29. package/package/corpus/hooks/scripts/quality-gates.json +261 -0
  30. package/package/corpus/hooks/scripts/type-references-allowlist.txt +71 -0
  31. package/package/corpus/hooks/scripts/validate-package-imports.sh +166 -0
  32. package/package/corpus/hooks/scripts/validate-package-subpaths.sh +120 -0
  33. package/package/corpus/hooks/scripts/validate-type-references.sh +308 -0
  34. package/package/corpus/hooks/settings.template.json +40 -0
  35. package/package/corpus/prompts/add-business-rule.prompt.md +22 -0
  36. package/package/corpus/prompts/add-concept.prompt.md +17 -0
  37. package/package/corpus/prompts/add-ef-migration.prompt.md +24 -0
  38. package/package/corpus/prompts/add-projection.prompt.md +20 -0
  39. package/package/corpus/prompts/add-reactor.prompt.md +22 -0
  40. package/package/corpus/prompts/add-reducer.prompt.md +20 -0
  41. package/package/corpus/prompts/audit-hooks.prompt.md +15 -0
  42. package/package/corpus/prompts/check-doc-drift.prompt.md +21 -0
  43. package/package/corpus/prompts/code-review.prompt.md +9 -0
  44. package/package/corpus/prompts/new-feature.prompt.md +9 -0
  45. package/package/corpus/prompts/new-vertical-slice.prompt.md +18 -0
  46. package/package/corpus/prompts/review-pr.prompt.md +35 -0
  47. package/package/corpus/prompts/review-skill.prompt.md +16 -0
  48. package/package/corpus/prompts/scaffold-feature.prompt.md +16 -0
  49. package/package/corpus/prompts/ship-changes.prompt.md +20 -0
  50. package/package/corpus/prompts/verify-ai-setup.prompt.md +19 -0
  51. package/package/corpus/prompts/write-documentation.prompt.md +21 -0
  52. package/package/corpus/prompts/write-specs.prompt.md +22 -0
  53. package/package/corpus/rules/capability-is-not-authority.md +31 -0
  54. package/package/corpus/rules/code-quality.csharp.md +91 -0
  55. package/package/corpus/rules/code-quality.md +82 -0
  56. package/package/corpus/rules/code-quality.typescript.md +89 -0
  57. package/package/corpus/rules/components.md +207 -0
  58. package/package/corpus/rules/concepts.md +115 -0
  59. package/package/corpus/rules/csharp.md +269 -0
  60. package/package/corpus/rules/dialogs.md +264 -0
  61. package/package/corpus/rules/documentation-structure-and-formatting.md +148 -0
  62. package/package/corpus/rules/documentation.md +90 -0
  63. package/package/corpus/rules/editing-cratis-docs.md +69 -0
  64. package/package/corpus/rules/efcore.md +235 -0
  65. package/package/corpus/rules/efcore.specs.md +44 -0
  66. package/package/corpus/rules/exit-codes-and-wrappers.md +33 -0
  67. package/package/corpus/rules/framework.md +52 -0
  68. package/package/corpus/rules/frontend-quality.md +59 -0
  69. package/package/corpus/rules/frontend-testing.md +126 -0
  70. package/package/corpus/rules/general.md +305 -0
  71. package/package/corpus/rules/git-commits.md +138 -0
  72. package/package/corpus/rules/github-actions.md +92 -0
  73. package/package/corpus/rules/glossary.md +61 -0
  74. package/package/corpus/rules/guards-and-fuses.md +45 -0
  75. package/package/corpus/rules/local-work-artifacts.md +33 -0
  76. package/package/corpus/rules/managing-ai-rules.md +40 -0
  77. package/package/corpus/rules/orleans.md +50 -0
  78. package/package/corpus/rules/pull-requests.md +78 -0
  79. package/package/corpus/rules/react.md +195 -0
  80. package/package/corpus/rules/reactors.md +238 -0
  81. package/package/corpus/rules/rtk.md +37 -0
  82. package/package/corpus/rules/specs.csharp.md +139 -0
  83. package/package/corpus/rules/specs.md +132 -0
  84. package/package/corpus/rules/specs.scenarios.csharp.md +172 -0
  85. package/package/corpus/rules/specs.typescript.md +139 -0
  86. package/package/corpus/rules/storybook.md +84 -0
  87. package/package/corpus/rules/terminal-commands.md +19 -0
  88. package/package/corpus/rules/typescript.md +149 -0
  89. package/package/corpus/rules/verification-discipline.md +21 -0
  90. package/package/corpus/rules/vertical-slices.md +338 -0
  91. package/package/corpus/rules/web-fetching.md +11 -0
  92. package/package/corpus/rules/writing-correct-examples.md +35 -0
  93. package/package/corpus/rules/writing-cratis-docs.md +70 -0
  94. package/package/corpus/skills/cratis-engineering-csharp-conventions/LICENSE +2 -0
  95. package/package/corpus/skills/cratis-engineering-csharp-conventions/SKILL.md +131 -0
  96. package/package/corpus/skills/cratis-engineering-csharp-conventions/references/code-style.md +187 -0
  97. package/package/corpus/skills/cratis-engineering-csharp-conventions/references/domain-philosophy.md +91 -0
  98. package/package/corpus/skills/cratis-engineering-csharp-conventions/references/exceptions-logging-and-di.md +223 -0
  99. package/package/corpus/skills/cratis-engineering-decision-record/LICENSE +2 -0
  100. package/package/corpus/skills/cratis-engineering-decision-record/SKILL.md +133 -0
  101. package/package/corpus/skills/cratis-engineering-decision-record/references/record-format.md +107 -0
  102. package/package/corpus/skills/cratis-engineering-docs-authoring/LICENSE +2 -0
  103. package/package/corpus/skills/cratis-engineering-docs-authoring/SKILL.md +86 -0
  104. package/package/corpus/skills/cratis-engineering-docs-authoring/references/site-format.md +46 -0
  105. package/package/corpus/skills/cratis-engineering-effect-boundaries/LICENSE +2 -0
  106. package/package/corpus/skills/cratis-engineering-effect-boundaries/SKILL.md +130 -0
  107. package/package/corpus/skills/cratis-engineering-effect-boundaries/references/failure-archetypes.md +133 -0
  108. package/package/corpus/skills/cratis-fundamentals-concept/verification.json +8 -0
  109. package/package/profile-catalog.json +821 -0
  110. package/package.json +35 -13
  111. package/src/index.ts +80 -0
  112. /package/{skills → package/corpus/skills}/cratis-application-react-specifications/LICENSE +0 -0
  113. /package/{skills → package/corpus/skills}/cratis-application-react-specifications/SKILL.md +0 -0
  114. /package/{skills → package/corpus/skills}/cratis-application-slice-specifications/LICENSE +0 -0
  115. /package/{skills → package/corpus/skills}/cratis-application-slice-specifications/SKILL.md +0 -0
  116. /package/{skills → package/corpus/skills}/cratis-arc-authentication-authorization-and-identity/LICENSE +0 -0
  117. /package/{skills → package/corpus/skills}/cratis-arc-authentication-authorization-and-identity/SKILL.md +0 -0
  118. /package/{skills → package/corpus/skills}/cratis-arc-authentication-authorization-and-identity/references/authentication.md +0 -0
  119. /package/{skills → package/corpus/skills}/cratis-arc-authentication-authorization-and-identity/references/authorization.md +0 -0
  120. /package/{skills → package/corpus/skills}/cratis-arc-authentication-authorization-and-identity/references/frontend.md +0 -0
  121. /package/{skills → package/corpus/skills}/cratis-arc-authentication-authorization-and-identity/references/local-development.md +0 -0
  122. /package/{skills → package/corpus/skills}/cratis-arc-authentication-authorization-and-identity/references/tenancy.md +0 -0
  123. /package/{skills → package/corpus/skills}/cratis-arc-command/LICENSE +0 -0
  124. /package/{skills → package/corpus/skills}/cratis-arc-command/SKILL.md +0 -0
  125. /package/{skills → package/corpus/skills}/cratis-arc-command/references/command-result.md +0 -0
  126. /package/{skills → package/corpus/skills}/cratis-arc-command/references/handler-shapes.md +0 -0
  127. /package/{skills → package/corpus/skills}/cratis-arc-command/references/proxy-generation.md +0 -0
  128. /package/{skills → package/corpus/skills}/cratis-arc-command/references/read-model-injection.md +0 -0
  129. /package/{skills → package/corpus/skills}/cratis-arc-command-execution/LICENSE +0 -0
  130. /package/{skills → package/corpus/skills}/cratis-arc-command-execution/SKILL.md +0 -0
  131. /package/{skills → package/corpus/skills}/cratis-arc-command-validation/LICENSE +0 -0
  132. /package/{skills → package/corpus/skills}/cratis-arc-command-validation/SKILL.md +0 -0
  133. /package/{skills → package/corpus/skills}/cratis-arc-ef-core-migration/LICENSE +0 -0
  134. /package/{skills → package/corpus/skills}/cratis-arc-ef-core-migration/SKILL.md +0 -0
  135. /package/{skills → package/corpus/skills}/cratis-arc-observable-query-http/LICENSE +0 -0
  136. /package/{skills → package/corpus/skills}/cratis-arc-observable-query-http/SKILL.md +0 -0
  137. /package/{skills → package/corpus/skills}/cratis-arc-query-paging/LICENSE +0 -0
  138. /package/{skills → package/corpus/skills}/cratis-arc-query-paging/SKILL.md +0 -0
  139. /package/{skills → package/corpus/skills}/cratis-arc-react-page/LICENSE +0 -0
  140. /package/{skills → package/corpus/skills}/cratis-arc-react-page/SKILL.md +0 -0
  141. /package/{skills → package/corpus/skills}/cratis-arc-react-page/references/data-page.md +0 -0
  142. /package/{skills → package/corpus/skills}/cratis-arc-react-page/references/data-tables.md +0 -0
  143. /package/{skills → package/corpus/skills}/cratis-arc-react-page/references/dialogs.md +0 -0
  144. /package/{skills → package/corpus/skills}/cratis-arc-react-page/references/mvvm.md +0 -0
  145. /package/{skills → package/corpus/skills}/cratis-arc-react-page/references/queries-and-commands.md +0 -0
  146. /package/{skills → package/corpus/skills}/cratis-chronicle-cli-operations/LICENSE +0 -0
  147. /package/{skills → package/corpus/skills}/cratis-chronicle-cli-operations/SKILL.md +0 -0
  148. /package/{skills → package/corpus/skills}/cratis-chronicle-client-dotnet/LICENSE +0 -0
  149. /package/{skills → package/corpus/skills}/cratis-chronicle-client-dotnet/SKILL.md +0 -0
  150. /package/{skills → package/corpus/skills}/cratis-chronicle-client-elixir/LICENSE +0 -0
  151. /package/{skills → package/corpus/skills}/cratis-chronicle-client-elixir/SKILL.md +0 -0
  152. /package/{skills → package/corpus/skills}/cratis-chronicle-client-kotlin/LICENSE +0 -0
  153. /package/{skills → package/corpus/skills}/cratis-chronicle-client-kotlin/SKILL.md +0 -0
  154. /package/{skills → package/corpus/skills}/cratis-chronicle-client-typescript/LICENSE +0 -0
  155. /package/{skills → package/corpus/skills}/cratis-chronicle-client-typescript/SKILL.md +0 -0
  156. /package/{skills → package/corpus/skills}/cratis-chronicle-compliance/LICENSE +0 -0
  157. /package/{skills → package/corpus/skills}/cratis-chronicle-compliance/SKILL.md +0 -0
  158. /package/{skills → package/corpus/skills}/cratis-chronicle-event-constraints/LICENSE +0 -0
  159. /package/{skills → package/corpus/skills}/cratis-chronicle-event-constraints/SKILL.md +0 -0
  160. /package/{skills → package/corpus/skills}/cratis-chronicle-event-modeling/LICENSE +0 -0
  161. /package/{skills → package/corpus/skills}/cratis-chronicle-event-modeling/SKILL.md +0 -0
  162. /package/{skills → package/corpus/skills}/cratis-chronicle-event-specifications/LICENSE +0 -0
  163. /package/{skills → package/corpus/skills}/cratis-chronicle-event-specifications/SKILL.md +0 -0
  164. /package/{skills → package/corpus/skills}/cratis-chronicle-event-type-migration/LICENSE +0 -0
  165. /package/{skills → package/corpus/skills}/cratis-chronicle-event-type-migration/SKILL.md +0 -0
  166. /package/{skills → package/corpus/skills}/cratis-chronicle-mcp-inspection/LICENSE +0 -0
  167. /package/{skills → package/corpus/skills}/cratis-chronicle-mcp-inspection/SKILL.md +0 -0
  168. /package/{skills → package/corpus/skills}/cratis-chronicle-mcp-inspection/references/blocked-tools.md +0 -0
  169. /package/{skills → package/corpus/skills}/cratis-chronicle-mcp-inspection/references/observational-tools.md +0 -0
  170. /package/{skills → package/corpus/skills}/cratis-chronicle-multi-tenancy/LICENSE +0 -0
  171. /package/{skills → package/corpus/skills}/cratis-chronicle-multi-tenancy/SKILL.md +0 -0
  172. /package/{skills → package/corpus/skills}/cratis-chronicle-projection/LICENSE +0 -0
  173. /package/{skills → package/corpus/skills}/cratis-chronicle-projection/SKILL.md +0 -0
  174. /package/{skills → package/corpus/skills}/cratis-chronicle-projection/references/fluent-builder.md +0 -0
  175. /package/{skills → package/corpus/skills}/cratis-chronicle-projection/references/model-bound-attributes.md +0 -0
  176. /package/{skills → package/corpus/skills}/cratis-chronicle-reactor/LICENSE +0 -0
  177. /package/{skills → package/corpus/skills}/cratis-chronicle-reactor/SKILL.md +0 -0
  178. /package/{skills → package/corpus/skills}/cratis-chronicle-read-model/LICENSE +0 -0
  179. /package/{skills → package/corpus/skills}/cratis-chronicle-read-model/SKILL.md +0 -0
  180. /package/{skills → package/corpus/skills}/cratis-chronicle-read-model/references/queries.md +0 -0
  181. /package/{skills → package/corpus/skills}/cratis-chronicle-read-model-specifications/LICENSE +0 -0
  182. /package/{skills → package/corpus/skills}/cratis-chronicle-read-model-specifications/SKILL.md +0 -0
  183. /package/{skills → package/corpus/skills}/cratis-chronicle-reducer/LICENSE +0 -0
  184. /package/{skills → package/corpus/skills}/cratis-chronicle-reducer/SKILL.md +0 -0
  185. /package/{skills → package/corpus/skills}/cratis-chronicle-web-workbench/LICENSE +0 -0
  186. /package/{skills → package/corpus/skills}/cratis-chronicle-web-workbench/SKILL.md +0 -0
  187. /package/{skills → package/corpus/skills}/cratis-cli-terminal-workbench/LICENSE +0 -0
  188. /package/{skills → package/corpus/skills}/cratis-cli-terminal-workbench/SKILL.md +0 -0
  189. /package/{skills → package/corpus/skills}/cratis-code-review/LICENSE +0 -0
  190. /package/{skills → package/corpus/skills}/cratis-code-review/SKILL.md +0 -0
  191. /package/{skills → package/corpus/skills}/cratis-components-accessibility/LICENSE +0 -0
  192. /package/{skills → package/corpus/skills}/cratis-components-accessibility/SKILL.md +0 -0
  193. /package/{skills → package/corpus/skills}/cratis-components-schema-editor/LICENSE +0 -0
  194. /package/{skills → package/corpus/skills}/cratis-components-schema-editor/SKILL.md +0 -0
  195. /package/{skills → package/corpus/skills}/cratis-components-stepper-command-dialog/LICENSE +0 -0
  196. /package/{skills → package/corpus/skills}/cratis-components-stepper-command-dialog/SKILL.md +0 -0
  197. /package/{skills → package/corpus/skills}/cratis-components-styling/LICENSE +0 -0
  198. /package/{skills → package/corpus/skills}/cratis-components-styling/SKILL.md +0 -0
  199. /package/{skills → package/corpus/skills}/cratis-components-toolbar/LICENSE +0 -0
  200. /package/{skills → package/corpus/skills}/cratis-components-toolbar/SKILL.md +0 -0
  201. /package/{skills → package/corpus/skills}/cratis-documentation-writing/LICENSE +0 -0
  202. /package/{skills → package/corpus/skills}/cratis-documentation-writing/SKILL.md +0 -0
  203. /package/{skills → package/corpus/skills}/cratis-event-model-diagram/LICENSE +0 -0
  204. /package/{skills → package/corpus/skills}/cratis-event-model-diagram/SKILL.md +0 -0
  205. /package/{skills → package/corpus/skills}/cratis-fundamentals-concept/LICENSE +0 -0
  206. /package/{skills → package/corpus/skills}/cratis-fundamentals-concept/SKILL.md +0 -0
  207. /package/{skills → package/corpus/skills}/cratis-fundamentals-type-discovery/LICENSE +0 -0
  208. /package/{skills → package/corpus/skills}/cratis-fundamentals-type-discovery/SKILL.md +0 -0
  209. /package/{skills → package/corpus/skills}/cratis-governed-release-methodology/LICENSE +0 -0
  210. /package/{skills → package/corpus/skills}/cratis-governed-release-methodology/SKILL.md +0 -0
  211. /package/{skills → package/corpus/skills}/cratis-lens-browser-extension/LICENSE +0 -0
  212. /package/{skills → package/corpus/skills}/cratis-lens-browser-extension/SKILL.md +0 -0
  213. /package/{skills → package/corpus/skills}/cratis-performance-review/LICENSE +0 -0
  214. /package/{skills → package/corpus/skills}/cratis-performance-review/SKILL.md +0 -0
  215. /package/{skills → package/corpus/skills}/cratis-screenplay-model-authoring/LICENSE +0 -0
  216. /package/{skills → package/corpus/skills}/cratis-screenplay-model-authoring/SKILL.md +0 -0
  217. /package/{skills → package/corpus/skills}/cratis-security-review/LICENSE +0 -0
  218. /package/{skills → package/corpus/skills}/cratis-security-review/SKILL.md +0 -0
  219. /package/{skills → package/corpus/skills}/cratis-specification-by-example/LICENSE +0 -0
  220. /package/{skills → package/corpus/skills}/cratis-specification-by-example/SKILL.md +0 -0
  221. /package/{skills → package/corpus/skills}/cratis-specifications-csharp/LICENSE +0 -0
  222. /package/{skills → package/corpus/skills}/cratis-specifications-csharp/SKILL.md +0 -0
  223. /package/{skills → package/corpus/skills}/cratis-specifications-csharp/references/application-scenarios.md +0 -0
  224. /package/{skills → package/corpus/skills}/cratis-specifications-csharp/references/csharp-patterns.md +0 -0
  225. /package/{skills → package/corpus/skills}/cratis-specifications-csharp/references/integration-specs.md +0 -0
  226. /package/{skills → package/corpus/skills}/cratis-specifications-typescript/LICENSE +0 -0
  227. /package/{skills → package/corpus/skills}/cratis-specifications-typescript/SKILL.md +0 -0
  228. /package/{skills → package/corpus/skills}/cratis-specifications-typescript/references/typescript-patterns.md +0 -0
  229. /package/{skills → package/corpus/skills}/cratis-stage-rendering-and-sandbox/LICENSE +0 -0
  230. /package/{skills → package/corpus/skills}/cratis-stage-rendering-and-sandbox/SKILL.md +0 -0
  231. /package/{skills → package/corpus/skills}/cratis-studio-mcp-safety-guidance/LICENSE +0 -0
  232. /package/{skills → package/corpus/skills}/cratis-studio-mcp-safety-guidance/SKILL.md +0 -0
  233. /package/{skills → package/corpus/skills}/cratis-studio-mcp-safety-guidance/references/blocked-tools.md +0 -0
  234. /package/{skills → package/corpus/skills}/cratis-studio-mcp-safety-guidance/references/observational-tools.md +0 -0
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env bash
2
+ # PostToolUse hook — deterministic pattern pass over a just-edited file.
3
+ #
4
+ # Zero model calls, zero token cost until something actually matches. Reads the Claude Code
5
+ # hook JSON on stdin, resolves the edited file, and runs every applicable pattern from
6
+ # cratis-patterns.json against it. A match appends a one-line reminder to context; it NEVER
7
+ # blocks (always exits 0). Each pattern fires at most once per file per session, so it
8
+ # cannot flood the conversation.
9
+ #
10
+ # Escape hatch: CRATIS_HOOKS_SKIP_SCAN=1 disables the pass entirely.
11
+ set -euo pipefail
12
+
13
+ # SCRIPTDIR, not a path relative to the caller: shellcheck resolves a plain relative `source=`
14
+ # against the current working directory, and these hooks are linted from wherever CI happens to run.
15
+ # shellcheck source=SCRIPTDIR/hook-lib.sh
16
+ . "$(dirname "${BASH_SOURCE[0]}")/hook-lib.sh"
17
+
18
+ [ "${CRATIS_HOOKS_SKIP_SCAN:-0}" = "1" ] && exit 0
19
+
20
+ input="$(hook_read_stdin)"
21
+ [ -n "$input" ] || exit 0
22
+
23
+ # jq is the repository's established JSON dependency. If it is not
24
+ # installed the hook degrades to a silent no-op rather than breaking the session.
25
+ hook_have jq || exit 0
26
+
27
+ root="$(hook_repo_root)"
28
+ cwd="$(hook_json "$input" '.cwd')"
29
+ [ -n "$cwd" ] || cwd="$root"
30
+
31
+ file="$(hook_json "$input" '.tool_input.file_path')"
32
+ [ -n "$file" ] || file="$(hook_json "$input" '.tool_input.notebook_path')"
33
+ [ -n "$file" ] || exit 0
34
+
35
+ file="$(hook_abspath "$file" "$cwd")"
36
+ [ -f "$file" ] || exit 0
37
+
38
+ # Never scan binaries or very large files — this hook must stay cheap.
39
+ LC_ALL=C grep -Iq . "$file" 2>/dev/null || exit 0
40
+ size="$(wc -c <"$file" 2>/dev/null || printf '0')"
41
+ [ "${size:-0}" -le 2000000 ] || exit 0
42
+
43
+ rel="$(hook_relpath "$file" "$root")"
44
+
45
+ # ── Pattern set: shipped defaults + optional local extension / override ───────
46
+ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
47
+ base_file="${CRATIS_HOOKS_PATTERNS:-$here/cratis-patterns.json}"
48
+ local_file="$here/cratis-patterns.local.json"
49
+ [ -f "$base_file" ] || exit 0
50
+
51
+ if [ -f "$local_file" ]; then
52
+ # Local entries with the same id win; new ids are appended.
53
+ merged="$(jq -s '
54
+ {patterns: (
55
+ ((.[0].patterns // []) | map(select(.id as $i | ((.[1].patterns // []) | map(.id) | index($i)) == null)))
56
+ + ((.[1].patterns // []))
57
+ )}' "$base_file" "$local_file" 2>/dev/null || true)"
58
+ [ -n "$merged" ] || merged="$(cat "$base_file")"
59
+ else
60
+ merged="$(cat "$base_file")"
61
+ fi
62
+
63
+ # One record per line. Fields separated by US (0x1f); list members inside a field by SOH (0x01).
64
+ # `join` is used instead of @tsv because @tsv escapes backslashes, which would corrupt regexes.
65
+ records="$(printf '%s' "$merged" | jq -r '
66
+ .patterns // []
67
+ | map(select((.enabled // true) == true))
68
+ | .[]
69
+ | [ .id,
70
+ .pattern,
71
+ .message,
72
+ (.rule // ""),
73
+ ((.paths // ["**/*"]) | join("")),
74
+ ((.exclude_paths // []) | join("")),
75
+ ((.requires_in_file // []) | join("")),
76
+ ((.absent_in_file // []) | join("")),
77
+ (.within_type_attribute // ""),
78
+ ((.join_lines // 1) | tostring) ]
79
+ | join("")
80
+ ' 2>/dev/null || true)"
81
+ [ -n "$records" ] || exit 0
82
+
83
+ session="$(hook_json "$input" '.session_id')"
84
+ state_dir="$(hook_state_dir "${session:-nosession}")" || exit 0
85
+ seen_file="$state_dir/pattern-scan.seen"
86
+
87
+ # Match a file against the newline-separated ERE list on stdin. mode=all|none.
88
+ file_has() {
89
+ local mode="$1" f="$2" re
90
+ while IFS= read -r re; do
91
+ [ -n "$re" ] || continue
92
+ if LC_ALL=C grep -Eq -- "$re" "$f" 2>/dev/null; then
93
+ [ "$mode" = "none" ] && return 1
94
+ else
95
+ [ "$mode" = "all" ] && return 1
96
+ fi
97
+ done
98
+ return 0
99
+ }
100
+
101
+ findings=""
102
+ while IFS=$'\037' read -r id pattern message rule paths excludes requires absent attr joinlines; do
103
+ [ -n "${id:-}" ] || continue
104
+ [ -n "${pattern:-}" ] || continue
105
+
106
+ printf '%s' "${paths:-}" | tr '\001' '\n' | hook_glob_match "$rel" || continue
107
+ if [ -n "${excludes:-}" ]; then
108
+ printf '%s' "$excludes" | tr '\001' '\n' | hook_glob_match "$rel" && continue
109
+ fi
110
+ if [ -n "${requires:-}" ]; then
111
+ printf '%s' "$requires" | tr '\001' '\n' | file_has all "$file" || continue
112
+ fi
113
+ if [ -n "${absent:-}" ]; then
114
+ printf '%s' "$absent" | tr '\001' '\n' | file_has none "$file" || continue
115
+ fi
116
+
117
+ # The scan: first matching line number, or nothing. Regexes travel through ENVIRON
118
+ # because `awk -v` performs backslash-escape processing on the value.
119
+ line="$(
120
+ CRATIS_PAT="$pattern" CRATIS_ATTR="${attr:-}" CRATIS_JOIN="${joinlines:-1}" awk '
121
+ function cnt(s, ch, n, i) { n = 0; for (i = 1; i <= length(s); i++) if (substr(s, i, 1) == ch) n++; return n }
122
+ function trim(s) { sub(/^[ \t]+/, "", s); sub(/[ \t\r]+$/, "", s); return s }
123
+ BEGIN {
124
+ pat = ENVIRON["CRATIS_PAT"]
125
+ attr = ENVIRON["CRATIS_ATTR"]
126
+ jn = ENVIRON["CRATIS_JOIN"] + 0
127
+ if (jn < 1) jn = 1
128
+ if (attr != "") attrRe = "\\[" attr "(\\]|\\()"
129
+ mode = 0; depth = 0; pending = ""
130
+ }
131
+ {
132
+ lines[NR] = $0
133
+ if (attr == "") next
134
+ t = trim($0)
135
+ if (mode == 2) { # inside a braced type body
136
+ scope[NR] = 1
137
+ depth += cnt($0, "{") - cnt($0, "}")
138
+ if (depth <= 0) mode = 0
139
+ next
140
+ }
141
+ if (mode == 1) { # declaration still being read
142
+ scope[NR] = 1
143
+ if (index($0, "{") > 0) {
144
+ depth = cnt($0, "{") - cnt($0, "}")
145
+ mode = (depth > 0) ? 2 : 0
146
+ } else if (index($0, ";") > 0) {
147
+ mode = 0
148
+ }
149
+ next
150
+ }
151
+ if (t == "" || t ~ /^\/\// || t ~ /^\*/ || t ~ /^\/\*/) next
152
+ if (t ~ /^\[/) { pending = pending " " t; next } # accumulate attributes
153
+ if (t ~ /(^|[ \t])(record|class|struct|interface)([ \t]|$)/) {
154
+ if (attrRe != "" && pending ~ attrRe) {
155
+ scope[NR] = 1
156
+ if (index($0, "{") > 0) { depth = cnt($0, "{") - cnt($0, "}"); mode = (depth > 0) ? 2 : 0 }
157
+ else if (index($0, ";") > 0) { mode = 0 }
158
+ else { mode = 1 }
159
+ }
160
+ pending = ""
161
+ next
162
+ }
163
+ pending = ""
164
+ }
165
+ END {
166
+ for (i = 1; i <= NR; i++) {
167
+ if (attr != "" && !(i in scope)) continue
168
+ tl = trim(lines[i])
169
+ if (tl ~ /^\/\// || tl ~ /^\*/ || tl ~ /^\/\*/) continue
170
+ buf = lines[i]
171
+ for (j = 1; j < jn; j++) if (i + j <= NR) buf = buf " " lines[i + j]
172
+ if (buf ~ pat) { print i; exit }
173
+ }
174
+ }
175
+ ' "$file" 2>/dev/null || true
176
+ )"
177
+ [ -n "$line" ] || continue
178
+
179
+ hook_claim_once "$seen_file" "$id $rel" || continue
180
+
181
+ findings="${findings}${rel}:${line} — [${rule}] ${message}
182
+ "
183
+ done <<EOF
184
+ $records
185
+ EOF
186
+
187
+ [ -n "$findings" ] || exit 0
188
+
189
+ context="Cratis rule check on the file you just edited (deterministic pattern pass, not a build):
190
+ ${findings}
191
+ These are framework contracts from .cratis/ai/rules/general.md. Fix them now rather than at the quality gate."
192
+
193
+ jq -n --arg ctx "$context" '{
194
+ systemMessage: "cratis-hooks: rule reminder appended to context",
195
+ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: $ctx }
196
+ }'
197
+ exit 0
@@ -0,0 +1,84 @@
1
+ {
2
+ "$comment": [
3
+ "Deterministic pattern pass for the Cratis PostToolUse hook (cratis-pattern-scan.sh).",
4
+ "Data, not code: a consuming repository extends or overrides this without touching the script.",
5
+ " - drop a cratis-patterns.local.json next to this file to ADD patterns (merged, never replaces)",
6
+ " - or point CRATIS_HOOKS_PATTERNS at a replacement file",
7
+ " - set \"enabled\": false on a shipped pattern in the local file to silence it (matched by id)",
8
+ "",
9
+ "Pattern fields:",
10
+ " id stable identifier; also the de-duplication key (fires once per file per session)",
11
+ " rule human reference into .cratis/ai/rules (shown with the reminder)",
12
+ " pattern POSIX ERE, matched line by line",
13
+ " paths globs the file must match (repo-relative)",
14
+ " exclude_paths globs that veto the match",
15
+ " message the one-line reminder appended to context",
16
+ " join_lines optional; match against N joined lines so a signature can wrap (default 1)",
17
+ " within_type_attribute optional; only match inside a type declaration carrying [ThatAttribute]",
18
+ " requires_in_file optional EREs that must ALSO match somewhere in the file",
19
+ " absent_in_file optional EREs that must NOT match anywhere in the file",
20
+ " enabled optional; default true",
21
+ "",
22
+ "The C# patterns are application-profile only (.cratis/ai/rules/general.md). They are scoped by the",
23
+ "\"paths\" globs to Source/**, the application source root general.md documents — not a path",
24
+ "in Cratis/AI itself, which is a corpus of markdown and JSON with no C# source, so they never",
25
+ "fire here. A framework-profile repository (Arc, Chronicle, Fundamentals, Components) should",
26
+ "disable them in its local file; a repository whose application source root is not Source/",
27
+ "re-scopes the \"paths\" globs there instead."
28
+ ],
29
+ "patterns": [
30
+ {
31
+ "id": "cratis-automap-call",
32
+ "rule": "general.md rule 10 [contract]",
33
+ "pattern": "\\.AutoMap[[:space:]]*\\(",
34
+ "paths": ["Source/**/*.cs"],
35
+ "exclude_paths": ["**/obj/**", "**/bin/**", "**/*.g.cs", "**/*.Designer.cs"],
36
+ "absent_in_file": ["NoAutoMap"],
37
+ "message": "AutoMap is on by default in Chronicle — never call `.AutoMap()`. Remove the call and match property names; use `[SetFrom<T>]` or `.Set().To()` only for genuine name differences. `.AutoMap()` is legal only inside a scope previously disabled with `.NoAutoMap()`."
38
+ },
39
+ {
40
+ "id": "cratis-ieventlog-in-handle",
41
+ "rule": "general.md rule 14 [contract]",
42
+ "pattern": "Handle[[:space:]]*\\([^)]*IEventLog",
43
+ "join_lines": 5,
44
+ "paths": ["Source/**/*.cs"],
45
+ "exclude_paths": ["**/obj/**", "**/bin/**", "**/*.g.cs"],
46
+ "message": "Never inject `IEventLog` into `Handle()`. Express appends through the return type — return the event, `IEnumerable<object>`, or `EventForEventSourceId` wrappers for cross-stream appends."
47
+ },
48
+ {
49
+ "id": "cratis-nullable-event-property",
50
+ "rule": "general.md rule 6 [contract]",
51
+ "pattern": "[A-Za-z_][A-Za-z0-9_.]*(<[^<>]*>)?\\?[[:space:]]+[A-Za-z_]",
52
+ "within_type_attribute": "EventType",
53
+ "paths": ["Source/**/*.cs"],
54
+ "exclude_paths": ["**/obj/**", "**/bin/**", "**/*.g.cs"],
55
+ "message": "Avoid nullable properties on an `[EventType]` — Chronicle's analyzer warns on them. Model the optional fact as a separate event, or resolve the nullable command input to a non-null sentinel before constructing the event."
56
+ },
57
+ {
58
+ "id": "cratis-route-on-readmodel",
59
+ "rule": "general.md rule 12 [contract]",
60
+ "pattern": "\\[Route[[:space:]]*\\(",
61
+ "within_type_attribute": "ReadModel",
62
+ "paths": ["Source/**/*.cs"],
63
+ "exclude_paths": ["**/obj/**", "**/bin/**", "**/*.g.cs"],
64
+ "message": "Model-bound query custom paths use `[Path(\"...\")]` (Cratis `PathAttribute`), not ASP.NET `[Route]`. `[Route]` is reserved for controller-based endpoints, which this convention avoids."
65
+ },
66
+ {
67
+ "id": "cratis-controller-base",
68
+ "rule": "general.md rule 1 [contract]",
69
+ "pattern": "[:,][[:space:]]*ControllerBase([^A-Za-z0-9_]|$)",
70
+ "requires_in_file": ["using[[:space:]]+Microsoft\\.AspNetCore\\.Mvc[[:space:]]*;"],
71
+ "paths": ["Source/**/*.cs"],
72
+ "exclude_paths": ["**/obj/**", "**/bin/**", "**/*.g.cs"],
73
+ "message": "Model-bound — no controllers. Commands are `[Command]` records with a public instance `Handle()`; queries are `static` methods on `[ReadModel]` records. Arc generates the HTTP surface."
74
+ },
75
+ {
76
+ "id": "cratis-primereact-dialog-import",
77
+ "rule": "general.md rule 16 [convention]",
78
+ "pattern": "from[[:space:]]+['\"]primereact/dialog['\"]",
79
+ "paths": ["**/*.ts", "**/*.tsx"],
80
+ "exclude_paths": ["**/node_modules/**", "**/dist/**", "**/wwwroot/**", "**/obj/**", "**/bin/**"],
81
+ "message": "Never import `Dialog` from `primereact/dialog` — use `CommandDialog` / `Dialog` from `@cratis/components`."
82
+ }
83
+ ]
84
+ }
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env bash
2
+ # Stop hook — the real quality gate.
3
+ #
4
+ # Looks at what actually changed in the working tree, runs only the gates that change touches,
5
+ # and exits 2 (blocking turn-end, stderr fed back to the model) when one fails. It never edits
6
+ # code: it only builds, tests and lints. If nothing relevant changed it exits immediately and
7
+ # silently, so a documentation- or corpus-only turn costs nothing.
8
+ #
9
+ # Gate commands are data (quality-gates.json), not code — see that file for the schema.
10
+ #
11
+ # Environment:
12
+ # CRATIS_HOOKS_SKIP_GATE=1 skip the gate entirely
13
+ # CRATIS_HOOKS_GATE_DRYRUN=1 print the dispatch plan (which gates would run, and why) and exit 0
14
+ # CRATIS_HOOKS_GATES=<path> use a different gate configuration file
15
+ set -euo pipefail
16
+
17
+ # SCRIPTDIR, not a path relative to the caller: shellcheck resolves a plain relative `source=`
18
+ # against the current working directory, and these hooks are linted from wherever CI happens to run.
19
+ # shellcheck source=SCRIPTDIR/hook-lib.sh
20
+ . "$(dirname "${BASH_SOURCE[0]}")/hook-lib.sh"
21
+
22
+ [ "${CRATIS_HOOKS_SKIP_GATE:-0}" = "1" ] && exit 0
23
+
24
+ input="$(hook_read_stdin)"
25
+ hook_have jq || exit 0
26
+
27
+ # Never re-enter: Claude Code sets stop_hook_active when the previous Stop hook already
28
+ # blocked and the model is continuing. Blocking again would loop forever.
29
+ if [ -n "$input" ] && [ "$(hook_json "$input" '.stop_hook_active')" = "true" ]; then
30
+ exit 0
31
+ fi
32
+
33
+ root="$(hook_repo_root)"
34
+ here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
35
+ config="${CRATIS_HOOKS_GATES:-$here/quality-gates.json}"
36
+ dryrun="${CRATIS_HOOKS_GATE_DRYRUN:-0}"
37
+
38
+ [ -f "$config" ] || exit 0
39
+ jq -e . "$config" >/dev/null 2>&1 || {
40
+ printf 'cratis-quality-gate: %s is not valid JSON — gate skipped.\n' "$config" >&2
41
+ exit 0
42
+ }
43
+ [ "$(jq -r '.enabled // true' "$config")" = "true" ] || exit 0
44
+
45
+ # ── What changed in the working tree ─────────────────────────────────────────
46
+ git -C "$root" rev-parse --git-dir >/dev/null 2>&1 || exit 0
47
+ changed="$(
48
+ {
49
+ git -C "$root" diff --name-only HEAD 2>/dev/null || true
50
+ git -C "$root" ls-files --others --exclude-standard 2>/dev/null || true
51
+ } | LC_ALL=C sort -u
52
+ )"
53
+ [ -n "$changed" ] || exit 0
54
+
55
+ # ── Project discovery ────────────────────────────────────────────────────────
56
+ # A gate names *what kind of project* it builds, never a product's file. The repository's
57
+ # own solution or package is discovered here, so the shipped gates activate unchanged in an
58
+ # application, a framework, or a corpus-only repository. Read lazily: a configuration whose
59
+ # gates all use literal requires.paths never pays for the listing.
60
+ repo_paths=""
61
+ repo_paths_read=0
62
+ repository_paths() {
63
+ if [ "$repo_paths_read" -eq 0 ]; then
64
+ repo_paths_read=1
65
+ repo_paths="$(
66
+ {
67
+ git -C "$root" ls-files 2>/dev/null || true
68
+ git -C "$root" ls-files --others --exclude-standard 2>/dev/null || true
69
+ } | LC_ALL=C sort -u
70
+ )"
71
+ fi
72
+ printf '%s\n' "$repo_paths"
73
+ }
74
+
75
+ # Print the repository path a gate should be located by, given newline-separated globs.
76
+ # The globs are tried IN ORDER and the first one with a match wins, so a configuration can
77
+ # state a preference ("a solution at the root, else one anywhere") rather than depending on
78
+ # where a path happens to sort. Prints nothing when nothing matches.
79
+ discover_path() {
80
+ local g match
81
+ while IFS= read -r g; do
82
+ [ -n "$g" ] || continue
83
+ match="$(repository_paths | hook_glob_first_match "$g" || true)"
84
+ [ -n "$match" ] && { printf '%s\n' "$match"; return 0; }
85
+ done <<EOF
86
+ $1
87
+ EOF
88
+ return 1
89
+ }
90
+
91
+ gate_count="$(jq -r '.gates | length' "$config")"
92
+ [ "${gate_count:-0}" -gt 0 ] || exit 0
93
+ fail_fast="$(jq -r '.failFast // true' "$config")"
94
+ max_lines="$(jq -r '.maxOutputLines // 60' "$config")"
95
+
96
+ tmp_root="$(hook_state_dir "$(hook_json "$input" '.session_id')")" || tmp_root="${TMPDIR:-/tmp}"
97
+ log_dir="$tmp_root/gate-logs"
98
+ mkdir -p "$log_dir" 2>/dev/null || log_dir="${TMPDIR:-/tmp}"
99
+
100
+ # Does any changed path match this gate's globs (and survive its excludes)?
101
+ gate_triggered() {
102
+ local idx="$1" inc exc p
103
+ inc="$(jq -r --argjson i "$idx" '.gates[$i].changed // [] | .[]' "$config")"
104
+ exc="$(jq -r --argjson i "$idx" '.gates[$i].excludeChanged // [] | .[]' "$config")"
105
+ [ -n "$inc" ] || return 1
106
+ while IFS= read -r p; do
107
+ [ -n "$p" ] || continue
108
+ printf '%s\n' "$inc" | hook_glob_match "$p" || continue
109
+ if [ -n "$exc" ]; then
110
+ printf '%s\n' "$exc" | hook_glob_match "$p" && continue
111
+ fi
112
+ return 0
113
+ done <<EOF
114
+ $changed
115
+ EOF
116
+ return 1
117
+ }
118
+
119
+ # Report the first unmet requirement, or nothing when the gate can run here.
120
+ gate_unmet() {
121
+ local idx="$1" c p
122
+ while IFS= read -r c; do
123
+ [ -n "$c" ] || continue
124
+ hook_have "$c" || { printf "command '%s' is not on PATH" "$c"; return 0; }
125
+ done <<EOF
126
+ $(jq -r --argjson i "$idx" '.gates[$i].requires.commands // [] | .[]' "$config")
127
+ EOF
128
+ while IFS= read -r p; do
129
+ [ -n "$p" ] || continue
130
+ [ -e "$root/$p" ] || { printf "'%s' does not exist in this repository" "$p"; return 0; }
131
+ done <<EOF
132
+ $(jq -r --argjson i "$idx" '.gates[$i].requires.paths // [] | .[]' "$config")
133
+ EOF
134
+ return 0
135
+ }
136
+
137
+ idx=0
138
+ ran=0
139
+ while [ "$idx" -lt "$gate_count" ]; do
140
+ id="$(jq -r --argjson i "$idx" '.gates[$i].id' "$config")"
141
+ desc="$(jq -r --argjson i "$idx" '.gates[$i].description // ""' "$config")"
142
+ wd="$(jq -r --argjson i "$idx" '.gates[$i].workingDirectory // ""' "$config")"
143
+
144
+ if ! gate_triggered "$idx"; then
145
+ [ "$dryrun" = "1" ] && printf 'cratis-quality-gate: SKIP %-24s (no matching change)\n' "$id" >&2
146
+ idx=$((idx + 1))
147
+ continue
148
+ fi
149
+
150
+ # workingDirectoryFrom is discovery AND requirement in one: the gate runs in the directory
151
+ # of the discovered project file, and a repository holding no such file is a NO-OP.
152
+ if [ -z "$wd" ]; then
153
+ wd_globs="$(jq -r --argjson i "$idx" '.gates[$i].workingDirectoryFrom // [] | .[]' "$config")"
154
+ if [ -n "$wd_globs" ]; then
155
+ wd_path="$(discover_path "$wd_globs" || true)"
156
+ if [ -z "$wd_path" ]; then
157
+ printf 'cratis-quality-gate: NO-OP %-24s — no repository path matches %s. Configure it in %s.\n' \
158
+ "$id" "$(printf '%s' "$wd_globs" | tr '\n' ' ')" "${config#"$root"/}" >&2
159
+ idx=$((idx + 1))
160
+ continue
161
+ fi
162
+ wd="$(dirname "$wd_path")"
163
+ fi
164
+ fi
165
+ [ -n "$wd" ] || wd="."
166
+
167
+ unmet="$(gate_unmet "$idx")"
168
+ if [ -n "$unmet" ]; then
169
+ printf 'cratis-quality-gate: NO-OP %-24s — %s. Configure it in %s.\n' \
170
+ "$id" "$unmet" "${config#"$root"/}" >&2
171
+ idx=$((idx + 1))
172
+ continue
173
+ fi
174
+
175
+ cmd=()
176
+ while IFS= read -r arg; do
177
+ cmd+=("$arg")
178
+ done <<EOF
179
+ $(jq -r --argjson i "$idx" '.gates[$i].command // [] | .[]' "$config")
180
+ EOF
181
+ if [ "${#cmd[@]}" -eq 0 ]; then
182
+ idx=$((idx + 1))
183
+ continue
184
+ fi
185
+
186
+ if [ "$dryrun" = "1" ]; then
187
+ printf 'cratis-quality-gate: RUN %-24s %s\n $ %s (cwd: %s)\n' \
188
+ "$id" "$desc" "${cmd[*]}" "$wd" >&2
189
+ idx=$((idx + 1))
190
+ ran=$((ran + 1))
191
+ continue
192
+ fi
193
+
194
+ log="$log_dir/$id.log"
195
+ rc=0
196
+ (cd "$root/$wd" && "${cmd[@]}") >"$log" 2>&1 || rc=$?
197
+ ran=$((ran + 1))
198
+
199
+ if [ "$rc" -ne 0 ]; then
200
+ {
201
+ printf 'QUALITY GATE FAILED: %s (exit %s)\n' "$id" "$rc"
202
+ printf ' %s\n' "$desc"
203
+ printf ' $ %s (cwd: %s)\n\n' "${cmd[*]}" "$wd"
204
+ printf -- '--- last %s lines ---\n' "$max_lines"
205
+ tail -n "$max_lines" "$log" 2>/dev/null || true
206
+ printf -- '--- end ---\n\n'
207
+ printf 'Fix the failure and re-run the gate. Never change code merely to make a gate pass,\n'
208
+ printf 'and never suppress warnings. Full log: %s\n' "$log"
209
+ } >&2
210
+ [ "$fail_fast" = "true" ] && exit 2
211
+ failed=1
212
+ fi
213
+
214
+ idx=$((idx + 1))
215
+ done
216
+
217
+ [ "${failed:-0}" -eq 0 ] || exit 2
218
+ [ "$dryrun" = "1" ] && printf 'cratis-quality-gate: dry run complete — %s gate(s) would run.\n' "$ran" >&2
219
+ exit 0
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env bash
2
+ # Shared helpers for the Cratis enforcement hooks.
3
+ #
4
+ # Sourced by cratis-guard-writes.sh, cratis-pattern-scan.sh and cratis-quality-gate.sh.
5
+ # Portable: bash 3.2 (macOS system bash) and up, BSD + GNU userland. No GNU-only flags,
6
+ # no `mapfile`/`readarray`, no associative arrays, no `eval`.
7
+ set -euo pipefail
8
+
9
+ # ── Environment ───────────────────────────────────────────────────────────────
10
+
11
+ # Root of the repository the hook is running for.
12
+ hook_repo_root() {
13
+ local d="${CLAUDE_PROJECT_DIR:-}"
14
+ if [ -n "$d" ] && [ -d "$d" ]; then
15
+ (cd "$d" && pwd)
16
+ return 0
17
+ fi
18
+ (cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)
19
+ }
20
+
21
+ # True when the named command is on PATH.
22
+ hook_have() {
23
+ command -v "$1" >/dev/null 2>&1
24
+ }
25
+
26
+ # Read all of stdin. Never fails on an empty pipe.
27
+ hook_read_stdin() {
28
+ cat 2>/dev/null || true
29
+ }
30
+
31
+ # Extract a single scalar from a JSON document on $1 using the jq path in $2.
32
+ # Prints the empty string when jq is absent, the JSON is malformed, or the path is null.
33
+ hook_json() {
34
+ local json="$1" path="$2"
35
+ hook_have jq || return 0
36
+ printf '%s' "$json" | jq -r "$path // empty" 2>/dev/null || true
37
+ }
38
+
39
+ # ── Paths ─────────────────────────────────────────────────────────────────────
40
+
41
+ # Absolutize $1 against $2 (a directory), leaving already-absolute paths untouched.
42
+ hook_abspath() {
43
+ local p="$1" base="${2:-$PWD}"
44
+ case "$p" in
45
+ /*) printf '%s\n' "$p" ;;
46
+ *) printf '%s\n' "${base%/}/$p" ;;
47
+ esac
48
+ }
49
+
50
+ # Make $1 relative to the root $2 when it lives underneath it.
51
+ hook_relpath() {
52
+ local p="$1" r="${2%/}"
53
+ case "$p" in
54
+ "$r"/*) printf '%s\n' "${p#"$r"/}" ;;
55
+ *) printf '%s\n' "$p" ;;
56
+ esac
57
+ }
58
+
59
+ # Glob dialect (gitignore/minimatch-like, matched against a repo-relative path):
60
+ # **/ any number of leading directories (including none)
61
+ # ** any characters, directory separators included
62
+ # * any characters except /
63
+ # ? one character except /
64
+ #
65
+ # The conversion is an awk source fragment shared by both matchers below rather than
66
+ # copied into each, so the two directions cannot drift apart.
67
+ hook_glob_awk_lib='
68
+ function g2re(g, out, i, n, c, c2, c3) {
69
+ out = "^"; n = length(g)
70
+ for (i = 1; i <= n; i++) {
71
+ c = substr(g, i, 1)
72
+ if (c == "*") {
73
+ c2 = substr(g, i + 1, 1); c3 = substr(g, i + 2, 1)
74
+ if (c2 == "*" && c3 == "/") { out = out "(.*/)?"; i += 2 }
75
+ else if (c2 == "*") { out = out ".*"; i += 1 }
76
+ else { out = out "[^/]*" }
77
+ } else if (c == "?") {
78
+ out = out "[^/]"
79
+ } else if (index(".[]()+^$\\{}|", c) > 0) {
80
+ out = out "\\" c
81
+ } else {
82
+ out = out c
83
+ }
84
+ }
85
+ return out "$"
86
+ }
87
+ '
88
+
89
+ # Does path $1 match any of the newline-separated globs on stdin?
90
+ hook_glob_match() {
91
+ CRATIS_GLOB_PATH="$1" awk "$hook_glob_awk_lib"'
92
+ BEGIN { p = ENVIRON["CRATIS_GLOB_PATH"]; found = 0 }
93
+ { g = $0; sub(/^[ \t]+/, "", g); sub(/[ \t\r]+$/, "", g) }
94
+ g == "" { next }
95
+ { if (p ~ g2re(g)) { found = 1; exit } }
96
+ END { exit(found ? 0 : 1) }
97
+ '
98
+ }
99
+
100
+ # The other direction: print the FIRST newline-separated path on stdin matching any of the
101
+ # newline-separated globs in $1, and exit 0. Prints nothing and exits 1 when none matches,
102
+ # or when $1 holds no glob.
103
+ #
104
+ # hook_glob_match answers "does this one path match?" and costs a process per path, which is
105
+ # right for the handful of changed files a hook sees. Discovery asks the mirror question over
106
+ # every path in the repository, so it gets one awk pass instead of thousands of processes.
107
+ hook_glob_first_match() {
108
+ CRATIS_GLOBS="$1" awk "$hook_glob_awk_lib"'
109
+ BEGIN {
110
+ count = 0
111
+ n = split(ENVIRON["CRATIS_GLOBS"], globs, "\n")
112
+ for (i = 1; i <= n; i++) {
113
+ g = globs[i]; sub(/^[ \t]+/, "", g); sub(/[ \t\r]+$/, "", g)
114
+ if (g != "") res[++count] = g2re(g)
115
+ }
116
+ found = 0
117
+ }
118
+ count == 0 { exit }
119
+ { p = $0; sub(/[ \t\r]+$/, "", p) }
120
+ p == "" { next }
121
+ {
122
+ for (i = 1; i <= count; i++) {
123
+ if (p ~ res[i]) { print p; found = 1; exit }
124
+ }
125
+ }
126
+ END { exit(found ? 0 : 1) }
127
+ '
128
+ }
129
+
130
+ # ── Session state ─────────────────────────────────────────────────────────────
131
+
132
+ # Per-session scratch directory used to de-duplicate reminders. Session ids are
133
+ # sanitized so a hostile value cannot escape the scratch root.
134
+ hook_state_dir() {
135
+ local session="${1:-nosession}" base
136
+ session="$(printf '%s' "$session" | tr -c 'A-Za-z0-9._-' '_')"
137
+ [ -n "$session" ] || session="nosession"
138
+ base="${TMPDIR:-/tmp}"
139
+ base="${base%/}/cratis-hooks/$session"
140
+ mkdir -p "$base" 2>/dev/null || return 1
141
+ printf '%s\n' "$base"
142
+ }
143
+
144
+ # Record key $2 in the state file $1. Returns 0 the first time, 1 afterwards.
145
+ hook_claim_once() {
146
+ local file="$1" key="$2"
147
+ if [ -f "$file" ] && LC_ALL=C grep -Fqx -- "$key" "$file" 2>/dev/null; then
148
+ return 1
149
+ fi
150
+ printf '%s\n' "$key" >>"$file" 2>/dev/null || true
151
+ return 0
152
+ }