@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,55 @@
1
+ #!/usr/bin/env bash
2
+ # Lint Runner hook — PostToolUse(Edit|Write|MultiEdit)
3
+ #
4
+ # Runs the project-configured lint command against the changed file.
5
+ # Guide-mode behaviour matches test_runner.sh: until `.claude/project.json`
6
+ # is configured, emits guidance rather than failing.
7
+
8
+ # shellcheck source=./lib/common.sh
9
+ . "${BASH_SOURCE[0]%/*}/lib/common.sh"
10
+ read_payload
11
+
12
+ TOOL="$(payload_get .tool_name)"
13
+ case "$TOOL" in
14
+ Edit|Write|MultiEdit) ;;
15
+ *) emit_allow ;;
16
+ esac
17
+
18
+ FILE="$(payload_get .tool_input.file_path)"
19
+ [ -n "$FILE" ] || emit_allow
20
+ rel="${FILE#$CLAUDE_PROJECT_ROOT/}"
21
+
22
+ case "$rel" in
23
+ *.md|*.json|*.yaml|*.yml|*.toml|*.txt|docs/*|.claude/*|.config/*) emit_allow ;;
24
+ esac
25
+
26
+ configured="$(project_get .configured)"
27
+ cmd="$(project_get .lint.cmd)"
28
+
29
+ if [ "$configured" != "True" ] && [ "$configured" != "true" ]; then
30
+ emit_info "Lint Runner: .claude/project.json is not configured yet. Run \`/init-project\` to declare the lint command."
31
+ emit_allow
32
+ fi
33
+
34
+ if [ -z "$cmd" ] || [ "$cmd" = "None" ]; then
35
+ emit_info "Lint Runner: no .lint.cmd set in .claude/project.json. Skipping lint for '$rel'."
36
+ emit_allow
37
+ fi
38
+
39
+ timeout_s="$(project_get .lint.timeout_seconds)"
40
+ [ -z "$timeout_s" ] && timeout_s=60
41
+
42
+ final="${cmd//\{file\}/$rel}"
43
+
44
+ emit_info "Lint Runner: running \`$final\` (timeout ${timeout_s}s)"
45
+ out="$(cd "$CLAUDE_PROJECT_ROOT" && timeout "${timeout_s}s" bash -lc "$final" 2>&1)"
46
+ rc=$?
47
+ if [ $rc -ne 0 ]; then
48
+ log_line lint_runner "FAIL rc=$rc cmd=$final"
49
+ emit_info "Lint Runner: FAILED (exit $rc) — output:"
50
+ emit_info "$out"
51
+ exit 2
52
+ fi
53
+
54
+ log_line lint_runner "PASS cmd=$final"
55
+ emit_allow
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env bash
2
+ # Memory PreCompact — PreCompact event
3
+ #
4
+ # Fires before context compaction (manual /compact or auto). At this point
5
+ # the full transcript is still on disk; we walk it and write a continuity
6
+ # snapshot to .claude/memory/_resume.md. The next SessionStart (source:
7
+ # compact) re-injects that snapshot so the model knows where it left off.
8
+ #
9
+ # This hook NEVER blocks compaction. Snapshotting must be best-effort:
10
+ # a transcript-walk failure should not punish the user.
11
+ #
12
+ # Per docs: PreCompact stdout is NOT injected into context (only logged).
13
+ # So all useful output goes to disk; this hook prints nothing on stdout.
14
+
15
+ # shellcheck source=./lib/common.sh
16
+ . "${BASH_SOURCE[0]%/*}/lib/common.sh"
17
+ read_payload
18
+
19
+ TRANSCRIPT="$(payload_get .transcript_path)"
20
+ TRIGGER="$(payload_get .trigger)"
21
+ [ -n "$TRIGGER" ] || TRIGGER="auto"
22
+
23
+ # If we can't find the transcript, log and bail — never fail compaction.
24
+ if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then
25
+ log_line memory_pre_compact "no transcript path; skipped (trigger=$TRIGGER)"
26
+ exit 0
27
+ fi
28
+
29
+ MEM_DIR="$CLAUDE_DOTDIR/memory"
30
+ [ -d "$MEM_DIR" ] || { log_line memory_pre_compact "memory dir missing; skipped"; exit 0; }
31
+
32
+ python3 "$CLAUDE_DOTDIR/hooks/lib/resume_writer.py" \
33
+ "$TRANSCRIPT" "$CLAUDE_PROJECT_ROOT" "pre-compact" 2>>"$LOG_DIR/memory_pre_compact.log" || true
34
+
35
+ log_line memory_pre_compact "wrote _resume.md (trigger=$TRIGGER)"
36
+ exit 0
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env bash
2
+ # Memory Session Start — SessionStart
3
+ #
4
+ # At every session start, scans .claude/memory/*.md and emits a compact index
5
+ # into Claude's startup context: per-file entry counts, stale-entry counts,
6
+ # and a pending-flush nag if _pending.md has unreviewed candidates.
7
+ #
8
+ # Output format: structured `additionalContext` JSON so Claude Code injects
9
+ # the index directly into the startup prompt. Output kept under ~2KB; the
10
+ # canonical files load on first relevant skill invocation.
11
+
12
+ # shellcheck source=./lib/common.sh
13
+ . "${BASH_SOURCE[0]%/*}/lib/common.sh"
14
+ read_payload
15
+
16
+ # Marker cleanup — remove stale .harness_active from a prior session.
17
+ # Runs BEFORE the memory-dir check so cleanup happens regardless of memory state.
18
+ # Cross-session ghost prevention: the harness_continuation Stop hook reads this
19
+ # marker as Rung 2; without this cleanup, a leftover marker from a prior session
20
+ # would let yesterday's state: continue re-fire on today's first turn-end.
21
+ MARKER="$CLAUDE_DOTDIR/state/.harness_active"
22
+ if [ -f "$MARKER" ]; then
23
+ MARKER_SLUG="$(head -1 "$MARKER" 2>/dev/null)"
24
+ rm -f "$MARKER"
25
+ mkdir -p "$LOG_DIR"
26
+ printf '%s INFO removed stale .harness_active (slug=%s)\n' \
27
+ "$(date -u +%FT%TZ)" "$MARKER_SLUG" >> "$LOG_DIR/harness_continuation.log"
28
+ fi
29
+
30
+ MEM_DIR="$CLAUDE_DOTDIR/memory"
31
+
32
+ # If the memory directory doesn't exist (fresh repo, pre-init), do nothing.
33
+ [ -d "$MEM_DIR" ] || exit 0
34
+
35
+ # How the session started — drives the framing line for the resume snapshot.
36
+ SESSION_SOURCE="$(payload_get .source)"
37
+ [ -n "$SESSION_SOURCE" ] || SESSION_SOURCE="startup"
38
+
39
+ context="$(MEM_DIR="$MEM_DIR" CLAUDE_PROJECT_ROOT="$CLAUDE_PROJECT_ROOT" SESSION_SOURCE="$SESSION_SOURCE" python3 <<'PY'
40
+ import json, os, re, subprocess
41
+ from datetime import date, datetime, timezone
42
+ from pathlib import Path
43
+
44
+ mem_dir = Path(os.environ['MEM_DIR'])
45
+ root = Path(os.environ['CLAUDE_PROJECT_ROOT'])
46
+
47
+ # Resolve the current HEAD; if not a git repo, leave HEAD blank (skill cite-then-verify still works).
48
+ try:
49
+ head = subprocess.check_output(
50
+ ['git', '-C', str(root), 'rev-parse', '--short', 'HEAD'],
51
+ stderr=subprocess.DEVNULL, text=True,
52
+ ).strip()
53
+ except Exception:
54
+ head = ''
55
+
56
+ # Files in canonical order. _pending.md handled separately.
57
+ canonical = ['landmarks', 'libraries', 'decisions', 'landmines', 'conventions', 'pending-questions']
58
+ PENDING_FILE = 'pending-questions'
59
+ STALE_COMMITS = 30
60
+ STALE_DAYS = 30 # non-git fallback threshold
61
+
62
+
63
+ def _field(block, name):
64
+ m = re.search(rf'(?m)^\s*-\s*{re.escape(name)}\s*:\s*(.+?)\s*$', block, re.IGNORECASE)
65
+ return m.group(1).strip() if m else None
66
+
67
+
68
+ def _commit_distance(stamp):
69
+ try:
70
+ d = subprocess.check_output(
71
+ ['git', '-C', str(root), 'rev-list', '--count', f'{stamp}..HEAD'],
72
+ stderr=subprocess.DEVNULL, text=True,
73
+ ).strip()
74
+ return int(d) if d.isdigit() else None
75
+ except Exception:
76
+ return None
77
+
78
+
79
+ def _days_since(iso):
80
+ try:
81
+ d = datetime.strptime(iso, '%Y-%m-%d').date()
82
+ return (date.today() - d).days
83
+ except Exception:
84
+ return None
85
+
86
+
87
+ def _split_blocks(body):
88
+ parts = re.split(r'(?m)^(##\s+\S.*)$', body)
89
+ out = []
90
+ for i in range(1, len(parts), 2):
91
+ heading = parts[i]
92
+ tail = parts[i + 1] if i + 1 < len(parts) else ''
93
+ key = heading[2:].strip().split()[0] if heading[2:].strip() else ''
94
+ out.append((key, heading + tail))
95
+ return out
96
+
97
+
98
+ def _is_stale(block, name):
99
+ closure_field = 'resolved-at' if name == PENDING_FILE else 'superseded-at'
100
+ if _field(block, closure_field):
101
+ return False
102
+ stamp = _field(block, 'verified-at')
103
+ if head and stamp and stamp != 'HEAD':
104
+ dist = _commit_distance(stamp)
105
+ return dist is None or dist >= STALE_COMMITS
106
+ if not head:
107
+ days = _days_since(_field(block, 'last-touched') or '')
108
+ return days is not None and days >= STALE_DAYS
109
+ return False
110
+
111
+
112
+ rows = []
113
+ total_entries = 0
114
+ total_stale = 0
115
+ stale_records = [] # (file_name, key, last_touched) for the rendered block
116
+
117
+ for name in canonical:
118
+ p = mem_dir / f'{name}.md'
119
+ if not p.is_file():
120
+ rows.append((name, 0, 0, 'missing'))
121
+ continue
122
+ text = p.read_text(encoding='utf-8', errors='replace')
123
+ body = text.split('---', 2)[-1] if text.startswith('---') else text
124
+ blocks = _split_blocks(body)
125
+ n = len(blocks)
126
+ total_entries += n
127
+ stale = 0
128
+ for key, blk in blocks:
129
+ if not _is_stale(blk, name):
130
+ continue
131
+ stale += 1
132
+ stale_records.append((name, key, _field(blk, 'last-touched') or ''))
133
+ total_stale += stale
134
+ rows.append((name, n, stale, 'ok'))
135
+
136
+ # Pending candidates: count `## CANDIDATE:` entries in _pending.md body.
137
+ pending_path = mem_dir / '_pending.md'
138
+ pending_count = 0
139
+ if pending_path.is_file():
140
+ body = pending_path.read_text(encoding='utf-8', errors='replace').split('---', 2)[-1]
141
+ pending_count = len(re.findall(r'(?m)^##\s+CANDIDATE\b', body))
142
+
143
+ # Compose the context block.
144
+ lines = [
145
+ '## Project memory — index (.claude/memory/)',
146
+ '',
147
+ f'HEAD: `{head or "n/a"}` · total entries: {total_entries} · stale (>=30 commits old): {total_stale}',
148
+ '',
149
+ '| File | Entries | Stale | Status |',
150
+ '|---|---:|---:|---|',
151
+ ]
152
+ for name, n, stale, status in rows:
153
+ lines.append(f'| `{name}.md` | {n} | {stale} | {status} |')
154
+
155
+ if stale_records:
156
+ stale_records.sort(key=lambda r: (r[2] or '', f'{r[0]}:{r[1]}'))
157
+ top = stale_records[:5]
158
+ overflow = len(stale_records) - 5
159
+ lines.append('')
160
+ lines.append('## Stale entries')
161
+ lines.append('')
162
+ for fname, key, last in top:
163
+ last_part = f' — last-touched {last}' if last else ''
164
+ lines.append(f'- `{fname}.md` `{key}`{last_part}')
165
+ if overflow > 0:
166
+ lines.append(f'… and {overflow} more')
167
+
168
+ lines.append('')
169
+
170
+ if pending_count > 0:
171
+ lines.append(
172
+ f'**{pending_count} candidate{"" if pending_count == 1 else "s"} pending in `_pending.md`** — '
173
+ 'run `/memory-flush` to review and commit keepers before starting workflow phases.'
174
+ )
175
+ else:
176
+ lines.append('No pending memory candidates.')
177
+
178
+ lines.append('')
179
+ lines.append(
180
+ 'Files are read on demand by the relevant skill (scout reads landmarks, research reads libraries, etc.). '
181
+ 'Every cited entry is re-verified before use; failed verifications are corrected or deleted in the same run. '
182
+ 'See `.claude/memory/README.md` for the entry shape and self-healing rules.'
183
+ )
184
+
185
+ out = '\n'.join(lines)
186
+ # Cap the index portion at ~2KB so the resume snapshot has room.
187
+ if len(out) > 2048:
188
+ out = out[:2000] + '\n…(index truncated)'
189
+
190
+ # Resume snapshot — only injected when present and reasonably fresh.
191
+ # Source-aware framing tells the model why it's seeing this and what to do.
192
+ src = os.environ.get('SESSION_SOURCE', 'startup')
193
+ framings = {
194
+ 'compact': '↻ Resuming after compaction. Last captured state below — pick up from here.',
195
+ 'clear': '↻ Continuity from prior session. The user just `/clear`\'d; here is where things stood.',
196
+ 'resume': '↻ Session resumed. Last captured state below.',
197
+ 'startup': '↻ Prior session left this snapshot. If still relevant, pick up from here.',
198
+ }
199
+ framing = framings.get(src, framings['startup'])
200
+
201
+ resume_path = mem_dir / '_resume.md'
202
+ if resume_path.is_file():
203
+ try:
204
+ raw = resume_path.read_text(encoding='utf-8', errors='replace')
205
+ # Skip frontmatter (between leading --- markers) — keep just the body.
206
+ body = raw
207
+ if raw.startswith('---'):
208
+ parts = raw.split('---', 2)
209
+ if len(parts) == 3:
210
+ body = parts[2].lstrip('\n')
211
+ # Freshness gate: only inject if file modified <= 7 days ago.
212
+ mtime = datetime.fromtimestamp(resume_path.stat().st_mtime, tz=timezone.utc)
213
+ age_days = (datetime.now(timezone.utc) - mtime).days
214
+ if age_days <= 7 and body.strip():
215
+ # Total cap ~9.5KB to stay well under the 10KB additionalContext limit.
216
+ budget = 9500 - len(out) - len(framing) - 80
217
+ if budget > 500:
218
+ if len(body) > budget:
219
+ body = body[:budget].rstrip() + '\n\n…(snapshot truncated)'
220
+ out = (
221
+ out
222
+ + '\n\n---\n\n'
223
+ + framing
224
+ + f' (snapshot age: {age_days}d)\n\n'
225
+ + body
226
+ )
227
+ except Exception:
228
+ pass
229
+
230
+ print(json.dumps({
231
+ 'hookSpecificOutput': {
232
+ 'hookEventName': 'SessionStart',
233
+ 'additionalContext': out,
234
+ },
235
+ }))
236
+ PY
237
+ )"
238
+
239
+ # If python emitted nothing (memory dir empty / parse failure), exit silently.
240
+ [ -n "$context" ] || exit 0
241
+
242
+ printf '%s\n' "$context"
243
+ log_line memory_session_start "emitted memory index"
244
+ exit 0
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env bash
2
+ # Memory Stop — Stop event
3
+ #
4
+ # Fires once per assistant turn (end of a Claude response). Reads the
5
+ # transcript file (a JSONL where each line is one event), extracts patterns
6
+ # that look like memory candidates, and appends them to
7
+ # .claude/memory/_pending.md for later curation via /memory-flush.
8
+ #
9
+ # This hook is a PASSIVE COLLECTOR. It never writes to canonical memory
10
+ # files — only to the gitignored body of _pending.md. Claude curates
11
+ # candidates in main context via /memory-flush.
12
+ #
13
+ # Patterns extracted:
14
+ # - Edit/Write/MultiEdit on source files → landmark candidate
15
+ # - context7 MCP queries (resolve-library-id / query-docs) → library candidate
16
+ # - Bash 'rg'/'grep'/'git' searches over source dirs → potential landmark/landmine
17
+ # - Tool calls that touched .claude/memory/* → no-op (don't candidate-extract on memory writes)
18
+
19
+ # shellcheck source=./lib/common.sh
20
+ . "${BASH_SOURCE[0]%/*}/lib/common.sh"
21
+ read_payload
22
+
23
+ TRANSCRIPT="$(payload_get .transcript_path)"
24
+ [ -n "$TRANSCRIPT" ] && [ -f "$TRANSCRIPT" ] || exit 0
25
+
26
+ MEM_DIR="$CLAUDE_DOTDIR/memory"
27
+ PENDING="$MEM_DIR/_pending.md"
28
+ [ -f "$PENDING" ] || exit 0
29
+
30
+ # Extract candidates with python; never fail the hook (it's advisory).
31
+ TRANSCRIPT="$TRANSCRIPT" PENDING="$PENDING" python3 <<'PY' || true
32
+ import json, os, re, sys, time
33
+ from pathlib import Path
34
+ from datetime import datetime, timezone
35
+
36
+ transcript = Path(os.environ['TRANSCRIPT'])
37
+ pending = Path(os.environ['PENDING'])
38
+
39
+ # Load existing pending body to avoid re-emitting duplicates within the session.
40
+ existing = pending.read_text(encoding='utf-8', errors='replace')
41
+ existing_keys = set(re.findall(r'(?m)^##\s+CANDIDATE:\s*(\S+)', existing))
42
+
43
+ candidates = [] # (key, category, body_lines)
44
+
45
+ # Source-dir prefixes that are interesting for landmark candidates.
46
+ SRC_PREFIXES = ('src/', 'lib/', 'app/', 'pkg/', 'internal/', 'cmd/', '.claude/hooks/', '.claude/skills/')
47
+ SKIP_PREFIXES = ('.claude/memory/', '.claude/state/', 'docs/scout/', 'docs/research/', 'docs/intake/',
48
+ 'docs/specs/', 'docs/brd/', 'docs/rca/', 'docs/security/', 'docs/archive/')
49
+
50
+ def is_source(path: str) -> bool:
51
+ if not isinstance(path, str) or not path:
52
+ return False
53
+ if any(path.startswith(p) for p in SKIP_PREFIXES):
54
+ return False
55
+ if any(path.startswith(p) for p in SRC_PREFIXES):
56
+ return True
57
+ return False
58
+
59
+ # Track per-path edit counts so we only candidate paths touched ≥1 time
60
+ # (loose threshold; the curator decides what's worth keeping).
61
+ path_touches = {} # path -> count
62
+ lib_queries = [] # list of dicts {library, topic}
63
+
64
+ # Walk the transcript JSONL.
65
+ try:
66
+ with transcript.open('r', encoding='utf-8', errors='replace') as f:
67
+ for line in f:
68
+ line = line.strip()
69
+ if not line:
70
+ continue
71
+ try:
72
+ ev = json.loads(line)
73
+ except Exception:
74
+ continue
75
+ # Most relevant: assistant tool_use blocks.
76
+ msg = ev.get('message') or ev
77
+ if not isinstance(msg, dict):
78
+ continue
79
+ content = msg.get('content')
80
+ if not isinstance(content, list):
81
+ continue
82
+ for block in content:
83
+ if not isinstance(block, dict):
84
+ continue
85
+ if block.get('type') != 'tool_use':
86
+ continue
87
+ name = block.get('name', '')
88
+ inp = block.get('input') or {}
89
+ # Edit/Write/MultiEdit
90
+ if name in ('Edit', 'Write', 'MultiEdit'):
91
+ fp = inp.get('file_path', '')
92
+ # Strip leading project root prefix if present.
93
+ if fp:
94
+ path_touches[fp] = path_touches.get(fp, 0) + 1
95
+ # context7 MCP query
96
+ elif 'context7' in name:
97
+ lib = inp.get('libraryName') or inp.get('library_name') or inp.get('libraryID')
98
+ topic = inp.get('topic') or inp.get('query') or ''
99
+ if lib:
100
+ lib_queries.append({'library': str(lib), 'topic': str(topic)[:80]})
101
+ except Exception as e:
102
+ sys.stderr.write(f'memory_stop: transcript walk failed: {e}\n')
103
+
104
+ # Build candidates.
105
+ ts = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%MZ')
106
+
107
+ # Landmark candidates from touched source files.
108
+ for fp, n in sorted(path_touches.items(), key=lambda kv: (-kv[1], kv[0])):
109
+ # Convert absolute path to repo-relative if it begins with the repo root.
110
+ rel = fp
111
+ cwd = os.environ.get('CLAUDE_PROJECT_DIR') or os.getcwd()
112
+ if fp.startswith(cwd + '/'):
113
+ rel = fp[len(cwd) + 1:]
114
+ if not is_source(rel):
115
+ continue
116
+ key = f'{rel} → landmarks.md'
117
+ if key in existing_keys:
118
+ continue
119
+ body = [
120
+ f'## CANDIDATE: {key}',
121
+ f'- Touched in this session: {n} time{"s" if n != 1 else ""}',
122
+ f'- Suggested role: <fill in from session context>',
123
+ f'- Source: file written/edited at {ts}',
124
+ '',
125
+ ]
126
+ candidates.append((key, 'landmarks', body))
127
+
128
+ # Library candidates from context7 queries.
129
+ seen_libs = set()
130
+ for q in lib_queries:
131
+ lib = q['library']
132
+ if lib in seen_libs:
133
+ continue
134
+ seen_libs.add(lib)
135
+ key = f'{lib} → libraries.md'
136
+ if key in existing_keys:
137
+ continue
138
+ body = [
139
+ f'## CANDIDATE: {key}',
140
+ f'- Library: {lib}',
141
+ f'- Topics queried this session: {q["topic"] or "(no topic field)"}',
142
+ f'- Source: context7 MCP query at {ts}',
143
+ f'- Reminder: pin a version before promoting to canonical (lib@version is the stable key).',
144
+ '',
145
+ ]
146
+ candidates.append((key, 'libraries', body))
147
+
148
+ # If nothing to add, exit cleanly.
149
+ if not candidates:
150
+ sys.exit(0)
151
+
152
+ # Append a session-tagged block to pending.
153
+ prefix = f'\n\n<!-- session {ts} -->\n'
154
+ new_block = prefix + '\n'.join('\n'.join(b) for _, _, b in candidates)
155
+ with pending.open('a', encoding='utf-8') as f:
156
+ f.write(new_block)
157
+
158
+ # Print a concise info note for the user.
159
+ total_pending = len(re.findall(r'(?m)^##\s+CANDIDATE\b', existing)) + len(candidates)
160
+ sys.stderr.write(
161
+ f'memory_stop: appended {len(candidates)} candidate(s) to .claude/memory/_pending.md '
162
+ f'(total pending: {total_pending}). Run /memory-flush to review.\n'
163
+ )
164
+ PY
165
+
166
+ log_line memory_stop "ran end-of-turn extraction"
167
+
168
+ # Refresh the continuity snapshot so even mid-session crashes / abrupt /clear
169
+ # leave a usable _resume.md. Best-effort; never fail the hook.
170
+ python3 "$CLAUDE_DOTDIR/hooks/lib/resume_writer.py" \
171
+ "$TRANSCRIPT" "$CLAUDE_PROJECT_ROOT" "stop" 2>>"$LOG_DIR/memory_stop.log" || true
172
+
173
+ exit 0
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env bash
2
+ # PlantUML Syntax Guard — PreToolUse(Write|Edit|MultiEdit)
3
+ #
4
+ # Validates every ```plantuml``` fenced block inside writes to docs/specs/*.md.
5
+ # The spec template is diagram-driven; a spec with broken PlantUML is useless
6
+ # to reviewers and breaks /spec-render. Catching it at the write boundary
7
+ # prevents broken diagrams from ever landing on disk.
8
+ #
9
+ # How it validates:
10
+ # 1. Extract every ```plantuml ...``` fenced block from the proposed content.
11
+ # 2. For each block, pipe to `plantuml -checkonly -pipe` and capture exit code.
12
+ # 3. Any non-zero exit → block the write with a reason naming the offending
13
+ # block (1-indexed) and its first line.
14
+ #
15
+ # Guide mode (advisory):
16
+ # - If `plantuml` is not on PATH, emit a one-line info message and allow.
17
+ # - If a spec has zero plantuml blocks, allow — spec_diagram_presence_guard
18
+ # is the hook that enforces presence.
19
+ #
20
+ # Template files (_TEMPLATE_*) are exempt.
21
+
22
+ # shellcheck source=./lib/common.sh
23
+ . "${BASH_SOURCE[0]%/*}/lib/common.sh"
24
+ read_payload
25
+
26
+ TOOL="$(payload_get .tool_name)"
27
+ case "$TOOL" in
28
+ Write|Edit|MultiEdit) ;;
29
+ *) emit_allow ;;
30
+ esac
31
+
32
+ FILE="$(payload_get .tool_input.file_path)"
33
+ [ -n "$FILE" ] || emit_allow
34
+ rel="${FILE#$CLAUDE_PROJECT_ROOT/}"
35
+
36
+ # Only enforce on spec artifacts.
37
+ case "$rel" in
38
+ docs/specs/*.md) ;;
39
+ *) emit_allow ;;
40
+ esac
41
+
42
+ # Exempt templates.
43
+ base="${rel##*/}"
44
+ case "$base" in
45
+ _TEMPLATE_*|*TEMPLATE*.md) emit_allow ;;
46
+ esac
47
+
48
+ # Guide mode when plantuml is not installed. Emit once, then allow.
49
+ if ! command -v plantuml >/dev/null 2>&1; then
50
+ emit_info "PlantUML Syntax Guard: \`plantuml\` CLI not found on PATH — running in guide mode. Install with 'brew install plantuml' (macOS) or 'apt-get install plantuml' (Debian/Ubuntu) to enable strict validation. Skipping syntax check for '$rel'."
51
+ log_line plantuml_syntax_guard "GUIDE (no plantuml) $rel"
52
+ emit_allow
53
+ fi
54
+
55
+ # Compute the post-write content (same merge strategy as artifact_template_guard).
56
+ HOOK_FILE="$FILE" HOOK_TOOL="$TOOL" HOOK_REL="$rel" python3 <<'PY' >/tmp/.plantuml_guard_content.$$ 2>/dev/null
57
+ import json, os, pathlib, sys
58
+
59
+ payload = json.loads(os.environ.get("HOOK_PAYLOAD", "") or "{}")
60
+ tool = os.environ["HOOK_TOOL"]
61
+ file_ = os.environ["HOOK_FILE"]
62
+ ti = payload.get("tool_input") or {}
63
+
64
+ def current():
65
+ try:
66
+ return pathlib.Path(file_).read_text(encoding="utf-8")
67
+ except Exception:
68
+ return ""
69
+
70
+ if tool == "Write":
71
+ content = ti.get("content") or ""
72
+ elif tool == "Edit":
73
+ base = current()
74
+ old = ti.get("old_string") or ""
75
+ new = ti.get("new_string") or ""
76
+ if ti.get("replace_all"):
77
+ content = base.replace(old, new)
78
+ else:
79
+ content = base.replace(old, new, 1) if old in base else (base + new)
80
+ elif tool == "MultiEdit":
81
+ content = current()
82
+ for edit in (ti.get("edits") or []):
83
+ old = edit.get("old_string") or ""
84
+ new = edit.get("new_string") or ""
85
+ if edit.get("replace_all"):
86
+ content = content.replace(old, new)
87
+ else:
88
+ content = content.replace(old, new, 1) if old in content else (content + new)
89
+ else:
90
+ content = ""
91
+
92
+ sys.stdout.write(content)
93
+ PY
94
+
95
+ CONTENT_FILE="/tmp/.plantuml_guard_content.$$"
96
+ trap 'rm -f "$CONTENT_FILE"' EXIT
97
+
98
+ # Extract plantuml blocks and validate each. Python does the heavy lifting so
99
+ # we don't have to reason about multiline regex in bash.
100
+ HOOK_REL="$rel" HOOK_CONTENT_FILE="$CONTENT_FILE" python3 <<'PY'
101
+ import json, os, re, subprocess, sys
102
+
103
+ rel = os.environ["HOOK_REL"]
104
+ try:
105
+ content = open(os.environ["HOOK_CONTENT_FILE"], encoding="utf-8").read()
106
+ except Exception:
107
+ content = ""
108
+
109
+ if not content.strip():
110
+ sys.exit(0)
111
+
112
+ # Match ```plantuml ... ``` fenced blocks (case-insensitive language tag).
113
+ fence_re = re.compile(r'^[ \t]*```[ \t]*plantuml[ \t]*$(.*?)^[ \t]*```[ \t]*$',
114
+ re.DOTALL | re.IGNORECASE | re.MULTILINE)
115
+ blocks = [m.group(1) for m in fence_re.finditer(content)]
116
+ if not blocks:
117
+ # No PlantUML blocks → nothing to validate. Presence is a separate guard.
118
+ sys.exit(0)
119
+
120
+ failures = []
121
+ for idx, body in enumerate(blocks, start=1):
122
+ src = body.strip("\n")
123
+ # Ensure the block has a @startuml/@enduml envelope; plantuml requires it.
124
+ if "@startuml" not in src:
125
+ src = "@startuml\n" + src + "\n@enduml\n"
126
+ first_line = next((ln for ln in src.splitlines() if ln.strip() and not ln.strip().startswith("@start")), "").strip()[:80]
127
+ try:
128
+ r = subprocess.run(
129
+ ["plantuml", "-checkonly", "-pipe"],
130
+ input=src.encode("utf-8"),
131
+ capture_output=True,
132
+ timeout=15,
133
+ )
134
+ except FileNotFoundError:
135
+ # Race: vanished between the bash check and here. Guide mode.
136
+ sys.exit(0)
137
+ except subprocess.TimeoutExpired:
138
+ failures.append((idx, first_line, "plantuml -checkonly timed out after 15s"))
139
+ continue
140
+ if r.returncode != 0:
141
+ err = (r.stderr or r.stdout or b"").decode("utf-8", errors="replace").strip().splitlines()
142
+ detail = " | ".join(err[-3:]) if err else f"exit={r.returncode}"
143
+ failures.append((idx, first_line, detail))
144
+
145
+ if not failures:
146
+ sys.exit(0)
147
+
148
+ lines = [f"PlantUML Syntax Guard: '{rel}' has invalid PlantUML in {len(failures)} block(s). Fix and re-run."]
149
+ for idx, first_line, detail in failures:
150
+ label = f'"{first_line}"' if first_line else "(empty first line)"
151
+ lines.append(f" - block #{idx} {label}: {detail}")
152
+ lines.append("Tip: render interactively via the plantuml MCP server, or run `/spec-lint <slug>` to iterate before saving.")
153
+
154
+ print(json.dumps({
155
+ "hookSpecificOutput": {
156
+ "hookEventName": "PreToolUse",
157
+ "permissionDecision": "deny",
158
+ "permissionDecisionReason": "\n".join(lines),
159
+ }
160
+ }))
161
+ PY