@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.
- package/LICENSE +202 -0
- package/README.md +222 -0
- package/bin/cli.js +247 -0
- package/obj/template/.claude/agents/swarm-worker.md +52 -0
- package/obj/template/.claude/bin/LICENSE +201 -0
- package/obj/template/.claude/bin/NOTICE +48 -0
- package/obj/template/.claude/commands/approve-spec.md +29 -0
- package/obj/template/.claude/commands/approve-swarm.md +27 -0
- package/obj/template/.claude/commands/grant-commit.md +19 -0
- package/obj/template/.claude/commands/init-project.md +191 -0
- package/obj/template/.claude/hooks/artifact_template_guard.sh +141 -0
- package/obj/template/.claude/hooks/consent_gate_grant.sh +89 -0
- package/obj/template/.claude/hooks/destructive_cmd_guard.sh +42 -0
- package/obj/template/.claude/hooks/env_guard.sh +36 -0
- package/obj/template/.claude/hooks/git_commit_guard.sh +93 -0
- package/obj/template/.claude/hooks/harness_continuation.sh +121 -0
- package/obj/template/.claude/hooks/lib/__pycache__/resume_writer.cpython-314.pyc +0 -0
- package/obj/template/.claude/hooks/lib/common.sh +328 -0
- package/obj/template/.claude/hooks/lib/resume_writer.py +341 -0
- package/obj/template/.claude/hooks/lint_runner.sh +55 -0
- package/obj/template/.claude/hooks/memory_pre_compact.sh +36 -0
- package/obj/template/.claude/hooks/memory_session_start.sh +244 -0
- package/obj/template/.claude/hooks/memory_stop.sh +173 -0
- package/obj/template/.claude/hooks/plantuml_syntax_guard.sh +161 -0
- package/obj/template/.claude/hooks/process_lifecycle_guard.sh +89 -0
- package/obj/template/.claude/hooks/setup_guard.sh +50 -0
- package/obj/template/.claude/hooks/spec_approval_guard.sh +81 -0
- package/obj/template/.claude/hooks/spec_design_calls_guard.sh +183 -0
- package/obj/template/.claude/hooks/spec_diagram_presence_guard.sh +141 -0
- package/obj/template/.claude/hooks/swarm_approval_guard.sh +39 -0
- package/obj/template/.claude/hooks/swarm_boundary_guard.sh +136 -0
- package/obj/template/.claude/hooks/tdd_order_guard.sh +176 -0
- package/obj/template/.claude/hooks/test_runner.sh +75 -0
- package/obj/template/.claude/hooks/tests/fixtures/ac008_byte_equal_reference.txt +12 -0
- package/obj/template/.claude/hooks/tests/memory_session_start_test.sh +285 -0
- package/obj/template/.claude/hooks/track_guard.sh +127 -0
- package/obj/template/.claude/hooks/verify_pass_guard.sh +88 -0
- package/obj/template/.claude/memory/README.md +108 -0
- package/obj/template/.claude/memory/_pending.md +15 -0
- package/obj/template/.claude/memory/_resume.md +12 -0
- package/obj/template/.claude/memory/conventions.md +26 -0
- package/obj/template/.claude/memory/decisions.md +29 -0
- package/obj/template/.claude/memory/landmarks.md +26 -0
- package/obj/template/.claude/memory/landmines.md +27 -0
- package/obj/template/.claude/memory/libraries.md +27 -0
- package/obj/template/.claude/memory/pending-questions.md +28 -0
- package/obj/template/.claude/project.json +221 -0
- package/obj/template/.claude/settings.json +110 -0
- package/obj/template/.claude/skills/archive/SKILL.md +48 -0
- package/obj/template/.claude/skills/archive/archive.sh +145 -0
- package/obj/template/.claude/skills/audit-baseline/SKILL.md +80 -0
- package/obj/template/.claude/skills/audit-baseline/audit.sh +919 -0
- package/obj/template/.claude/skills/brd/SKILL.md +44 -0
- package/obj/template/.claude/skills/brd/template.md +83 -0
- package/obj/template/.claude/skills/chore/SKILL.md +99 -0
- package/obj/template/.claude/skills/claude-automation-recommender/LICENSE +202 -0
- package/obj/template/.claude/skills/claude-automation-recommender/NOTICE +69 -0
- package/obj/template/.claude/skills/claude-automation-recommender/SKILL.md +358 -0
- package/obj/template/.claude/skills/claude-automation-recommender/references/hooks-patterns.md +226 -0
- package/obj/template/.claude/skills/claude-automation-recommender/references/mcp-servers.md +263 -0
- package/obj/template/.claude/skills/claude-automation-recommender/references/plugins-reference.md +98 -0
- package/obj/template/.claude/skills/claude-automation-recommender/references/skills-reference.md +408 -0
- package/obj/template/.claude/skills/claude-automation-recommender/references/subagent-templates.md +181 -0
- package/obj/template/.claude/skills/code-structure/SKILL.md +204 -0
- package/obj/template/.claude/skills/commit/SKILL.md +21 -0
- package/obj/template/.claude/skills/copywriting/SKILL.md +252 -0
- package/obj/template/.claude/skills/copywriting/evals/evals.json +111 -0
- package/obj/template/.claude/skills/copywriting/references/ai-writing-detection.md +200 -0
- package/obj/template/.claude/skills/copywriting/references/copy-frameworks.md +344 -0
- package/obj/template/.claude/skills/copywriting/references/natural-transitions.md +272 -0
- package/obj/template/.claude/skills/design-ui/SKILL.md +175 -0
- package/obj/template/.claude/skills/design-ui/references/design-vs-development.md +89 -0
- package/obj/template/.claude/skills/design-ui/references/intent-table.md +64 -0
- package/obj/template/.claude/skills/design-ui/references/orchestration.md +121 -0
- package/obj/template/.claude/skills/design-ui/references/state-machine.md +125 -0
- package/obj/template/.claude/skills/document/SKILL.md +66 -0
- package/obj/template/.claude/skills/documentation/SKILL.md +50 -0
- package/obj/template/.claude/skills/harness/SKILL.md +169 -0
- package/obj/template/.claude/skills/humanizer/SKILL.md +489 -0
- package/obj/template/.claude/skills/humanizer/references/ai-writing-detection.md +208 -0
- package/obj/template/.claude/skills/impeccable/PROJECT_NOTES.md +22 -0
- package/obj/template/.claude/skills/impeccable/SKILL.md +153 -0
- package/obj/template/.claude/skills/impeccable/agents/openai.yaml +4 -0
- package/obj/template/.claude/skills/impeccable/reference/adapt.md +190 -0
- package/obj/template/.claude/skills/impeccable/reference/animate.md +173 -0
- package/obj/template/.claude/skills/impeccable/reference/audit.md +134 -0
- package/obj/template/.claude/skills/impeccable/reference/bolder.md +113 -0
- package/obj/template/.claude/skills/impeccable/reference/brand.md +104 -0
- package/obj/template/.claude/skills/impeccable/reference/clarify.md +174 -0
- package/obj/template/.claude/skills/impeccable/reference/cognitive-load.md +106 -0
- package/obj/template/.claude/skills/impeccable/reference/color-and-contrast.md +105 -0
- package/obj/template/.claude/skills/impeccable/reference/colorize.md +154 -0
- package/obj/template/.claude/skills/impeccable/reference/craft.md +138 -0
- package/obj/template/.claude/skills/impeccable/reference/critique.md +213 -0
- package/obj/template/.claude/skills/impeccable/reference/delight.md +302 -0
- package/obj/template/.claude/skills/impeccable/reference/distill.md +111 -0
- package/obj/template/.claude/skills/impeccable/reference/document.md +427 -0
- package/obj/template/.claude/skills/impeccable/reference/extract.md +70 -0
- package/obj/template/.claude/skills/impeccable/reference/harden.md +347 -0
- package/obj/template/.claude/skills/impeccable/reference/heuristics-scoring.md +234 -0
- package/obj/template/.claude/skills/impeccable/reference/interaction-design.md +195 -0
- package/obj/template/.claude/skills/impeccable/reference/layout.md +141 -0
- package/obj/template/.claude/skills/impeccable/reference/live.md +513 -0
- package/obj/template/.claude/skills/impeccable/reference/motion-design.md +99 -0
- package/obj/template/.claude/skills/impeccable/reference/onboard.md +234 -0
- package/obj/template/.claude/skills/impeccable/reference/optimize.md +258 -0
- package/obj/template/.claude/skills/impeccable/reference/overdrive.md +130 -0
- package/obj/template/.claude/skills/impeccable/reference/personas.md +178 -0
- package/obj/template/.claude/skills/impeccable/reference/polish.md +232 -0
- package/obj/template/.claude/skills/impeccable/reference/product.md +62 -0
- package/obj/template/.claude/skills/impeccable/reference/quieter.md +99 -0
- package/obj/template/.claude/skills/impeccable/reference/responsive-design.md +114 -0
- package/obj/template/.claude/skills/impeccable/reference/shape.md +136 -0
- package/obj/template/.claude/skills/impeccable/reference/spatial-design.md +100 -0
- package/obj/template/.claude/skills/impeccable/reference/teach.md +137 -0
- package/obj/template/.claude/skills/impeccable/reference/typeset.md +124 -0
- package/obj/template/.claude/skills/impeccable/reference/typography.md +159 -0
- package/obj/template/.claude/skills/impeccable/reference/ux-writing.md +107 -0
- package/obj/template/.claude/skills/impeccable/scripts/cleanup-deprecated.mjs +284 -0
- package/obj/template/.claude/skills/impeccable/scripts/command-metadata.json +94 -0
- package/obj/template/.claude/skills/impeccable/scripts/design-parser.mjs +820 -0
- package/obj/template/.claude/skills/impeccable/scripts/detect-csp.mjs +198 -0
- package/obj/template/.claude/skills/impeccable/scripts/is-generated.mjs +69 -0
- package/obj/template/.claude/skills/impeccable/scripts/live-accept.mjs +465 -0
- package/obj/template/.claude/skills/impeccable/scripts/live-browser.js +4684 -0
- package/obj/template/.claude/skills/impeccable/scripts/live-inject.mjs +436 -0
- package/obj/template/.claude/skills/impeccable/scripts/live-poll.mjs +187 -0
- package/obj/template/.claude/skills/impeccable/scripts/live-server.mjs +679 -0
- package/obj/template/.claude/skills/impeccable/scripts/live-wrap.mjs +395 -0
- package/obj/template/.claude/skills/impeccable/scripts/live.mjs +247 -0
- package/obj/template/.claude/skills/impeccable/scripts/load-context.mjs +93 -0
- package/obj/template/.claude/skills/impeccable/scripts/modern-screenshot.umd.js +14 -0
- package/obj/template/.claude/skills/impeccable/scripts/pin.mjs +214 -0
- package/obj/template/.claude/skills/implement/SKILL.md +83 -0
- package/obj/template/.claude/skills/intake/SKILL.md +46 -0
- package/obj/template/.claude/skills/intake/template.md +61 -0
- package/obj/template/.claude/skills/integrate/SKILL.md +62 -0
- package/obj/template/.claude/skills/memory-flush/SKILL.md +172 -0
- package/obj/template/.claude/skills/memory-flush/sweep.py +286 -0
- package/obj/template/.claude/skills/memory-flush/tests/run.sh +327 -0
- package/obj/template/.claude/skills/prose/SKILL.md +119 -0
- package/obj/template/.claude/skills/rca/SKILL.md +42 -0
- package/obj/template/.claude/skills/rca/template.md +83 -0
- package/obj/template/.claude/skills/research/SKILL.md +75 -0
- package/obj/template/.claude/skills/scenario/SKILL.md +64 -0
- package/obj/template/.claude/skills/scout/SKILL.md +72 -0
- package/obj/template/.claude/skills/security/SKILL.md +75 -0
- package/obj/template/.claude/skills/simplify/SKILL.md +67 -0
- package/obj/template/.claude/skills/spec/SKILL.md +69 -0
- package/obj/template/.claude/skills/spec/template.md +274 -0
- package/obj/template/.claude/skills/spec-diagram-review/SKILL.md +81 -0
- package/obj/template/.claude/skills/spec-lint/SKILL.md +55 -0
- package/obj/template/.claude/skills/spec-lint/lint.sh +218 -0
- package/obj/template/.claude/skills/spec-render/SKILL.md +45 -0
- package/obj/template/.claude/skills/spec-render/render.sh +109 -0
- package/obj/template/.claude/skills/spec-traceability-review/SKILL.md +72 -0
- package/obj/template/.claude/skills/swarm-dispatch/SKILL.md +212 -0
- package/obj/template/.claude/skills/swarm-dispatch/swarm_merge.sh +154 -0
- package/obj/template/.claude/skills/swarm-plan/SKILL.md +90 -0
- package/obj/template/.claude/skills/swarm-plan/validate.sh +181 -0
- package/obj/template/.claude/skills/tdd/SKILL.md +100 -0
- package/obj/template/.claude/skills/technical-tutorials/SKILL.md +569 -0
- package/obj/template/.claude/skills/technical-tutorials/references/audience-context-README.md +53 -0
- package/obj/template/.claude/skills/technical-tutorials/references/audience-context.md +246 -0
- package/obj/template/.claude/skills/technical-tutorials/references/audience-example.md +175 -0
- package/obj/template/.claude/skills/technical-tutorials/references/audience-template.md +152 -0
- package/obj/template/.claude/skills/triage/SKILL.md +55 -0
- package/obj/template/.claude/skills/verify/SKILL.md +74 -0
- package/obj/template/.mcp.json +24 -0
- package/obj/template/CLAUDE.md +327 -0
- package/obj/template/docs/init/seed.md +585 -0
- package/obj/template/manifest.json +214 -0
- package/package.json +48 -0
- package/src/.mcp.template.json +24 -0
- package/src/.npmrc.template +2 -0
- package/src/CLAUDE.template.md +327 -0
- package/src/agents/swarm-worker.template.md +51 -0
- package/src/cli/conflict.js +31 -0
- package/src/cli/doctor.js +152 -0
- package/src/cli/install.js +93 -0
- package/src/cli/io.js +27 -0
- package/src/cli/manifest.js +38 -0
- package/src/cli/mcp.js +54 -0
- package/src/cli/merge.js +107 -0
- package/src/cli/plantuml.js +121 -0
- package/src/cli/util.js +10 -0
- package/src/memory/_pending.template.md +15 -0
- package/src/memory/_resume.template.md +12 -0
- package/src/memory/conventions.template.md +26 -0
- package/src/memory/decisions.template.md +29 -0
- package/src/memory/landmarks.template.md +26 -0
- package/src/memory/landmines.template.md +27 -0
- package/src/memory/libraries.template.md +27 -0
- package/src/memory/pending-questions.template.md +28 -0
- package/src/project.template.json +221 -0
- package/src/seed.template.md +585 -0
- 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
|