@friedbotstudio/create-baseline 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +222 -0
  3. package/bin/cli.js +247 -0
  4. package/obj/template/.claude/agents/swarm-worker.md +52 -0
  5. package/obj/template/.claude/bin/LICENSE +201 -0
  6. package/obj/template/.claude/bin/NOTICE +48 -0
  7. package/obj/template/.claude/commands/approve-spec.md +29 -0
  8. package/obj/template/.claude/commands/approve-swarm.md +27 -0
  9. package/obj/template/.claude/commands/grant-commit.md +19 -0
  10. package/obj/template/.claude/commands/init-project.md +191 -0
  11. package/obj/template/.claude/hooks/artifact_template_guard.sh +141 -0
  12. package/obj/template/.claude/hooks/consent_gate_grant.sh +89 -0
  13. package/obj/template/.claude/hooks/destructive_cmd_guard.sh +42 -0
  14. package/obj/template/.claude/hooks/env_guard.sh +36 -0
  15. package/obj/template/.claude/hooks/git_commit_guard.sh +93 -0
  16. package/obj/template/.claude/hooks/harness_continuation.sh +121 -0
  17. package/obj/template/.claude/hooks/lib/__pycache__/resume_writer.cpython-314.pyc +0 -0
  18. package/obj/template/.claude/hooks/lib/common.sh +328 -0
  19. package/obj/template/.claude/hooks/lib/resume_writer.py +341 -0
  20. package/obj/template/.claude/hooks/lint_runner.sh +55 -0
  21. package/obj/template/.claude/hooks/memory_pre_compact.sh +36 -0
  22. package/obj/template/.claude/hooks/memory_session_start.sh +244 -0
  23. package/obj/template/.claude/hooks/memory_stop.sh +173 -0
  24. package/obj/template/.claude/hooks/plantuml_syntax_guard.sh +161 -0
  25. package/obj/template/.claude/hooks/process_lifecycle_guard.sh +89 -0
  26. package/obj/template/.claude/hooks/setup_guard.sh +50 -0
  27. package/obj/template/.claude/hooks/spec_approval_guard.sh +81 -0
  28. package/obj/template/.claude/hooks/spec_design_calls_guard.sh +183 -0
  29. package/obj/template/.claude/hooks/spec_diagram_presence_guard.sh +141 -0
  30. package/obj/template/.claude/hooks/swarm_approval_guard.sh +39 -0
  31. package/obj/template/.claude/hooks/swarm_boundary_guard.sh +136 -0
  32. package/obj/template/.claude/hooks/tdd_order_guard.sh +176 -0
  33. package/obj/template/.claude/hooks/test_runner.sh +75 -0
  34. package/obj/template/.claude/hooks/tests/fixtures/ac008_byte_equal_reference.txt +12 -0
  35. package/obj/template/.claude/hooks/tests/memory_session_start_test.sh +285 -0
  36. package/obj/template/.claude/hooks/track_guard.sh +127 -0
  37. package/obj/template/.claude/hooks/verify_pass_guard.sh +88 -0
  38. package/obj/template/.claude/memory/README.md +108 -0
  39. package/obj/template/.claude/memory/_pending.md +15 -0
  40. package/obj/template/.claude/memory/_resume.md +12 -0
  41. package/obj/template/.claude/memory/conventions.md +26 -0
  42. package/obj/template/.claude/memory/decisions.md +29 -0
  43. package/obj/template/.claude/memory/landmarks.md +26 -0
  44. package/obj/template/.claude/memory/landmines.md +27 -0
  45. package/obj/template/.claude/memory/libraries.md +27 -0
  46. package/obj/template/.claude/memory/pending-questions.md +28 -0
  47. package/obj/template/.claude/project.json +221 -0
  48. package/obj/template/.claude/settings.json +110 -0
  49. package/obj/template/.claude/skills/archive/SKILL.md +48 -0
  50. package/obj/template/.claude/skills/archive/archive.sh +145 -0
  51. package/obj/template/.claude/skills/audit-baseline/SKILL.md +80 -0
  52. package/obj/template/.claude/skills/audit-baseline/audit.sh +919 -0
  53. package/obj/template/.claude/skills/brd/SKILL.md +44 -0
  54. package/obj/template/.claude/skills/brd/template.md +83 -0
  55. package/obj/template/.claude/skills/chore/SKILL.md +99 -0
  56. package/obj/template/.claude/skills/claude-automation-recommender/LICENSE +202 -0
  57. package/obj/template/.claude/skills/claude-automation-recommender/NOTICE +69 -0
  58. package/obj/template/.claude/skills/claude-automation-recommender/SKILL.md +358 -0
  59. package/obj/template/.claude/skills/claude-automation-recommender/references/hooks-patterns.md +226 -0
  60. package/obj/template/.claude/skills/claude-automation-recommender/references/mcp-servers.md +263 -0
  61. package/obj/template/.claude/skills/claude-automation-recommender/references/plugins-reference.md +98 -0
  62. package/obj/template/.claude/skills/claude-automation-recommender/references/skills-reference.md +408 -0
  63. package/obj/template/.claude/skills/claude-automation-recommender/references/subagent-templates.md +181 -0
  64. package/obj/template/.claude/skills/code-structure/SKILL.md +204 -0
  65. package/obj/template/.claude/skills/commit/SKILL.md +21 -0
  66. package/obj/template/.claude/skills/copywriting/SKILL.md +252 -0
  67. package/obj/template/.claude/skills/copywriting/evals/evals.json +111 -0
  68. package/obj/template/.claude/skills/copywriting/references/ai-writing-detection.md +200 -0
  69. package/obj/template/.claude/skills/copywriting/references/copy-frameworks.md +344 -0
  70. package/obj/template/.claude/skills/copywriting/references/natural-transitions.md +272 -0
  71. package/obj/template/.claude/skills/design-ui/SKILL.md +175 -0
  72. package/obj/template/.claude/skills/design-ui/references/design-vs-development.md +89 -0
  73. package/obj/template/.claude/skills/design-ui/references/intent-table.md +64 -0
  74. package/obj/template/.claude/skills/design-ui/references/orchestration.md +121 -0
  75. package/obj/template/.claude/skills/design-ui/references/state-machine.md +125 -0
  76. package/obj/template/.claude/skills/document/SKILL.md +66 -0
  77. package/obj/template/.claude/skills/documentation/SKILL.md +50 -0
  78. package/obj/template/.claude/skills/harness/SKILL.md +169 -0
  79. package/obj/template/.claude/skills/humanizer/SKILL.md +489 -0
  80. package/obj/template/.claude/skills/humanizer/references/ai-writing-detection.md +208 -0
  81. package/obj/template/.claude/skills/impeccable/PROJECT_NOTES.md +22 -0
  82. package/obj/template/.claude/skills/impeccable/SKILL.md +153 -0
  83. package/obj/template/.claude/skills/impeccable/agents/openai.yaml +4 -0
  84. package/obj/template/.claude/skills/impeccable/reference/adapt.md +190 -0
  85. package/obj/template/.claude/skills/impeccable/reference/animate.md +173 -0
  86. package/obj/template/.claude/skills/impeccable/reference/audit.md +134 -0
  87. package/obj/template/.claude/skills/impeccable/reference/bolder.md +113 -0
  88. package/obj/template/.claude/skills/impeccable/reference/brand.md +104 -0
  89. package/obj/template/.claude/skills/impeccable/reference/clarify.md +174 -0
  90. package/obj/template/.claude/skills/impeccable/reference/cognitive-load.md +106 -0
  91. package/obj/template/.claude/skills/impeccable/reference/color-and-contrast.md +105 -0
  92. package/obj/template/.claude/skills/impeccable/reference/colorize.md +154 -0
  93. package/obj/template/.claude/skills/impeccable/reference/craft.md +138 -0
  94. package/obj/template/.claude/skills/impeccable/reference/critique.md +213 -0
  95. package/obj/template/.claude/skills/impeccable/reference/delight.md +302 -0
  96. package/obj/template/.claude/skills/impeccable/reference/distill.md +111 -0
  97. package/obj/template/.claude/skills/impeccable/reference/document.md +427 -0
  98. package/obj/template/.claude/skills/impeccable/reference/extract.md +70 -0
  99. package/obj/template/.claude/skills/impeccable/reference/harden.md +347 -0
  100. package/obj/template/.claude/skills/impeccable/reference/heuristics-scoring.md +234 -0
  101. package/obj/template/.claude/skills/impeccable/reference/interaction-design.md +195 -0
  102. package/obj/template/.claude/skills/impeccable/reference/layout.md +141 -0
  103. package/obj/template/.claude/skills/impeccable/reference/live.md +513 -0
  104. package/obj/template/.claude/skills/impeccable/reference/motion-design.md +99 -0
  105. package/obj/template/.claude/skills/impeccable/reference/onboard.md +234 -0
  106. package/obj/template/.claude/skills/impeccable/reference/optimize.md +258 -0
  107. package/obj/template/.claude/skills/impeccable/reference/overdrive.md +130 -0
  108. package/obj/template/.claude/skills/impeccable/reference/personas.md +178 -0
  109. package/obj/template/.claude/skills/impeccable/reference/polish.md +232 -0
  110. package/obj/template/.claude/skills/impeccable/reference/product.md +62 -0
  111. package/obj/template/.claude/skills/impeccable/reference/quieter.md +99 -0
  112. package/obj/template/.claude/skills/impeccable/reference/responsive-design.md +114 -0
  113. package/obj/template/.claude/skills/impeccable/reference/shape.md +136 -0
  114. package/obj/template/.claude/skills/impeccable/reference/spatial-design.md +100 -0
  115. package/obj/template/.claude/skills/impeccable/reference/teach.md +137 -0
  116. package/obj/template/.claude/skills/impeccable/reference/typeset.md +124 -0
  117. package/obj/template/.claude/skills/impeccable/reference/typography.md +159 -0
  118. package/obj/template/.claude/skills/impeccable/reference/ux-writing.md +107 -0
  119. package/obj/template/.claude/skills/impeccable/scripts/cleanup-deprecated.mjs +284 -0
  120. package/obj/template/.claude/skills/impeccable/scripts/command-metadata.json +94 -0
  121. package/obj/template/.claude/skills/impeccable/scripts/design-parser.mjs +820 -0
  122. package/obj/template/.claude/skills/impeccable/scripts/detect-csp.mjs +198 -0
  123. package/obj/template/.claude/skills/impeccable/scripts/is-generated.mjs +69 -0
  124. package/obj/template/.claude/skills/impeccable/scripts/live-accept.mjs +465 -0
  125. package/obj/template/.claude/skills/impeccable/scripts/live-browser.js +4684 -0
  126. package/obj/template/.claude/skills/impeccable/scripts/live-inject.mjs +436 -0
  127. package/obj/template/.claude/skills/impeccable/scripts/live-poll.mjs +187 -0
  128. package/obj/template/.claude/skills/impeccable/scripts/live-server.mjs +679 -0
  129. package/obj/template/.claude/skills/impeccable/scripts/live-wrap.mjs +395 -0
  130. package/obj/template/.claude/skills/impeccable/scripts/live.mjs +247 -0
  131. package/obj/template/.claude/skills/impeccable/scripts/load-context.mjs +93 -0
  132. package/obj/template/.claude/skills/impeccable/scripts/modern-screenshot.umd.js +14 -0
  133. package/obj/template/.claude/skills/impeccable/scripts/pin.mjs +214 -0
  134. package/obj/template/.claude/skills/implement/SKILL.md +83 -0
  135. package/obj/template/.claude/skills/intake/SKILL.md +46 -0
  136. package/obj/template/.claude/skills/intake/template.md +61 -0
  137. package/obj/template/.claude/skills/integrate/SKILL.md +62 -0
  138. package/obj/template/.claude/skills/memory-flush/SKILL.md +172 -0
  139. package/obj/template/.claude/skills/memory-flush/sweep.py +286 -0
  140. package/obj/template/.claude/skills/memory-flush/tests/run.sh +327 -0
  141. package/obj/template/.claude/skills/prose/SKILL.md +119 -0
  142. package/obj/template/.claude/skills/rca/SKILL.md +42 -0
  143. package/obj/template/.claude/skills/rca/template.md +83 -0
  144. package/obj/template/.claude/skills/research/SKILL.md +75 -0
  145. package/obj/template/.claude/skills/scenario/SKILL.md +64 -0
  146. package/obj/template/.claude/skills/scout/SKILL.md +72 -0
  147. package/obj/template/.claude/skills/security/SKILL.md +75 -0
  148. package/obj/template/.claude/skills/simplify/SKILL.md +67 -0
  149. package/obj/template/.claude/skills/spec/SKILL.md +69 -0
  150. package/obj/template/.claude/skills/spec/template.md +274 -0
  151. package/obj/template/.claude/skills/spec-diagram-review/SKILL.md +81 -0
  152. package/obj/template/.claude/skills/spec-lint/SKILL.md +55 -0
  153. package/obj/template/.claude/skills/spec-lint/lint.sh +218 -0
  154. package/obj/template/.claude/skills/spec-render/SKILL.md +45 -0
  155. package/obj/template/.claude/skills/spec-render/render.sh +109 -0
  156. package/obj/template/.claude/skills/spec-traceability-review/SKILL.md +72 -0
  157. package/obj/template/.claude/skills/swarm-dispatch/SKILL.md +212 -0
  158. package/obj/template/.claude/skills/swarm-dispatch/swarm_merge.sh +154 -0
  159. package/obj/template/.claude/skills/swarm-plan/SKILL.md +90 -0
  160. package/obj/template/.claude/skills/swarm-plan/validate.sh +181 -0
  161. package/obj/template/.claude/skills/tdd/SKILL.md +100 -0
  162. package/obj/template/.claude/skills/technical-tutorials/SKILL.md +569 -0
  163. package/obj/template/.claude/skills/technical-tutorials/references/audience-context-README.md +53 -0
  164. package/obj/template/.claude/skills/technical-tutorials/references/audience-context.md +246 -0
  165. package/obj/template/.claude/skills/technical-tutorials/references/audience-example.md +175 -0
  166. package/obj/template/.claude/skills/technical-tutorials/references/audience-template.md +152 -0
  167. package/obj/template/.claude/skills/triage/SKILL.md +55 -0
  168. package/obj/template/.claude/skills/verify/SKILL.md +74 -0
  169. package/obj/template/.mcp.json +24 -0
  170. package/obj/template/CLAUDE.md +327 -0
  171. package/obj/template/docs/init/seed.md +585 -0
  172. package/obj/template/manifest.json +214 -0
  173. package/package.json +48 -0
  174. package/src/.mcp.template.json +24 -0
  175. package/src/.npmrc.template +2 -0
  176. package/src/CLAUDE.template.md +327 -0
  177. package/src/agents/swarm-worker.template.md +51 -0
  178. package/src/cli/conflict.js +31 -0
  179. package/src/cli/doctor.js +152 -0
  180. package/src/cli/install.js +93 -0
  181. package/src/cli/io.js +27 -0
  182. package/src/cli/manifest.js +38 -0
  183. package/src/cli/mcp.js +54 -0
  184. package/src/cli/merge.js +107 -0
  185. package/src/cli/plantuml.js +121 -0
  186. package/src/cli/util.js +10 -0
  187. package/src/memory/_pending.template.md +15 -0
  188. package/src/memory/_resume.template.md +12 -0
  189. package/src/memory/conventions.template.md +26 -0
  190. package/src/memory/decisions.template.md +29 -0
  191. package/src/memory/landmarks.template.md +26 -0
  192. package/src/memory/landmines.template.md +27 -0
  193. package/src/memory/libraries.template.md +27 -0
  194. package/src/memory/pending-questions.template.md +28 -0
  195. package/src/project.template.json +221 -0
  196. package/src/seed.template.md +585 -0
  197. package/src/settings.template.json +110 -0
