@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,136 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Swarm Boundary Guard — PreToolUse(Write|Edit|MultiEdit)
|
|
3
|
+
#
|
|
4
|
+
# Enforces the swarm invariant: within an active wave, writes may only touch
|
|
5
|
+
# files that appear in some task's declared write_set. Because write_sets are
|
|
6
|
+
# pairwise disjoint within a wave (enforced by swarm-plan at plan time), any
|
|
7
|
+
# write uniquely maps back to exactly one task — so the guard does not need
|
|
8
|
+
# to identify which agent is writing. It only needs to verify the file is
|
|
9
|
+
# owned by SOMEONE in the active wave.
|
|
10
|
+
#
|
|
11
|
+
# Control file:
|
|
12
|
+
# .claude/state/swarm/active_wave.json
|
|
13
|
+
# Format:
|
|
14
|
+
# {
|
|
15
|
+
# "slug": "<slug>",
|
|
16
|
+
# "wave": <n>,
|
|
17
|
+
# "started_at": <epoch>,
|
|
18
|
+
# "write_sets": [
|
|
19
|
+
# {"task_id": "T-001", "files": ["src/foo.py", "tests/foo_test.py"]},
|
|
20
|
+
# ...
|
|
21
|
+
# ]
|
|
22
|
+
# }
|
|
23
|
+
#
|
|
24
|
+
# Semantics:
|
|
25
|
+
# - active_wave.json missing → not in swarm, allow.
|
|
26
|
+
# - file path under an exempt prefix → allow (tooling/state/vcs writes).
|
|
27
|
+
# - file path in enforced prefix and in union(write_sets) → allow.
|
|
28
|
+
# - file path in enforced prefix and NOT in any write_set → deny.
|
|
29
|
+
# - file path NOT in enforced prefix → allow (hook scope is narrow on purpose).
|
|
30
|
+
#
|
|
31
|
+
# Exempt / enforced prefixes come from project.json → swarm.exempt_path_prefixes
|
|
32
|
+
# / swarm.enforced_path_prefixes. Sensible defaults if absent.
|
|
33
|
+
|
|
34
|
+
# shellcheck source=./lib/common.sh
|
|
35
|
+
. "${BASH_SOURCE[0]%/*}/lib/common.sh"
|
|
36
|
+
read_payload
|
|
37
|
+
|
|
38
|
+
TOOL="$(payload_get .tool_name)"
|
|
39
|
+
case "$TOOL" in
|
|
40
|
+
Write|Edit|MultiEdit) ;;
|
|
41
|
+
*) emit_allow ;;
|
|
42
|
+
esac
|
|
43
|
+
|
|
44
|
+
FILE="$(payload_get .tool_input.file_path)"
|
|
45
|
+
[ -n "$FILE" ] || emit_allow
|
|
46
|
+
rel="${FILE#$CLAUDE_PROJECT_ROOT/}"
|
|
47
|
+
|
|
48
|
+
ACTIVE_WAVE="$STATE_DIR/swarm/active_wave.json"
|
|
49
|
+
if [ ! -f "$ACTIVE_WAVE" ]; then
|
|
50
|
+
# Not in a swarm — guard is dormant.
|
|
51
|
+
emit_allow
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
HOOK_REL="$rel" HOOK_ACTIVE="$ACTIVE_WAVE" HOOK_PROJECT_JSON="$PROJECT_JSON" python3 <<'PY'
|
|
55
|
+
import json, os, sys
|
|
56
|
+
|
|
57
|
+
rel = os.environ["HOOK_REL"]
|
|
58
|
+
active_path = os.environ["HOOK_ACTIVE"]
|
|
59
|
+
pj_path = os.environ["HOOK_PROJECT_JSON"]
|
|
60
|
+
|
|
61
|
+
# Load active wave.
|
|
62
|
+
try:
|
|
63
|
+
active = json.load(open(active_path))
|
|
64
|
+
except Exception as e:
|
|
65
|
+
# Corrupt active_wave.json — fail CLOSED. If we're inside a swarm we need
|
|
66
|
+
# this file to be readable; if we can't read it, we cannot safely allow
|
|
67
|
+
# arbitrary writes.
|
|
68
|
+
print(json.dumps({
|
|
69
|
+
"hookSpecificOutput": {
|
|
70
|
+
"hookEventName": "PreToolUse",
|
|
71
|
+
"permissionDecision": "deny",
|
|
72
|
+
"permissionDecisionReason": (
|
|
73
|
+
f"Swarm Boundary Guard: active_wave.json exists but could not be parsed ({e}). "
|
|
74
|
+
"This is a swarm-state corruption — swarm-dispatch must clean up and re-plan."
|
|
75
|
+
),
|
|
76
|
+
}
|
|
77
|
+
}))
|
|
78
|
+
sys.exit(0)
|
|
79
|
+
|
|
80
|
+
# Load config.
|
|
81
|
+
exempt_prefixes = [".claude/", ".git/"]
|
|
82
|
+
enforced_prefixes = None
|
|
83
|
+
try:
|
|
84
|
+
pj = json.load(open(pj_path))
|
|
85
|
+
sw = (pj.get("swarm") or {})
|
|
86
|
+
if isinstance(sw.get("exempt_path_prefixes"), list):
|
|
87
|
+
exempt_prefixes = sw["exempt_path_prefixes"]
|
|
88
|
+
if isinstance(sw.get("enforced_path_prefixes"), list):
|
|
89
|
+
enforced_prefixes = sw["enforced_path_prefixes"]
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
# Exempt paths (tooling / vcs / deps) — never enforced.
|
|
94
|
+
for p in exempt_prefixes:
|
|
95
|
+
if rel.startswith(p):
|
|
96
|
+
sys.exit(0)
|
|
97
|
+
|
|
98
|
+
# If enforced_prefixes is set, only enforce within those roots.
|
|
99
|
+
# If absent, enforce on everything not exempt.
|
|
100
|
+
if enforced_prefixes is not None:
|
|
101
|
+
if not any(rel.startswith(p) for p in enforced_prefixes):
|
|
102
|
+
sys.exit(0)
|
|
103
|
+
|
|
104
|
+
# Build union of active write_sets.
|
|
105
|
+
write_sets = active.get("write_sets") or []
|
|
106
|
+
owners = {} # file -> task_id
|
|
107
|
+
for entry in write_sets:
|
|
108
|
+
tid = entry.get("task_id", "?")
|
|
109
|
+
for f in (entry.get("files") or []):
|
|
110
|
+
owners[f] = tid
|
|
111
|
+
|
|
112
|
+
if rel in owners:
|
|
113
|
+
sys.exit(0)
|
|
114
|
+
|
|
115
|
+
# Plan drift: file not owned by any active task in this wave.
|
|
116
|
+
slug = active.get("slug", "?")
|
|
117
|
+
wave = active.get("wave", "?")
|
|
118
|
+
owners_preview = ", ".join(sorted(set(owners.keys()))[:6])
|
|
119
|
+
if len(owners) > 6:
|
|
120
|
+
owners_preview += f", … ({len(owners)} total)"
|
|
121
|
+
|
|
122
|
+
msg = (
|
|
123
|
+
f"Swarm Boundary Guard: write to '{rel}' denied. "
|
|
124
|
+
f"Swarm '{slug}' wave {wave} is active; no task in this wave owns that file. "
|
|
125
|
+
f"Files owned by this wave: {owners_preview or '(none)'}. "
|
|
126
|
+
"Either (a) abort this write, (b) stop the swarm and re-plan so the file is in some task's write_set, "
|
|
127
|
+
"or (c) if this is a genuinely required file that was missed at plan time, surface it — do not patch mid-wave."
|
|
128
|
+
)
|
|
129
|
+
print(json.dumps({
|
|
130
|
+
"hookSpecificOutput": {
|
|
131
|
+
"hookEventName": "PreToolUse",
|
|
132
|
+
"permissionDecision": "deny",
|
|
133
|
+
"permissionDecisionReason": msg,
|
|
134
|
+
}
|
|
135
|
+
}))
|
|
136
|
+
PY
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# TDD Order Guard — PreToolUse(Write)
|
|
3
|
+
#
|
|
4
|
+
# When Claude creates a new source file (first write, file does not exist),
|
|
5
|
+
# require that a corresponding test file already exists. Enforces
|
|
6
|
+
# test-before-source TDD per seed.md § "TDD order guard".
|
|
7
|
+
#
|
|
8
|
+
# Applies only if .tdd.enabled is true in project.json. Skips edits to
|
|
9
|
+
# existing files (TDD ordering applies to file creation, not later edits).
|
|
10
|
+
# Honours source/test/exempt globs from project.json.
|
|
11
|
+
|
|
12
|
+
# shellcheck source=./lib/common.sh
|
|
13
|
+
. "${BASH_SOURCE[0]%/*}/lib/common.sh"
|
|
14
|
+
read_payload
|
|
15
|
+
|
|
16
|
+
TOOL="$(payload_get .tool_name)"
|
|
17
|
+
[ "$TOOL" = "Write" ] || emit_allow
|
|
18
|
+
|
|
19
|
+
enabled="$(project_get .tdd.enabled)"
|
|
20
|
+
[ "$enabled" = "True" ] || [ "$enabled" = "true" ] || emit_allow
|
|
21
|
+
|
|
22
|
+
FILE="$(payload_get .tool_input.file_path)"
|
|
23
|
+
[ -n "$FILE" ] || emit_allow
|
|
24
|
+
|
|
25
|
+
# Only apply on file *creation*. If file already exists, it's an edit.
|
|
26
|
+
[ -e "$FILE" ] && emit_allow
|
|
27
|
+
|
|
28
|
+
# Relative path for glob matching.
|
|
29
|
+
rel="${FILE#$CLAUDE_PROJECT_ROOT/}"
|
|
30
|
+
|
|
31
|
+
# Exempt patterns (docs, config, .claude itself, etc.).
|
|
32
|
+
if path_matches_globs "$rel" "$(project_get .tdd.exempt_globs)"; then
|
|
33
|
+
emit_allow
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
# Not a source path → nothing to enforce.
|
|
37
|
+
if ! path_matches_globs "$rel" "$(project_get .tdd.source_globs)"; then
|
|
38
|
+
emit_allow
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
# Never gate test files themselves.
|
|
42
|
+
if path_matches_globs "$rel" "$(project_get .tdd.test_globs)"; then
|
|
43
|
+
emit_allow
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
# Look for a corresponding test file. Candidates are derived from
|
|
47
|
+
# `project.json → tdd.test_globs` so customized projects (Go-style
|
|
48
|
+
# `<pkg>_test.go`, Rust `tests/integration.rs`, jest `__tests__/*`,
|
|
49
|
+
# etc.) are honored without code changes here.
|
|
50
|
+
stem="$(basename "$FILE")"
|
|
51
|
+
name="${stem%.*}"
|
|
52
|
+
ext="${stem##*.}"
|
|
53
|
+
dir="$(dirname "$rel")"
|
|
54
|
+
|
|
55
|
+
# Candidate generation in Python — reads tdd.test_globs and produces
|
|
56
|
+
# combinations of {dir-root × name × suffix/prefix} plus mirrored layout.
|
|
57
|
+
candidates="$(python3 - "$rel" "$CLAUDE_PROJECT_ROOT" <<'PY' 2>/dev/null || true
|
|
58
|
+
import sys, os, re, json, pathlib
|
|
59
|
+
|
|
60
|
+
rel = sys.argv[1]
|
|
61
|
+
root = pathlib.Path(sys.argv[2])
|
|
62
|
+
proj_path = root / ".claude/project.json"
|
|
63
|
+
try:
|
|
64
|
+
cfg = json.loads(proj_path.read_text(encoding="utf-8"))
|
|
65
|
+
except Exception:
|
|
66
|
+
cfg = {}
|
|
67
|
+
test_globs = (cfg.get("tdd", {}) or {}).get("test_globs", []) or []
|
|
68
|
+
|
|
69
|
+
stem = os.path.basename(rel)
|
|
70
|
+
name, _, ext = stem.rpartition(".")
|
|
71
|
+
ext = ext or "py"
|
|
72
|
+
src_dir = os.path.dirname(rel)
|
|
73
|
+
|
|
74
|
+
# Extension family — when source is .js/.mjs/.cjs, tests may use any of
|
|
75
|
+
# the JS-ESM-family extensions. Same for .ts/.tsx/.mts/.cts. Without this,
|
|
76
|
+
# a .js source whose test is .mjs (a common node:test ESM convention)
|
|
77
|
+
# would fail the existence check and the guard would falsely block.
|
|
78
|
+
JS_FAMILY = {"js", "mjs", "cjs"}
|
|
79
|
+
TS_FAMILY = {"ts", "tsx", "mts", "cts"}
|
|
80
|
+
if ext in JS_FAMILY:
|
|
81
|
+
ext_variants = list(JS_FAMILY)
|
|
82
|
+
elif ext in TS_FAMILY:
|
|
83
|
+
ext_variants = list(TS_FAMILY)
|
|
84
|
+
else:
|
|
85
|
+
ext_variants = [ext]
|
|
86
|
+
|
|
87
|
+
# Strip a source-root prefix so candidates can mirror the layout under a
|
|
88
|
+
# parallel test-root: src/foo/bar.py → foo/bar.py.
|
|
89
|
+
src_subpath = rel
|
|
90
|
+
for r in ("src/", "lib/", "app/", "pkg/", "internal/"):
|
|
91
|
+
if rel.startswith(r):
|
|
92
|
+
src_subpath = rel[len(r):]
|
|
93
|
+
break
|
|
94
|
+
src_subpath_noext = re.sub(r"\.[^./]+$", "", src_subpath)
|
|
95
|
+
|
|
96
|
+
suffix_patterns = [] # e.g. "_test", ".test", ".spec" — appended to name
|
|
97
|
+
prefix_patterns = [] # e.g. "test_" — prepended to name
|
|
98
|
+
dir_roots = [] # e.g. "tests", "test", "spec", "__tests__"
|
|
99
|
+
|
|
100
|
+
for g in test_globs:
|
|
101
|
+
# **/*<sep><word>.* → suffix `<sep><word>` e.g. "_test", ".test"
|
|
102
|
+
m = re.match(r"^\*\*/\*([._-][^*/.]+)\.\*$", g)
|
|
103
|
+
if m:
|
|
104
|
+
suffix_patterns.append(m.group(1))
|
|
105
|
+
continue
|
|
106
|
+
# **/<word>_*.* → prefix "<word>_" e.g. "test_*"
|
|
107
|
+
m = re.match(r"^\*\*/([^*/.]+)_\*\.\*$", g)
|
|
108
|
+
if m:
|
|
109
|
+
prefix_patterns.append(m.group(1) + "_")
|
|
110
|
+
continue
|
|
111
|
+
# <dir>/** → directory root
|
|
112
|
+
m = re.match(r"^([\w._-]+)/\*\*$", g)
|
|
113
|
+
if m:
|
|
114
|
+
dir_roots.append(m.group(1))
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
# Backstop conventions for any pattern dimension the configured globs
|
|
118
|
+
# don't cover. The common project.json ships directory globs only
|
|
119
|
+
# (`tests/**` etc.) — without these backstops we'd miss pytest's
|
|
120
|
+
# `test_<name>` prefix and the standard `_test`/`.test`/`.spec` suffixes.
|
|
121
|
+
if not suffix_patterns:
|
|
122
|
+
suffix_patterns = ["_test", ".test", ".spec"]
|
|
123
|
+
if not prefix_patterns:
|
|
124
|
+
prefix_patterns = ["test_"]
|
|
125
|
+
if not dir_roots:
|
|
126
|
+
dir_roots = ["tests", "test", "spec", "__tests__"]
|
|
127
|
+
|
|
128
|
+
cands = set()
|
|
129
|
+
def add(p): cands.add(p.lstrip("/"))
|
|
130
|
+
|
|
131
|
+
# Co-located beside the source file (across extension family)
|
|
132
|
+
for e in ext_variants:
|
|
133
|
+
for s in suffix_patterns:
|
|
134
|
+
add(f"{src_dir}/{name}{s}.{e}")
|
|
135
|
+
for p in prefix_patterns:
|
|
136
|
+
add(f"{src_dir}/{p}{name}.{e}")
|
|
137
|
+
|
|
138
|
+
# Under each dir-root: stem-based + mirrored-layout (across extension family)
|
|
139
|
+
for d in dir_roots:
|
|
140
|
+
add(f"{d}/{src_subpath}") # mirror layout: tests/foo/bar.py
|
|
141
|
+
for e in ext_variants:
|
|
142
|
+
add(f"{d}/{name}.{e}") # plain stem (Rust-like)
|
|
143
|
+
for s in suffix_patterns:
|
|
144
|
+
add(f"{d}/{name}{s}.{e}")
|
|
145
|
+
add(f"{d}/{src_subpath_noext}{s}.{e}")
|
|
146
|
+
for p in prefix_patterns:
|
|
147
|
+
add(f"{d}/{p}{name}.{e}")
|
|
148
|
+
add(f"{d}/{p}{src_subpath_noext}.{e}")
|
|
149
|
+
|
|
150
|
+
# Co-located inside __tests__-style subdirs (Jest)
|
|
151
|
+
for d in dir_roots:
|
|
152
|
+
if d.startswith("_") or d == "__tests__":
|
|
153
|
+
for e in ext_variants:
|
|
154
|
+
for s in suffix_patterns:
|
|
155
|
+
add(f"{src_dir}/{d}/{name}{s}.{e}")
|
|
156
|
+
|
|
157
|
+
print("\n".join(sorted(cands)))
|
|
158
|
+
PY
|
|
159
|
+
)"
|
|
160
|
+
|
|
161
|
+
found=""
|
|
162
|
+
while IFS= read -r c; do
|
|
163
|
+
[ -z "$c" ] && continue
|
|
164
|
+
if [ -f "$CLAUDE_PROJECT_ROOT/$c" ]; then
|
|
165
|
+
found="$c"
|
|
166
|
+
break
|
|
167
|
+
fi
|
|
168
|
+
done <<< "$candidates"
|
|
169
|
+
|
|
170
|
+
if [ -n "$found" ]; then
|
|
171
|
+
log_line tdd_order_guard "ALLOWED test exists: $found for $rel"
|
|
172
|
+
emit_allow
|
|
173
|
+
fi
|
|
174
|
+
|
|
175
|
+
log_line tdd_order_guard "BLOCKED no test for: $rel"
|
|
176
|
+
emit_block "TDD Order Guard: no test file found for new source '$rel'. Write the failing test first. Candidates were derived from project.json → tdd.test_globs (e.g. tests/${name}_test.${ext}, ${dir}/${name}_test.${ext}, tests/${src_subpath_noext:-${name}}.${ext}). If this file truly has no tests by design, add the path to .tdd.exempt_globs in .claude/project.json."
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Test Runner hook — PostToolUse(Edit|Write|MultiEdit)
|
|
3
|
+
#
|
|
4
|
+
# Runs the project-configured test command against the changed file's
|
|
5
|
+
# affected tests. This is a GUIDE hook: until `.claude/project.json`
|
|
6
|
+
# declares `test.cmd`, it emits guidance pointing at `/init-project` rather
|
|
7
|
+
# than failing. Once configured, it executes the command and surfaces
|
|
8
|
+
# failures as stderr info (PostToolUse cannot block the edit that already
|
|
9
|
+
# happened, but it surfaces test failures immediately so Claude reacts).
|
|
10
|
+
#
|
|
11
|
+
# Projects are free to replace this script entirely with their own logic.
|
|
12
|
+
|
|
13
|
+
# shellcheck source=./lib/common.sh
|
|
14
|
+
. "${BASH_SOURCE[0]%/*}/lib/common.sh"
|
|
15
|
+
read_payload
|
|
16
|
+
|
|
17
|
+
TOOL="$(payload_get .tool_name)"
|
|
18
|
+
case "$TOOL" in
|
|
19
|
+
Edit|Write|MultiEdit) ;;
|
|
20
|
+
*) emit_allow ;;
|
|
21
|
+
esac
|
|
22
|
+
|
|
23
|
+
FILE="$(payload_get .tool_input.file_path)"
|
|
24
|
+
[ -n "$FILE" ] || emit_allow
|
|
25
|
+
rel="${FILE#$CLAUDE_PROJECT_ROOT/}"
|
|
26
|
+
|
|
27
|
+
# Skip obviously non-code changes.
|
|
28
|
+
case "$rel" in
|
|
29
|
+
*.md|*.json|*.yaml|*.yml|*.toml|*.txt|docs/*|.claude/*|.config/*) emit_allow ;;
|
|
30
|
+
esac
|
|
31
|
+
|
|
32
|
+
configured="$(project_get .configured)"
|
|
33
|
+
cmd="$(project_get .test.cmd)"
|
|
34
|
+
|
|
35
|
+
if [ "$configured" != "True" ] && [ "$configured" != "true" ]; then
|
|
36
|
+
emit_info "Test Runner: .claude/project.json is not configured yet. Run \`/init-project\` to declare the test command for this repo. (Skipping test run for '$rel'.)"
|
|
37
|
+
emit_allow
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
if [ -z "$cmd" ] || [ "$cmd" = "None" ]; then
|
|
41
|
+
emit_info "Test Runner: no .test.cmd set in .claude/project.json. Skipping tests for '$rel'."
|
|
42
|
+
emit_allow
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
# Resolve affected tests. If a custom resolver is configured, delegate to it;
|
|
46
|
+
# otherwise pass the changed file path and let the test command decide.
|
|
47
|
+
resolver="$(project_get .test.affected_resolver)"
|
|
48
|
+
affected=""
|
|
49
|
+
if [ -n "$resolver" ] && [ "$resolver" != "None" ]; then
|
|
50
|
+
if [ -x "$CLAUDE_PROJECT_ROOT/$resolver" ]; then
|
|
51
|
+
affected="$("$CLAUDE_PROJECT_ROOT/$resolver" "$rel" 2>/dev/null || true)"
|
|
52
|
+
else
|
|
53
|
+
emit_info "Test Runner: affected_resolver '$resolver' not found or not executable."
|
|
54
|
+
fi
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
timeout_s="$(project_get .test.timeout_seconds)"
|
|
58
|
+
[ -z "$timeout_s" ] && timeout_s=120
|
|
59
|
+
|
|
60
|
+
# Compose final command. {file} and {affected} placeholders are substituted.
|
|
61
|
+
final="${cmd//\{file\}/$rel}"
|
|
62
|
+
final="${final//\{affected\}/$affected}"
|
|
63
|
+
|
|
64
|
+
emit_info "Test Runner: running \`$final\` (timeout ${timeout_s}s)"
|
|
65
|
+
out="$(cd "$CLAUDE_PROJECT_ROOT" && timeout "${timeout_s}s" bash -lc "$final" 2>&1)"
|
|
66
|
+
rc=$?
|
|
67
|
+
if [ $rc -ne 0 ]; then
|
|
68
|
+
log_line test_runner "FAIL rc=$rc cmd=$final"
|
|
69
|
+
emit_info "Test Runner: FAILED (exit $rc) — output:"
|
|
70
|
+
emit_info "$out"
|
|
71
|
+
exit 2
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
log_line test_runner "PASS cmd=$final"
|
|
75
|
+
emit_allow
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
## Project memory — index (.claude/memory/)
|
|
2
|
+
|
|
3
|
+
HEAD: `n/a` · total entries: 32 · stale (>=30 commits old): 0
|
|
4
|
+
|
|
5
|
+
| File | Entries | Stale | Status |
|
|
6
|
+
|---|---:|---:|---|
|
|
7
|
+
| `landmarks.md` | 19 | 0 | ok |
|
|
8
|
+
| `libraries.md` | 3 | 0 | ok |
|
|
9
|
+
| `decisions.md` | 1 | 0 | ok |
|
|
10
|
+
| `landmines.md` | 5 | 0 | ok |
|
|
11
|
+
| `conventions.md` | 3 | 0 | ok |
|
|
12
|
+
| `pending-questions.md` | 1 | 0 | ok |
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Fixture-based integration tests for memory_session_start.sh
|
|
3
|
+
# Covers AC-003, AC-005, AC-007, AC-008 from docs/specs/memory-lifecycle-closure.md
|
|
4
|
+
#
|
|
5
|
+
# Each test builds a synthetic .claude/memory/ tree under a tempdir, invokes
|
|
6
|
+
# the hook with PROJECT_ROOT pointed at that tempdir, and asserts on the
|
|
7
|
+
# emitted additionalContext JSON.
|
|
8
|
+
|
|
9
|
+
set -uo pipefail
|
|
10
|
+
|
|
11
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
12
|
+
REPO_ROOT="$(cd "$HERE/../../.." && pwd)"
|
|
13
|
+
HOOK="$REPO_ROOT/.claude/hooks/memory_session_start.sh"
|
|
14
|
+
FIXTURES="$HERE/fixtures"
|
|
15
|
+
|
|
16
|
+
PASS=0; FAIL=0; FAILED=()
|
|
17
|
+
|
|
18
|
+
# --- assertion helpers --------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
fail() { echo " FAIL: $*"; return 1; }
|
|
21
|
+
|
|
22
|
+
assert_contains() {
|
|
23
|
+
local haystack="$1" needle="$2" msg="$3"
|
|
24
|
+
case "$haystack" in
|
|
25
|
+
*"$needle"*) return 0 ;;
|
|
26
|
+
*) fail "$msg :: expected to contain: $needle" ;;
|
|
27
|
+
esac
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
assert_not_contains() {
|
|
31
|
+
local haystack="$1" needle="$2" msg="$3"
|
|
32
|
+
case "$haystack" in
|
|
33
|
+
*"$needle"*) fail "$msg :: should NOT contain: $needle" ;;
|
|
34
|
+
*) return 0 ;;
|
|
35
|
+
esac
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# Run the hook against a project root. $1 = project root containing .claude/memory/.
|
|
39
|
+
# Prints the parsed additionalContext to stdout. Returns 1 if hook crashed.
|
|
40
|
+
run_hook() {
|
|
41
|
+
local proj="$1"
|
|
42
|
+
CLAUDE_PROJECT_DIR="$proj" \
|
|
43
|
+
bash "$HOOK" <<< '{}' 2>/dev/null | python3 -c '
|
|
44
|
+
import json, sys
|
|
45
|
+
data = sys.stdin.read().strip()
|
|
46
|
+
if not data:
|
|
47
|
+
sys.exit(2)
|
|
48
|
+
j = json.loads(data)
|
|
49
|
+
print(j["hookSpecificOutput"]["additionalContext"])
|
|
50
|
+
'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Build a minimal synthetic memory tree. $1 = root.
|
|
54
|
+
seed_tree() {
|
|
55
|
+
local root="$1"
|
|
56
|
+
mkdir -p "$root/.claude/memory" "$root/.claude/state/harness"
|
|
57
|
+
# Frontmatter-only files so hook reports 0 entries unless we add ## blocks.
|
|
58
|
+
for f in landmarks libraries decisions landmines conventions pending-questions; do
|
|
59
|
+
cat > "$root/.claude/memory/$f.md" <<'EOF'
|
|
60
|
+
---
|
|
61
|
+
owners: [test]
|
|
62
|
+
size-cap: 500
|
|
63
|
+
key: test
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
# Test fixture
|
|
67
|
+
EOF
|
|
68
|
+
done
|
|
69
|
+
# Minimal _pending.md so the hook does not emit the nag by accident.
|
|
70
|
+
cat > "$root/.claude/memory/_pending.md" <<'EOF'
|
|
71
|
+
---
|
|
72
|
+
owners: [test]
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
# Pending
|
|
76
|
+
EOF
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# Add an entry block to a canonical file. $1 root, $2 file basename, $3 entry body.
|
|
80
|
+
add_entry() {
|
|
81
|
+
local root="$1" file="$2"; shift 2
|
|
82
|
+
printf '\n%s\n' "$*" >> "$root/.claude/memory/$file.md"
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# Compute an ISO date N days ago (portable: GNU or BSD date).
|
|
86
|
+
days_ago() {
|
|
87
|
+
local n="$1"
|
|
88
|
+
if date -u -d "$n days ago" +%Y-%m-%d 2>/dev/null; then return; fi
|
|
89
|
+
date -u -v "-${n}d" +%Y-%m-%d
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
run() {
|
|
93
|
+
local name="$1"
|
|
94
|
+
echo "RUN $name"
|
|
95
|
+
if "$name"; then
|
|
96
|
+
PASS=$((PASS+1)); echo "PASS $name"
|
|
97
|
+
else
|
|
98
|
+
FAIL=$((FAIL+1)); FAILED+=("$name"); echo "FAIL $name"
|
|
99
|
+
fi
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# --- tests --------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
test_when_7_entries_stale_then_top_5_listed_oldest_first() {
|
|
105
|
+
local tmp; tmp="$(mktemp -d)"; trap "rm -rf $tmp" RETURN
|
|
106
|
+
seed_tree "$tmp"
|
|
107
|
+
# 7 entries across 3 files with ages 100/95/90/50/40/30/5 days, no closure fields.
|
|
108
|
+
local i=0
|
|
109
|
+
for spec in "landmarks:lm-a:100" "landmarks:lm-b:95" "libraries:lib-a:90" \
|
|
110
|
+
"libraries:lib-b:50" "decisions:dec-a:40" "decisions:dec-b:30" \
|
|
111
|
+
"conventions:conv-a:5"; do
|
|
112
|
+
local file="${spec%%:*}" key key_age age
|
|
113
|
+
key="$(echo "$spec" | cut -d: -f2)"
|
|
114
|
+
age="${spec##*:}"
|
|
115
|
+
add_entry "$tmp" "$file" "## $key
|
|
116
|
+
|
|
117
|
+
- role: test
|
|
118
|
+
- verified-at: HEAD
|
|
119
|
+
- last-touched: $(days_ago "$age")"
|
|
120
|
+
i=$((i+1))
|
|
121
|
+
done
|
|
122
|
+
local out; out="$(run_hook "$tmp")"
|
|
123
|
+
assert_contains "$out" "## Stale entries" "AC-003 stale block missing" || return 1
|
|
124
|
+
# The 5 oldest by last-touched are 100/95/90/50/40 → lm-a lm-b lib-a lib-b dec-a.
|
|
125
|
+
for k in lm-a lm-b lib-a lib-b dec-a; do
|
|
126
|
+
assert_contains "$out" "$k" "AC-003 expected key $k in stale block" || return 1
|
|
127
|
+
done
|
|
128
|
+
# 30-day-old entry IS stale (>=30 day threshold for non-git), but only top 5 listed.
|
|
129
|
+
# conv-a (5 days) is not stale; should be absent from the block.
|
|
130
|
+
# With 6 stale entries total, the 6th (dec-b) should appear via overflow.
|
|
131
|
+
assert_contains "$out" "and 1 more" "AC-003 overflow indicator missing" || return 1
|
|
132
|
+
assert_not_contains "$out" "## conv-a" "AC-003 non-stale should not appear in block" || return 1
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
test_when_8_stale_then_overflow_indicator_shows_3_more() {
|
|
136
|
+
local tmp; tmp="$(mktemp -d)"; trap "rm -rf $tmp" RETURN
|
|
137
|
+
seed_tree "$tmp"
|
|
138
|
+
# 8 stale entries, ages 200..130 by 10s.
|
|
139
|
+
local i=0
|
|
140
|
+
for age in 200 190 180 170 160 150 140 130; do
|
|
141
|
+
add_entry "$tmp" "landmarks" "## ent-$i
|
|
142
|
+
|
|
143
|
+
- role: test
|
|
144
|
+
- verified-at: HEAD
|
|
145
|
+
- last-touched: $(days_ago "$age")"
|
|
146
|
+
i=$((i+1))
|
|
147
|
+
done
|
|
148
|
+
local out; out="$(run_hook "$tmp")"
|
|
149
|
+
assert_contains "$out" "## Stale entries" "AC-003 stale block missing" || return 1
|
|
150
|
+
assert_contains "$out" "and 3 more" "AC-003 expected overflow '… and 3 more'" || return 1
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
test_when_5_stale_with_identical_last_touched_then_alphabetical_by_file_colon_key() {
|
|
154
|
+
local tmp; tmp="$(mktemp -d)"; trap "rm -rf $tmp" RETURN
|
|
155
|
+
seed_tree "$tmp"
|
|
156
|
+
local d; d="$(days_ago 120)"
|
|
157
|
+
# Place entries across files in non-alphabetical insertion order; expect
|
|
158
|
+
# alphabetical-by-file:key in the rendered block.
|
|
159
|
+
add_entry "$tmp" "landmines" "## zeta
|
|
160
|
+
|
|
161
|
+
- role: test
|
|
162
|
+
- verified-at: HEAD
|
|
163
|
+
- last-touched: $d"
|
|
164
|
+
add_entry "$tmp" "landmarks" "## alpha
|
|
165
|
+
|
|
166
|
+
- role: test
|
|
167
|
+
- verified-at: HEAD
|
|
168
|
+
- last-touched: $d"
|
|
169
|
+
add_entry "$tmp" "decisions" "## kappa
|
|
170
|
+
|
|
171
|
+
- role: test
|
|
172
|
+
- verified-at: HEAD
|
|
173
|
+
- last-touched: $d"
|
|
174
|
+
add_entry "$tmp" "libraries" "## beta
|
|
175
|
+
|
|
176
|
+
- role: test
|
|
177
|
+
- verified-at: HEAD
|
|
178
|
+
- last-touched: $d"
|
|
179
|
+
add_entry "$tmp" "conventions" "## mu
|
|
180
|
+
|
|
181
|
+
- role: test
|
|
182
|
+
- verified-at: HEAD
|
|
183
|
+
- last-touched: $d"
|
|
184
|
+
local out; out="$(run_hook "$tmp")"
|
|
185
|
+
# Expected lexicographic order of "<file>:<key>":
|
|
186
|
+
# conventions:mu < decisions:kappa < landmarks:alpha < landmines:zeta < libraries:beta
|
|
187
|
+
# Find the first appearance position of each key in the stale block.
|
|
188
|
+
local block; block="$(printf '%s\n' "$out" | awk '/## Stale entries/{flag=1;next}/^## /{flag=0}flag')"
|
|
189
|
+
local order; order="$(printf '%s\n' "$block" | grep -oE '(alpha|beta|kappa|mu|zeta)' | head -5 | tr '\n' ' ')"
|
|
190
|
+
assert_contains " $order" " mu kappa alpha zeta beta " "AC-003 alphabetical-by-file:key order wrong: got [$order]" || return 1
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
test_when_closure_field_and_stale_sha_then_not_counted_stale() {
|
|
194
|
+
local tmp; tmp="$(mktemp -d)"; trap "rm -rf $tmp" RETURN
|
|
195
|
+
seed_tree "$tmp"
|
|
196
|
+
# Entry stale by date but carries resolved-at on pending-questions → MUST be excluded.
|
|
197
|
+
add_entry "$tmp" "pending-questions" "## Q-999
|
|
198
|
+
|
|
199
|
+
- Question: stub
|
|
200
|
+
- verified-at: HEAD
|
|
201
|
+
- last-touched: $(days_ago 200)
|
|
202
|
+
- resolved-at: $(days_ago 1)"
|
|
203
|
+
# Plus a normal stale entry on landmarks (no closure) → still counted.
|
|
204
|
+
add_entry "$tmp" "landmarks" "## still-open
|
|
205
|
+
|
|
206
|
+
- role: test
|
|
207
|
+
- verified-at: HEAD
|
|
208
|
+
- last-touched: $(days_ago 200)"
|
|
209
|
+
local out; out="$(run_hook "$tmp")"
|
|
210
|
+
assert_not_contains "$out" "Q-999" "AC-005 closed entry must not appear in stale block" || return 1
|
|
211
|
+
# Header count must be 1, not 2.
|
|
212
|
+
if printf '%s\n' "$out" | grep -qE 'stale \(>=30 commits old\): 1\b'; then :; else
|
|
213
|
+
fail "AC-005 expected stale count 1 in header"
|
|
214
|
+
return 1
|
|
215
|
+
fi
|
|
216
|
+
assert_contains "$out" "still-open" "AC-005 sanity: unclosed stale entry should appear" || return 1
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
test_when_audit_runs_against_changed_repo_then_exit_0() {
|
|
220
|
+
( cd "$REPO_ROOT" && bash .claude/skills/audit-baseline/audit.sh >/dev/null 2>&1 ) \
|
|
221
|
+
|| { fail "AC-007 audit exited non-zero"; return 1; }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
test_when_hook_runs_unchanged_tree_then_header_and_table_byte_equal() {
|
|
225
|
+
# Run hook against the real repo memory tree and compare header+table to
|
|
226
|
+
# the captured pre-spec reference.
|
|
227
|
+
local out; out="$(run_hook "$REPO_ROOT")"
|
|
228
|
+
local actual_block
|
|
229
|
+
actual_block="$(printf '%s\n' "$out" | python3 -c '
|
|
230
|
+
import sys
|
|
231
|
+
lines = sys.stdin.read().split("\n")
|
|
232
|
+
started = False
|
|
233
|
+
out = []
|
|
234
|
+
for ln in lines:
|
|
235
|
+
if ln.startswith("## Project memory"):
|
|
236
|
+
started = True
|
|
237
|
+
if not started:
|
|
238
|
+
continue
|
|
239
|
+
out.append(ln)
|
|
240
|
+
if ln.startswith("| `pending-questions.md`"):
|
|
241
|
+
break
|
|
242
|
+
sys.stdout.write("\n".join(out) + "\n")
|
|
243
|
+
')"
|
|
244
|
+
local expected; expected="$(cat "$FIXTURES/ac008_byte_equal_reference.txt")"
|
|
245
|
+
if [ "$actual_block" = "$expected" ]; then return 0; fi
|
|
246
|
+
fail "AC-008 header+table not byte-equal"
|
|
247
|
+
diff <(printf '%s' "$expected") <(printf '%s' "$actual_block") || true
|
|
248
|
+
return 1
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
test_when_old_resume_snapshot_reinjected_then_hook_does_not_crash() {
|
|
252
|
+
local tmp; tmp="$(mktemp -d)"; trap "rm -rf $tmp" RETURN
|
|
253
|
+
seed_tree "$tmp"
|
|
254
|
+
# Old _resume.md from a pre-spec session — minimal body, no new index format.
|
|
255
|
+
cat > "$tmp/.claude/memory/_resume.md" <<'EOF'
|
|
256
|
+
---
|
|
257
|
+
written_at: 2026-04-30T12:00:00Z
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
# Resume snapshot (legacy)
|
|
261
|
+
Last phase: spec
|
|
262
|
+
EOF
|
|
263
|
+
local out
|
|
264
|
+
out="$(run_hook "$tmp")" || { fail "AC-008 hook crashed on legacy _resume"; return 1; }
|
|
265
|
+
assert_contains "$out" "## Project memory" "AC-008 expected index header still present" || return 1
|
|
266
|
+
assert_contains "$out" "Resume snapshot (legacy)" "AC-008 legacy body should be appended" || return 1
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
# --- runner -------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
run test_when_7_entries_stale_then_top_5_listed_oldest_first
|
|
272
|
+
run test_when_8_stale_then_overflow_indicator_shows_3_more
|
|
273
|
+
run test_when_5_stale_with_identical_last_touched_then_alphabetical_by_file_colon_key
|
|
274
|
+
run test_when_closure_field_and_stale_sha_then_not_counted_stale
|
|
275
|
+
run test_when_audit_runs_against_changed_repo_then_exit_0
|
|
276
|
+
run test_when_hook_runs_unchanged_tree_then_header_and_table_byte_equal
|
|
277
|
+
run test_when_old_resume_snapshot_reinjected_then_hook_does_not_crash
|
|
278
|
+
|
|
279
|
+
echo "----"
|
|
280
|
+
echo "Passed: $PASS Failed: $FAIL"
|
|
281
|
+
if [ "$FAIL" -gt 0 ]; then
|
|
282
|
+
echo "Failed tests:"
|
|
283
|
+
for t in "${FAILED[@]}"; do echo " - $t"; done
|
|
284
|
+
fi
|
|
285
|
+
exit $((FAIL > 0))
|