@@ -0,0 +1,328 @@
1
+ #!/usr/bin/env bash
2
+ # Shared helpers for baseline Claude Code hook scripts.
3
+ # Sourced by every hook in .claude/hooks/.
4
+ #
5
+ # Contract:
6
+ # - Hooks receive a JSON payload on stdin (the Claude Code hook event).
7
+ # - Hooks emit JSON to stdout for structured decisions, or exit non-zero with
8
+ # a stderr message to block/warn.
9
+ # - All hooks must be resilient to a missing/invalid project.json.
10
+ #
11
+ # Dependencies: bash >= 4, python3 (JSON parsing — POSIX-portable enough for
12
+ # macOS + modern Linux). No jq requirement.
13
+
14
+ set -u
15
+
16
+ # Defensive PATH: hooks may be invoked with a minimal environment. Ensure the
17
+ # standard utilities (dirname, cat, date, etc.) and python3 are findable.
18
+ export PATH="${PATH:-}:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/usr/sbin:/sbin"
19
+ if [ -z "${PATH%%:*}" ]; then
20
+ PATH="${PATH#:}"
21
+ export PATH
22
+ fi
23
+
24
+ CLAUDE_PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
25
+ CLAUDE_DOTDIR="$CLAUDE_PROJECT_ROOT/.claude"
26
+ PROJECT_JSON="$CLAUDE_DOTDIR/project.json"
27
+ STATE_DIR="$CLAUDE_DOTDIR/state"
28
+ LOG_DIR="$STATE_DIR/logs"
29
+ mkdir -p "$STATE_DIR" "$LOG_DIR" 2>/dev/null || true
30
+
31
+ # Read the raw hook JSON payload from stdin into HOOK_PAYLOAD.
32
+ read_payload() {
33
+ HOOK_PAYLOAD="$(cat)"
34
+ export HOOK_PAYLOAD
35
+ }
36
+
37
+ # Extract a field from the hook payload using a jsonpath-ish dotted path.
38
+ # Usage: payload_get '.tool_input.command'
39
+ # Requires read_payload to have been called first (so HOOK_PAYLOAD is set).
40
+ payload_get() {
41
+ local path="$1"
42
+ python3 - "$path" <<'PY'
43
+ import json, os, sys
44
+ path = sys.argv[1]
45
+ raw = os.environ.get("HOOK_PAYLOAD", "")
46
+ if not raw:
47
+ sys.exit(0)
48
+ try:
49
+ data = json.loads(raw)
50
+ except Exception:
51
+ sys.exit(0)
52
+ cur = data
53
+ for part in path.strip('.').split('.'):
54
+ if part == '':
55
+ continue
56
+ if isinstance(cur, dict):
57
+ cur = cur.get(part)
58
+ else:
59
+ cur = None
60
+ break
61
+ if cur is None:
62
+ sys.exit(0)
63
+ if isinstance(cur, (dict, list)):
64
+ print(json.dumps(cur))
65
+ else:
66
+ print(cur)
67
+ PY
68
+ }
69
+
70
+ # Read a field from .claude/project.json at a dotted path.
71
+ # Prints empty string if project.json or key is missing.
72
+ project_get() {
73
+ local path="$1"
74
+ [ -f "$PROJECT_JSON" ] || { echo ""; return 0; }
75
+ python3 - "$path" "$PROJECT_JSON" <<'PY'
76
+ import json, sys
77
+ path, pj = sys.argv[1], sys.argv[2]
78
+ try:
79
+ with open(pj) as f:
80
+ data = json.load(f)
81
+ except Exception:
82
+ sys.exit(0)
83
+ cur = data
84
+ for part in path.strip('.').split('.'):
85
+ if part == '':
86
+ continue
87
+ if isinstance(cur, dict):
88
+ cur = cur.get(part)
89
+ else:
90
+ cur = None
91
+ break
92
+ if cur is None:
93
+ sys.exit(0)
94
+ if isinstance(cur, (dict, list)):
95
+ print(json.dumps(cur))
96
+ else:
97
+ print(cur)
98
+ PY
99
+ }
100
+
101
+ # Emit a structured block decision (PreToolUse).
102
+ # Usage: emit_block "reason text"
103
+ emit_block() {
104
+ local reason="$1"
105
+ python3 - "$reason" <<'PY'
106
+ import json, sys
107
+ print(json.dumps({
108
+ "hookSpecificOutput": {
109
+ "hookEventName": "PreToolUse",
110
+ "permissionDecision": "deny",
111
+ "permissionDecisionReason": sys.argv[1],
112
+ }
113
+ }))
114
+ PY
115
+ exit 0
116
+ }
117
+
118
+ # Emit a structured ask decision (PreToolUse).
119
+ emit_ask() {
120
+ local reason="$1"
121
+ python3 - "$reason" <<'PY'
122
+ import json, sys
123
+ print(json.dumps({
124
+ "hookSpecificOutput": {
125
+ "hookEventName": "PreToolUse",
126
+ "permissionDecision": "ask",
127
+ "permissionDecisionReason": sys.argv[1],
128
+ }
129
+ }))
130
+ PY
131
+ exit 0
132
+ }
133
+
134
+ # Emit an allow (no-op) decision. Equivalent to exit 0 with no output, but
135
+ # explicit for clarity.
136
+ emit_allow() {
137
+ exit 0
138
+ }
139
+
140
+ # Emit an informational message (PostToolUse or advisory PreToolUse). Does not
141
+ # block. Printed to stderr so it surfaces in the transcript without polluting
142
+ # stdout (which Claude Code interprets as structured output).
143
+ emit_info() {
144
+ printf '%s\n' "$1" >&2
145
+ }
146
+
147
+ # Append a line to a hook-specific log for debugging. Never fails the hook.
148
+ log_line() {
149
+ local hook="$1" msg="$2"
150
+ printf '%s %s\n' "$(date -u +%FT%TZ)" "$msg" >>"$LOG_DIR/$hook.log" 2>/dev/null || true
151
+ }
152
+
153
+ # True if the given path matches any glob in a JSON array (from project_get).
154
+ # Usage: path_matches_globs "src/foo.py" "$(project_get .tdd.source_globs)"
155
+ path_matches_globs() {
156
+ local path="$1" globs_json="$2"
157
+ [ -z "$globs_json" ] && return 1
158
+ python3 - "$path" "$globs_json" <<'PY'
159
+ import json, sys, fnmatch
160
+ path, globs_json = sys.argv[1], sys.argv[2]
161
+ try:
162
+ globs = json.loads(globs_json)
163
+ except Exception:
164
+ sys.exit(1)
165
+ if not isinstance(globs, list):
166
+ sys.exit(1)
167
+ for g in globs:
168
+ if fnmatch.fnmatchcase(path, g) or fnmatch.fnmatchcase(path, g.rstrip('/**') + '/*'):
169
+ sys.exit(0)
170
+ # also handle simple ** recursion
171
+ if '**' in g:
172
+ # translate ** to match any depth
173
+ import re
174
+ pat = re.escape(g).replace(r'\*\*', '.*').replace(r'\*', '[^/]*').replace(r'\?', '.')
175
+ if re.fullmatch(pat, path):
176
+ sys.exit(0)
177
+ sys.exit(1)
178
+ PY
179
+ }
180
+
181
+ # True if stdin command matches any pattern in a JSON-array-of-regex.
182
+ # Usage: cmd_matches_any "$cmd" "$(project_get .destructive.hard_block_patterns)"
183
+ cmd_matches_any() {
184
+ local cmd="$1" patterns_json="$2"
185
+ [ -z "$patterns_json" ] && return 1
186
+ python3 - "$cmd" "$patterns_json" <<'PY'
187
+ import json, sys, re
188
+ cmd, patterns_json = sys.argv[1], sys.argv[2]
189
+ try:
190
+ patterns = json.loads(patterns_json)
191
+ except Exception:
192
+ sys.exit(1)
193
+ for p in patterns:
194
+ try:
195
+ if re.search(p, cmd):
196
+ sys.exit(0)
197
+ except re.error:
198
+ continue
199
+ sys.exit(1)
200
+ PY
201
+ }
202
+
203
+ # Canonicalize a path and make it relative to CLAUDE_PROJECT_ROOT.
204
+ # Lexical only — collapses ./ and ../ via os.path.normpath, does NOT resolve
205
+ # symlinks. This is deliberate: symlink resolution would let a path like
206
+ # .claude/state/spec_approvals/foo.approval (whose final component is a
207
+ # symlink to /tmp/x) silently redirect, defeating the case-pattern checks.
208
+ # Symlink-swap defense is a separate hardening (see seed.md §16 follow-ups).
209
+ #
210
+ # Returns the project-relative canonical path on stdout, or an absolute
211
+ # canonical path if the input escapes the project root. Empty if input is
212
+ # empty or equals the project root.
213
+ canonical_rel() {
214
+ python3 - "$1" "$CLAUDE_PROJECT_ROOT" <<'PY'
215
+ import os, sys
216
+ file_path = sys.argv[1]
217
+ root = sys.argv[2]
218
+ if not file_path:
219
+ sys.exit(0)
220
+ norm = os.path.normpath(os.path.abspath(file_path))
221
+ norm_root = os.path.normpath(os.path.abspath(root))
222
+ sep = os.sep
223
+ if norm == norm_root:
224
+ print("")
225
+ elif norm.startswith(norm_root + sep):
226
+ print(norm[len(norm_root) + 1:])
227
+ else:
228
+ print(norm)
229
+ PY
230
+ }
231
+
232
+ # Consent-gate marker file paths — written ONLY by consent_gate_grant.sh
233
+ # (UserPromptSubmit) when the user invokes the corresponding slash command,
234
+ # read by the gate guards (PreToolUse) before allowing approval-token writes.
235
+ # Hooks reference these constants instead of literal paths so a rename is a
236
+ # one-line change. Relative variants are for case-pattern matching against
237
+ # tool_input.file_path after CLAUDE_PROJECT_ROOT stripping.
238
+ CONSENT_MARKER_SPEC="$STATE_DIR/.spec_approval_grant"
239
+ CONSENT_MARKER_SWARM="$STATE_DIR/.swarm_approval_grant"
240
+ CONSENT_MARKER_COMMIT="$STATE_DIR/.commit_consent_grant"
241
+ CONSENT_MARKER_SPEC_REL=".claude/state/.spec_approval_grant"
242
+ CONSENT_MARKER_SWARM_REL=".claude/state/.swarm_approval_grant"
243
+ CONSENT_MARKER_COMMIT_REL=".claude/state/.commit_consent_grant"
244
+
245
+ # Reduce any user-typed approval argument (bare slug, filename, or path) to a
246
+ # bare slug. Called from BOTH ends of the consent handshake so the marker and
247
+ # the expected-slug check produce the same shape:
248
+ # docs/specs/foo.md -> foo
249
+ # foo.md -> foo
250
+ # foo -> foo
251
+ # approval-slug-canonicalization -> approval-slug-canonicalization
252
+ # Also used by the approval guards: feed in basename(file, .approval) to
253
+ # canonicalize legacy `<slug>.md.approval` and current `<slug>.approval` to
254
+ # the same bare slug, so both filename shapes validate.
255
+ canonical_slug() {
256
+ local s="${1##*/}"
257
+ printf '%s' "${s%.md}"
258
+ }
259
+
260
+ # Block Claude from writing a consent-marker file via Write/Edit/MultiEdit.
261
+ # The marker's unforgeability is what makes the consent gate structural —
262
+ # only consent_gate_grant (UserPromptSubmit, outside Claude's tool boundary)
263
+ # may produce it.
264
+ #
265
+ # Args: $1 rel_path $2 marker_rel_path $3 gate_label $4 user_command_hint
266
+ # Calls emit_block (which exits) on match; returns 0 otherwise.
267
+ block_marker_self_write() {
268
+ local rel="$1" marker_rel="$2" gate_label="$3" cmd_hint="$4"
269
+ local hook_log="${gate_label// /_}"
270
+ hook_log="${hook_log,,}"
271
+ if [ "$rel" = "$marker_rel" ]; then
272
+ log_line "$hook_log" "BLOCKED direct write to consent marker: $rel"
273
+ emit_block "$gate_label: '$rel' is a consent marker written by the consent_gate_grant UserPromptSubmit hook in response to \`$cmd_hint\`. Claude is not permitted to create or edit this marker — its unforgeability is what makes the gate structurally enforced."
274
+ fi
275
+ }
276
+
277
+ # Validate a consent marker (freshness + optional slug match) and consume it.
278
+ # emit_blocks (and exits) on any failure; returns 0 on success after deleting
279
+ # the marker. TTL comes from .consent.gate_marker_ttl_seconds (default 60).
280
+ #
281
+ # Args: $1 marker_path $2 gate_label $3 user_command_hint [$4 expected_slug]
282
+ #
283
+ # Marker shape:
284
+ # - With slug ($4 non-empty): line 1 = slug, line 2 = epoch.
285
+ # - Epoch-only ($4 empty): line 1 = epoch.
286
+ validate_consent_marker() {
287
+ local marker="$1" gate_label="$2" cmd_hint="$3" expected_slug="${4:-}"
288
+ local hook_log ttl marker_slug marker_epoch now age
289
+ hook_log="${gate_label// /_}"
290
+ hook_log="${hook_log,,}"
291
+
292
+ ttl="$(project_get .consent.gate_marker_ttl_seconds)"
293
+ [ -z "$ttl" ] && ttl=120
294
+
295
+ if [ ! -f "$marker" ]; then
296
+ log_line "$hook_log" "BLOCKED no marker: $marker"
297
+ emit_block "$gate_label: requires a fresh consent marker at $marker. The marker is produced by the consent_gate_grant hook when the user runs \`$cmd_hint\` — Claude cannot create it."
298
+ fi
299
+
300
+ if [ -n "$expected_slug" ]; then
301
+ { read -r marker_slug; read -r marker_epoch; } < "$marker" 2>/dev/null
302
+ else
303
+ read -r marker_epoch < "$marker" 2>/dev/null
304
+ marker_slug=""
305
+ fi
306
+
307
+ if ! [[ "$marker_epoch" =~ ^[0-9]+$ ]]; then
308
+ log_line "$hook_log" "BLOCKED malformed marker: $marker"
309
+ emit_block "$gate_label: marker at $marker is malformed. Ask the user to re-run \`$cmd_hint\`."
310
+ fi
311
+
312
+ now="$(date +%s)"
313
+ age=$(( now - marker_epoch ))
314
+ if [ "$age" -gt "$ttl" ]; then
315
+ log_line "$hook_log" "BLOCKED marker expired age=${age}s ttl=${ttl}s"
316
+ rm -f "$marker"
317
+ emit_block "$gate_label: consent marker expired (${age}s old, TTL ${ttl}s). Ask the user to re-run \`$cmd_hint\`."
318
+ fi
319
+
320
+ if [ -n "$expected_slug" ] && [ "$marker_slug" != "$expected_slug" ]; then
321
+ log_line "$hook_log" "BLOCKED slug mismatch marker=$marker_slug expected=$expected_slug"
322
+ emit_block "$gate_label: marker slug ($marker_slug) does not match expected ($expected_slug). Ask the user to re-run \`$cmd_hint\` with the correct argument."
323
+ fi
324
+
325
+ log_line "$hook_log" "ALLOWED marker=$marker age=${age}s slug=${marker_slug:-N/A}"
326
+ rm -f "$marker"
327
+ return 0
328
+ }
@@ -0,0 +1,341 @@
1
+ #!/usr/bin/env python3
2
+ """Continuity snapshot writer.
3
+
4
+ Walks a Claude Code transcript JSONL plus state files and writes a
5
+ single-snapshot `.claude/memory/_resume.md`. Shared by:
6
+
7
+ - memory_pre_compact.sh (PreCompact event — capture before compaction)
8
+ - memory_stop.sh (Stop event — refresh every turn-end)
9
+
10
+ The snapshot answers "where were we / what's next?" so a session that
11
+ gets compacted, /clear'd, or resumed in a new shell can pick up without
12
+ the user re-explaining context.
13
+
14
+ Heuristic only: scrapes the transcript for recent user prompts, recent
15
+ file writes, and the most recent Skill invocation, then merges with
16
+ .claude/state/workflow.json if present.
17
+
18
+ Invoked as:
19
+ python3 resume_writer.py <transcript_path> <project_dir> <trigger>
20
+
21
+ trigger is one of: pre-compact, stop, harness — recorded in frontmatter.
22
+ Exits 0 on success or any failure (this is best-effort; it must never
23
+ break the hook that called it).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ import re
31
+ import sys
32
+ from datetime import datetime, timezone
33
+ from pathlib import Path
34
+ from typing import Iterable
35
+
36
+ # How many most-recent items of each kind to retain in the snapshot.
37
+ MAX_USER_PROMPTS = 3
38
+ MAX_FILES = 12
39
+ MAX_SKILLS = 5
40
+ MAX_BASH = 5
41
+ USER_PROMPT_CHARS = 400 # truncate per-prompt; the snapshot is bounded
42
+
43
+
44
+ def _utc_now_iso() -> str:
45
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
46
+
47
+
48
+ def _read_json(path: Path) -> dict | list | None:
49
+ try:
50
+ return json.loads(path.read_text(encoding="utf-8"))
51
+ except Exception:
52
+ return None
53
+
54
+
55
+ def _iter_transcript_events(transcript: Path) -> Iterable[dict]:
56
+ if not transcript.is_file():
57
+ return
58
+ try:
59
+ with transcript.open("r", encoding="utf-8", errors="replace") as f:
60
+ for line in f:
61
+ line = line.strip()
62
+ if not line:
63
+ continue
64
+ try:
65
+ yield json.loads(line)
66
+ except Exception:
67
+ continue
68
+ except Exception:
69
+ return
70
+
71
+
72
+ def _extract_text_blocks(content) -> list[str]:
73
+ """A message's `content` is either a string or list of typed blocks."""
74
+ out: list[str] = []
75
+ if isinstance(content, str):
76
+ if content.strip():
77
+ out.append(content.strip())
78
+ return out
79
+ if not isinstance(content, list):
80
+ return out
81
+ for block in content:
82
+ if not isinstance(block, dict):
83
+ continue
84
+ if block.get("type") == "text":
85
+ t = block.get("text", "")
86
+ if isinstance(t, str) and t.strip():
87
+ out.append(t.strip())
88
+ return out
89
+
90
+
91
+ def _walk(transcript: Path) -> dict:
92
+ """Single pass over the transcript collecting everything we need."""
93
+ user_prompts: list[str] = []
94
+ file_writes: list[tuple[str, str]] = [] # (path, tool)
95
+ skill_calls: list[str] = []
96
+ bash_cmds: list[str] = []
97
+ last_assistant_text: str = ""
98
+
99
+ for ev in _iter_transcript_events(transcript):
100
+ msg = ev.get("message") if isinstance(ev, dict) else None
101
+ if not isinstance(msg, dict):
102
+ # Some transcripts put role/content at top level.
103
+ msg = ev if isinstance(ev, dict) else {}
104
+ role = msg.get("role") or ev.get("role") if isinstance(ev, dict) else None
105
+ content = msg.get("content")
106
+
107
+ if role == "user":
108
+ for t in _extract_text_blocks(content):
109
+ # Skip system-reminder noise and hook injections.
110
+ if t.startswith("<system-reminder>") or "<command-name>" in t[:64]:
111
+ continue
112
+ if t.startswith("<local-command-"):
113
+ continue
114
+ user_prompts.append(t)
115
+ elif role == "assistant":
116
+ text_blocks = _extract_text_blocks(content)
117
+ if text_blocks:
118
+ last_assistant_text = text_blocks[-1]
119
+ if isinstance(content, list):
120
+ for block in content:
121
+ if not isinstance(block, dict):
122
+ continue
123
+ if block.get("type") != "tool_use":
124
+ continue
125
+ name = block.get("name", "")
126
+ inp = block.get("input") or {}
127
+ if name in ("Edit", "Write", "MultiEdit"):
128
+ fp = inp.get("file_path") or ""
129
+ if fp:
130
+ file_writes.append((fp, name))
131
+ elif name == "Skill":
132
+ sk = inp.get("skill") or ""
133
+ if sk:
134
+ skill_calls.append(sk)
135
+ elif name == "Bash":
136
+ cmd = inp.get("command") or ""
137
+ if cmd:
138
+ bash_cmds.append(cmd.strip().splitlines()[0][:160])
139
+
140
+ return {
141
+ "user_prompts": user_prompts,
142
+ "file_writes": file_writes,
143
+ "skill_calls": skill_calls,
144
+ "bash_cmds": bash_cmds,
145
+ "last_assistant_text": last_assistant_text,
146
+ }
147
+
148
+
149
+ def _read_workflow(project_dir: Path) -> dict:
150
+ """Return whatever .claude/state/workflow.json holds, or {}."""
151
+ p = project_dir / ".claude" / "state" / "workflow.json"
152
+ data = _read_json(p)
153
+ return data if isinstance(data, dict) else {}
154
+
155
+
156
+ def _last_harness_log_line(project_dir: Path, slug: str) -> str:
157
+ log = project_dir / ".claude" / "state" / "harness" / f"{slug}.log"
158
+ if not log.is_file():
159
+ return ""
160
+ try:
161
+ lines = [ln for ln in log.read_text(encoding="utf-8", errors="replace").splitlines() if ln.strip()]
162
+ return lines[-1] if lines else ""
163
+ except Exception:
164
+ return ""
165
+
166
+
167
+ def _rel(path: str, project_dir: Path) -> str:
168
+ """Make project-relative if possible."""
169
+ try:
170
+ p = Path(path)
171
+ if p.is_absolute():
172
+ return str(p.relative_to(project_dir))
173
+ except Exception:
174
+ pass
175
+ return path
176
+
177
+
178
+ def _dedup_keep_order(items: Iterable[str]) -> list[str]:
179
+ seen: set[str] = set()
180
+ out: list[str] = []
181
+ for it in items:
182
+ if it in seen:
183
+ continue
184
+ seen.add(it)
185
+ out.append(it)
186
+ return out
187
+
188
+
189
+ def compose_snapshot(transcript: Path, project_dir: Path, trigger: str) -> str:
190
+ walk = _walk(transcript)
191
+ workflow = _read_workflow(project_dir)
192
+
193
+ slug = workflow.get("slug") or "(none)"
194
+ entry_phase = workflow.get("entry_phase") or "(unknown)"
195
+ completed = workflow.get("completed") or []
196
+ exceptions = workflow.get("exceptions") or []
197
+ phases = workflow.get("phases") or []
198
+
199
+ # Next phase = first phase not in completed, after entry_phase.
200
+ next_phase = "(unknown)"
201
+ if phases and isinstance(completed, list):
202
+ try:
203
+ start = phases.index(entry_phase) if entry_phase in phases else 0
204
+ except ValueError:
205
+ start = 0
206
+ for ph in phases[start:]:
207
+ if ph in exceptions:
208
+ continue
209
+ if ph not in completed:
210
+ next_phase = ph
211
+ break
212
+ else:
213
+ next_phase = "(workflow complete)"
214
+
215
+ last_completed = completed[-1] if completed else "(none)"
216
+
217
+ # File writes — most recent unique paths, project-relative.
218
+ files_recent = []
219
+ for fp, _tool in reversed(walk["file_writes"]):
220
+ rel = _rel(fp, project_dir)
221
+ if rel.startswith(".claude/state/") or rel.startswith(".claude/memory/_pending"):
222
+ continue
223
+ if rel not in files_recent:
224
+ files_recent.append(rel)
225
+ if len(files_recent) >= MAX_FILES:
226
+ break
227
+
228
+ # User prompts — last K, most-recent first.
229
+ prompts_recent = walk["user_prompts"][-MAX_USER_PROMPTS:]
230
+ prompts_recent = list(reversed(prompts_recent))
231
+
232
+ # Skill calls — last K, dedup keep-order, most-recent first.
233
+ skills_recent = list(reversed(_dedup_keep_order(walk["skill_calls"][-MAX_SKILLS * 3:])))[:MAX_SKILLS]
234
+
235
+ # Bash — last K (chatty, so keep small).
236
+ bash_recent = walk["bash_cmds"][-MAX_BASH:]
237
+ bash_recent = list(reversed(bash_recent))
238
+
239
+ last_log = _last_harness_log_line(project_dir, slug) if slug != "(none)" else ""
240
+
241
+ # Continue-with hint.
242
+ if slug == "(none)":
243
+ hint = "No active workflow. Run `/triage \"<request>\"` to start one, or `/harness` if you have a concrete request."
244
+ elif next_phase == "(workflow complete)":
245
+ hint = f"Workflow `{slug}` is complete. Run `/grant-commit` then `/harness` to commit."
246
+ else:
247
+ hint = f"Run `/harness` to resume `{slug}` at phase `{next_phase}`."
248
+
249
+ # Compose markdown.
250
+ lines: list[str] = []
251
+ lines.append("---")
252
+ lines.append("name: resume")
253
+ lines.append("type: continuity")
254
+ lines.append(f"last-updated: {_utc_now_iso()}")
255
+ lines.append(f"trigger: {trigger}")
256
+ lines.append("---")
257
+ lines.append("")
258
+ lines.append("# Resume snapshot")
259
+ lines.append("")
260
+ lines.append("## Active workflow")
261
+ lines.append(f"- Slug: `{slug}`")
262
+ lines.append(f"- Entry phase: `{entry_phase}`")
263
+ lines.append(f"- Last completed phase: `{last_completed}`")
264
+ lines.append(f"- Next phase due: `{next_phase}`")
265
+ if exceptions:
266
+ lines.append(f"- Exceptions: {', '.join(f'`{e}`' for e in exceptions)}")
267
+ if last_log:
268
+ lines.append(f"- Last harness log: `{last_log}`")
269
+ lines.append("")
270
+
271
+ lines.append("## In-flight files (most recent writes this session)")
272
+ if files_recent:
273
+ for fp in files_recent:
274
+ lines.append(f"- `{fp}`")
275
+ else:
276
+ lines.append("- (none captured)")
277
+ lines.append("")
278
+
279
+ lines.append("## Recent skill invocations")
280
+ if skills_recent:
281
+ for sk in skills_recent:
282
+ lines.append(f"- `/{sk}`")
283
+ else:
284
+ lines.append("- (none captured)")
285
+ lines.append("")
286
+
287
+ if bash_recent:
288
+ lines.append("## Recent shell commands")
289
+ for cmd in bash_recent:
290
+ lines.append(f"- `{cmd}`")
291
+ lines.append("")
292
+
293
+ lines.append("## Recent user requests (most recent first)")
294
+ if prompts_recent:
295
+ for p in prompts_recent:
296
+ text = p.replace("\r", " ")
297
+ if len(text) > USER_PROMPT_CHARS:
298
+ text = text[:USER_PROMPT_CHARS].rstrip() + "…"
299
+ block = "\n".join(f"> {ln}" for ln in text.splitlines())
300
+ lines.append(block)
301
+ lines.append("")
302
+ else:
303
+ lines.append("- (none captured)")
304
+ lines.append("")
305
+
306
+ lines.append("## Continue with")
307
+ lines.append(hint)
308
+ lines.append("")
309
+
310
+ return "\n".join(lines)
311
+
312
+
313
+ def write_snapshot(transcript: Path, project_dir: Path, trigger: str) -> Path | None:
314
+ mem_dir = project_dir / ".claude" / "memory"
315
+ if not mem_dir.is_dir():
316
+ return None
317
+ body = compose_snapshot(transcript, project_dir, trigger)
318
+ out = mem_dir / "_resume.md"
319
+ try:
320
+ out.write_text(body, encoding="utf-8")
321
+ return out
322
+ except Exception:
323
+ return None
324
+
325
+
326
+ def main(argv: list[str]) -> int:
327
+ if len(argv) < 4:
328
+ sys.stderr.write("usage: resume_writer.py <transcript> <project_dir> <trigger>\n")
329
+ return 0 # never fail the caller
330
+ try:
331
+ transcript = Path(argv[1])
332
+ project_dir = Path(argv[2])
333
+ trigger = argv[3]
334
+ write_snapshot(transcript, project_dir, trigger)
335
+ except Exception as e:
336
+ sys.stderr.write(f"resume_writer: {e}\n")
337
+ return 0
338
+
339
+
340
+ if __name__ == "__main__":
341
+ sys.exit(main(sys.argv))