create-anpunkit 2.0.3 → 2.2.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 (34) hide show
  1. package/bin/cli.js +3 -2
  2. package/package.json +1 -1
  3. package/template/.claude/agents/e2e-runner.md +44 -13
  4. package/template/.claude/agents/implementer.md +13 -7
  5. package/template/.claude/agents/infra-provisioner.md +33 -2
  6. package/template/.claude/agents/planner.md +79 -6
  7. package/template/.claude/agents/researcher.md +19 -8
  8. package/template/.claude/agents/spec-author.md +118 -0
  9. package/template/.claude/agents/test-author.md +76 -43
  10. package/template/.claude/anpunkit-manifest.json +47 -27
  11. package/template/.claude/commands/infra.md +28 -3
  12. package/template/.claude/commands/overview.md +84 -24
  13. package/template/.claude/commands/phase.md +139 -40
  14. package/template/.claude/commands/replan.md +23 -9
  15. package/template/.cursor/commands/infra.md +26 -1
  16. package/template/.cursor/commands/overview.md +83 -23
  17. package/template/.cursor/commands/phase.md +138 -39
  18. package/template/.cursor/commands/replan.md +22 -8
  19. package/template/.gitattributes +1 -0
  20. package/template/AGENTS.md +215 -36
  21. package/template/CLAUDE.md +1 -0
  22. package/template/README.md +59 -23
  23. package/template/anpunkit.png +0 -0
  24. package/template/commands.src/infra.md +28 -3
  25. package/template/commands.src/overview.md +84 -24
  26. package/template/commands.src/phase.md +139 -40
  27. package/template/commands.src/replan.md +23 -9
  28. package/template/docs/DESIGN_LOG.md +192 -1
  29. package/template/index.html +339 -0
  30. package/template/scripts/spec-conformance.sh +93 -0
  31. package/template/scripts/spec-staleness.sh +115 -0
  32. package/template/setup.sh +110 -47
  33. package/template/tests/helpers/spec-assert.py +162 -0
  34. package/template/tests/helpers/spec-assert.ts +158 -0
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env bash
2
+ # spec-conformance.sh — block GREEN until the phase spec is fully filled and every
3
+ # case is verified by a boundary test (v2.2). Runs between RED and GREEN, replacing
4
+ # the v2.1 human TEST REVIEW gate. (§5.55, rule 18)
5
+ #
6
+ # Two checks, both loud-fail (nonzero), no side effects:
7
+ # 1. NO `TBD` remains in docs/spec-phase-<n>.md or any fixture it references.
8
+ # 2. EVERY case-id in the spec table is cited by a test via `# spec: <case-id>`
9
+ # (or `// spec: <case-id>`) under tests/ or e2e/.
10
+ #
11
+ # bash scripts/spec-conformance.sh <phase-n>
12
+ #
13
+ # The case→test citation here, plus the placement convention (contract/transition
14
+ # tests live in tests/regression/), is what closes the rule-14 chain: reachable
15
+ # transition → filled case (rule 14) → cited boundary test (this) → regression corpus.
16
+ set -euo pipefail
17
+ cd "${CLAUDE_PROJECT_DIR:-.}"
18
+
19
+ N="${1:-}"
20
+ [ -n "$N" ] || { echo "spec-conformance: usage: spec-conformance.sh <phase-n>" >&2; exit 2; }
21
+
22
+ SPEC="docs/spec-phase-${N}.md"
23
+ [ -f "$SPEC" ] || { echo "spec-conformance: $SPEC not found." >&2; exit 2; }
24
+
25
+ FAIL=0
26
+
27
+ # ---- 1. no TBD left in the spec ----
28
+ if grep -nE '\bTBD\b' "$SPEC" >/dev/null 2>&1; then
29
+ echo "spec-conformance: FAIL — unfilled 'TBD' marker(s) remain in $SPEC:" >&2
30
+ grep -nE '\bTBD\b' "$SPEC" | sed 's/^/ /' >&2
31
+ echo " Every case must be filled (or escalated as CASE-SET-DIVERGENCE) before GREEN." >&2
32
+ FAIL=1
33
+ fi
34
+
35
+ # ---- referenced fixtures: must exist + carry no TBD ----
36
+ FIXREFS="$(grep -oE 'fixtures/[A-Za-z0-9_./-]+\.json' "$SPEC" | sort -u || true)"
37
+ if [ -n "$FIXREFS" ]; then
38
+ while IFS= read -r fx; do
39
+ [ -n "$fx" ] || continue
40
+ if [ ! -f "$fx" ]; then
41
+ echo "spec-conformance: FAIL — referenced fixture missing: $fx" >&2
42
+ FAIL=1
43
+ elif grep -nE '\bTBD\b' "$fx" >/dev/null 2>&1; then
44
+ echo "spec-conformance: FAIL — 'TBD' marker in fixture: $fx" >&2
45
+ FAIL=1
46
+ fi
47
+ done <<EOF
48
+ $FIXREFS
49
+ EOF
50
+ fi
51
+
52
+ # ---- 2. every case-id cited by a boundary test ----
53
+ # case-ids = the FIRST table-column cell of each row, matching PH<n>-... exactly.
54
+ # (Parse the column, not a free grep — else `fixtures/PH1-ORDER-01-input.json`
55
+ # would yield a bogus `PH1-ORDER-01-input` case-id.)
56
+ CASE_IDS="$(grep -E '^\|' "$SPEC" \
57
+ | awk -F'|' '{ id=$2; gsub(/^[ \t]+|[ \t]+$/,"",id); print id }' \
58
+ | grep -E "^PH${N}-[A-Za-z0-9_-]+$" | sort -u || true)"
59
+ if [ -z "$CASE_IDS" ]; then
60
+ echo "spec-conformance: WARN — no PH${N}-* case-ids found in $SPEC (empty spec?)." >&2
61
+ fi
62
+
63
+ SEARCH_DIRS=""
64
+ [ -d tests ] && SEARCH_DIRS="$SEARCH_DIRS tests"
65
+ [ -d e2e ] && SEARCH_DIRS="$SEARCH_DIRS e2e"
66
+
67
+ if [ -n "$CASE_IDS" ]; then
68
+ if [ -z "$SEARCH_DIRS" ]; then
69
+ echo "spec-conformance: FAIL — case-ids exist but no tests/ or e2e/ dir to cite them." >&2
70
+ FAIL=1
71
+ else
72
+ while IFS= read -r cid; do
73
+ [ -n "$cid" ] || continue
74
+ # cite convention: `spec: <case-id>` with a non-id char (or EOL) after, so
75
+ # PH2-ORDER-01 does not match PH2-ORDER-011.
76
+ if grep -rEq -- "spec:[[:space:]]*${cid}([^0-9A-Za-z_-]|$)" $SEARCH_DIRS 2>/dev/null; then
77
+ :
78
+ else
79
+ echo "spec-conformance: FAIL — case ${cid} has no boundary test citing it (# spec: ${cid})." >&2
80
+ FAIL=1
81
+ fi
82
+ done <<EOF
83
+ $CASE_IDS
84
+ EOF
85
+ fi
86
+ fi
87
+
88
+ if [ "$FAIL" -ne 0 ]; then
89
+ echo "spec-conformance: BLOCKED — fix the above before GREEN." >&2
90
+ exit 1
91
+ fi
92
+ echo "spec-conformance: OK — phase ${N} spec fully filled; every case-id cited by a boundary test."
93
+ exit 0
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env bash
2
+ # spec-staleness.sh — guard the per-phase spec header against upstream drift (v2.2).
3
+ #
4
+ # The behavioral contract docs/spec-phase-<n>.md carries a GENERATED header that
5
+ # transcludes the phase's PLAN.md `- acceptance:` line + the in-scope DATAFLOW.md
6
+ # transition rows, plus an embedded hash. If PLAN.md / DATAFLOW.md change after the
7
+ # skeleton was stamped (e.g. via /replan), the stored hash no longer matches the
8
+ # upstream → the spec must be regenerated/re-filled before SPEC REVIEW. (§5.55, rule 17)
9
+ #
10
+ # Hashing lives ONLY here (no duplication in agent prompts):
11
+ # bash scripts/spec-staleness.sh stamp <n> # compute + embed the hash (at /overview, on regenerate)
12
+ # bash scripts/spec-staleness.sh check <n> # default: recompute + compare; nonzero on drift
13
+ # bash scripts/spec-staleness.sh <n> # alias for: check <n>
14
+ #
15
+ # set -euo pipefail; nonzero = loud fail; `stamp` is the only side effect.
16
+ set -euo pipefail
17
+ cd "${CLAUDE_PROJECT_DIR:-.}"
18
+
19
+ # ---- args ----
20
+ SUB="check"
21
+ case "${1:-}" in
22
+ stamp) SUB="stamp"; shift;;
23
+ check) SUB="check"; shift;;
24
+ esac
25
+ N="${1:-}"
26
+ [ -n "$N" ] || { echo "spec-staleness: usage: spec-staleness.sh [stamp|check] <phase-n>" >&2; exit 2; }
27
+
28
+ SPEC="docs/spec-phase-${N}.md"
29
+ PLAN="docs/PLAN.md"
30
+ DATAFLOW="docs/DATAFLOW.md"
31
+ [ -f "$SPEC" ] || { echo "spec-staleness: $SPEC not found." >&2; exit 2; }
32
+ [ -f "$PLAN" ] || { echo "spec-staleness: $PLAN not found." >&2; exit 2; }
33
+
34
+ # ---- sha256 of stdin (portable: sha256sum -> shasum -> node) ----
35
+ sha_stdin() {
36
+ if command -v sha256sum >/dev/null 2>&1; then sha256sum | cut -d' ' -f1
37
+ elif command -v shasum >/dev/null 2>&1; then shasum -a 256 | cut -d' ' -f1
38
+ else node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>console.log(require('crypto').createHash('sha256').update(d).digest('hex')))"
39
+ fi
40
+ }
41
+
42
+ # normalize: NFC-ish arrow unify (→ -> ->), collapse spaces, trim. Content-sensitive,
43
+ # whitespace-insensitive — so cosmetic reflow does not trip the guard, content does.
44
+ norm() { sed -e 's/→/->/g' -e 's/[[:space:]]\{1,\}/ /g' -e 's/^ //' -e 's/ $//'; }
45
+
46
+ # ---- extract this phase's block from PLAN.md (## Phase <n>: ... up to next ## Phase) ----
47
+ phase_block() {
48
+ awk -v n="$N" '
49
+ $0 ~ ("^## Phase " n ":") {inblk=1; print; next}
50
+ inblk && /^## Phase / {inblk=0}
51
+ inblk {print}
52
+ ' "$PLAN"
53
+ }
54
+
55
+ BLOCK="$(phase_block || true)"
56
+ [ -n "$BLOCK" ] || { echo "spec-staleness: no '## Phase ${N}:' block in $PLAN." >&2; exit 2; }
57
+
58
+ # acceptance line(s) + dataflow line, normalized
59
+ ACC_LINE="$(printf '%s\n' "$BLOCK" | grep -E '^- acceptance:' | norm || true)"
60
+ DF_LINE="$(printf '%s\n' "$BLOCK" | grep -E '^- dataflow:' | norm || true)"
61
+
62
+ # in-scope transition tokens (from->to) named on the dataflow line
63
+ TOKENS="$(printf '%s\n' "$DF_LINE" | norm | grep -oE '[A-Za-z0-9_]+->[A-Za-z0-9_]+' | sort -u || true)"
64
+
65
+ # matching DATAFLOW.md data-rows (table rows naming an in-scope transition)
66
+ DF_ROWS=""
67
+ if [ -n "$TOKENS" ] && [ -f "$DATAFLOW" ]; then
68
+ while IFS= read -r tok; do
69
+ [ -n "$tok" ] || continue
70
+ rows="$(grep -E '^\|' "$DATAFLOW" | norm | grep -F "$tok" || true)"
71
+ DF_ROWS="${DF_ROWS}${rows}
72
+ "
73
+ done <<EOF
74
+ $TOKENS
75
+ EOF
76
+ fi
77
+ DF_ROWS="$(printf '%s\n' "$DF_ROWS" | grep -v '^$' | sort -u || true)"
78
+
79
+ # ---- the upstream signature + its hash ----
80
+ SIG="$(printf 'ACC:%s\nDF:%s\nROWS:\n%s\n' "$ACC_LINE" "$DF_LINE" "$DF_ROWS")"
81
+ WANT="$(printf '%s' "$SIG" | sha_stdin)"
82
+
83
+ HASH_RE='^<!-- spec-hash: .* -->$'
84
+ HAVE="$(grep -E "$HASH_RE" "$SPEC" | head -1 | sed -E 's/^<!-- spec-hash: (.*) -->$/\1/' || true)"
85
+
86
+ if [ "$SUB" = "stamp" ]; then
87
+ if grep -qE "$HASH_RE" "$SPEC"; then
88
+ tmp="$(mktemp)"
89
+ awk -v h="$WANT" '
90
+ /^<!-- spec-hash: .* -->$/ && !done { print "<!-- spec-hash: " h " -->"; done=1; next }
91
+ { print }
92
+ ' "$SPEC" > "$tmp" && mv "$tmp" "$SPEC"
93
+ else
94
+ echo "spec-staleness: $SPEC has no '<!-- spec-hash: ... -->' header line to stamp." >&2
95
+ exit 2
96
+ fi
97
+ echo "spec-staleness: stamped phase ${N} -> ${WANT}"
98
+ exit 0
99
+ fi
100
+
101
+ # ---- check ----
102
+ if [ -z "$HAVE" ] || [ "$HAVE" = "PENDING" ]; then
103
+ echo "spec-staleness: FAIL — phase ${N} spec hash is unstamped (${HAVE:-missing})." >&2
104
+ echo " Run: bash scripts/spec-staleness.sh stamp ${N}" >&2
105
+ exit 1
106
+ fi
107
+ if [ "$HAVE" != "$WANT" ]; then
108
+ echo "spec-staleness: FAIL — phase ${N} spec is STALE." >&2
109
+ echo " Upstream PLAN.md acceptance / DATAFLOW.md rows changed since the skeleton was stamped." >&2
110
+ echo " stored=${HAVE} current=${WANT}" >&2
111
+ echo " Regenerate the skeleton header from current PLAN/DATAFLOW, re-stamp, and re-fill." >&2
112
+ exit 1
113
+ fi
114
+ echo "spec-staleness: OK — phase ${N} spec header matches upstream (${WANT})."
115
+ exit 0
package/template/setup.sh CHANGED
@@ -12,6 +12,8 @@
12
12
  # hybrid -> merged idempotently, never replaced
13
13
  #
14
14
  # Flags: --src DIR --kb-path P --kb-remote URL --no-kb --force --dry-run
15
+ # --tools LIST (comma/space list from: claude cursor; default both)
16
+ # --add-tool T (additive: install T's files into an existing project)
15
17
  set -euo pipefail
16
18
 
17
19
  SRC="."
@@ -20,6 +22,9 @@ KB_REMOTE="${ANPUNKIT_KB_REMOTE:-}"
20
22
  NO_KB=0
21
23
  FORCE=0
22
24
  DRY=0
25
+ TOOLS_FLAG=""
26
+ ADD_TOOL=""
27
+ AVAILABLE_TOOLS="claude cursor" # tools with real adapters; extend here
23
28
  while [ $# -gt 0 ]; do
24
29
  case "$1" in
25
30
  --src) SRC="$2"; shift 2;;
@@ -28,6 +33,8 @@ while [ $# -gt 0 ]; do
28
33
  --no-kb) NO_KB=1; shift;;
29
34
  --force) FORCE=1; shift;;
30
35
  --dry-run) DRY=1; shift;;
36
+ --tools) TOOLS_FLAG="$2"; shift 2;;
37
+ --add-tool) ADD_TOOL="$2"; shift 2;;
31
38
  *) echo "unknown flag: $1" >&2; exit 2;;
32
39
  esac
33
40
  done
@@ -63,6 +70,36 @@ fi
63
70
  say "mode: $MODE (installed: ${OLD_VER:-none} -> new: $NEW_VER)"
64
71
  [ "$MODE" = "newer-installed" ] && say " ! installed version is newer than this package — proceeding as repair, review carefully."
65
72
 
73
+ # ---- tool selection (v2.1): install only the selected tools' adapters/hooks/files
74
+ TOOLS_FILE="$DEST/.claude/anpunkit-tools.json"
75
+ norm_tools() { printf '%s' "$1" | tr ',' ' ' | tr -s ' ' | sed 's/^ //; s/ $//'; }
76
+ read_recorded_tools() {
77
+ [ -f "$TOOLS_FILE" ] || { printf ''; return; }
78
+ node -e "try{console.log((JSON.parse(require('fs').readFileSync(process.argv[1],'utf8')).tools||[]).join(' '))}catch(e){console.log('')}" "$TOOLS_FILE" 2>/dev/null || printf ''
79
+ }
80
+ valid_tool() { case " $AVAILABLE_TOOLS " in *" $1 "*) return 0;; *) return 1;; esac; }
81
+
82
+ RECORDED="$(read_recorded_tools)"
83
+ if [ -n "$TOOLS_FLAG" ]; then
84
+ TOOLS="$(norm_tools "$TOOLS_FLAG")"
85
+ elif [ -n "$RECORDED" ]; then
86
+ TOOLS="$RECORDED" # re-run: preserve prior selection
87
+ elif [ "$MODE" = "fresh" ] && [ -t 0 ]; then
88
+ printf " Select tools to install (space-separated from: %s) [claude cursor]: " "$AVAILABLE_TOOLS"
89
+ read -r _sel || true
90
+ TOOLS="$(norm_tools "${_sel:-claude cursor}")"
91
+ else
92
+ TOOLS="claude cursor" # non-interactive default = both (no regression)
93
+ fi
94
+ # additive: --add-tool unions into the selection (and is recorded)
95
+ [ -n "$ADD_TOOL" ] && TOOLS="$(norm_tools "$TOOLS $(norm_tools "$ADD_TOOL")" | tr ' ' '\n' | sort -u | tr '\n' ' ')"
96
+ TOOLS="$(norm_tools "$TOOLS")"
97
+ # validate
98
+ for t in $TOOLS; do valid_tool "$t" || { echo "FATAL: unknown tool '$t' (available: $AVAILABLE_TOOLS)"; exit 2; }; done
99
+ [ -z "$TOOLS" ] && { echo "FATAL: no tools selected"; exit 2; }
100
+ tool_selected() { case " $TOOLS " in *" $1 "*) return 0;; *) return 1;; esac; }
101
+ say "tools: $TOOLS$([ -n "$ADD_TOOL" ] && echo ' (+'"$ADD_TOOL"' added)')"
102
+
66
103
  BACKUP="$DEST/.anpunkit-backup-$(date +%Y%m%d-%H%M%S)"
67
104
  backup_one() {
68
105
  [ "$MODE" = "fresh" ] && return 0
@@ -77,6 +114,11 @@ backup_one() {
77
114
  install_kit_file() {
78
115
  local rel="$1" s="$SRC/$1" d="$DEST/$1"
79
116
  [ -f "$s" ] || return 0
117
+ # v2.1 tool gating: cursor-only files skipped unless cursor selected.
118
+ # (.claude/* is shared infra — Cursor reads .claude/agents + .claude/hooks too.)
119
+ case "$rel" in
120
+ .cursor/*) tool_selected cursor || { act "skip $rel (cursor not selected)"; return 0; };;
121
+ esac
80
122
  if [ ! -f "$d" ]; then
81
123
  act "write $rel"; [ "$DRY" = 1 ] && return 0
82
124
  mkdir -p "$(dirname "$d")"; cp -p "$s" "$d"; return 0
@@ -114,61 +156,76 @@ else
114
156
  fi
115
157
 
116
158
  # ---- generate command adapters from commands.src/ (manifest-owned output)
117
- say ""; say "[2/6] generate command adapters:"
159
+ say ""; say "[2/6] generate command adapters (tools: $TOOLS):"
118
160
  gen_adapters() {
119
- mkdir -p "$DEST/.claude/commands" "$DEST/.cursor/commands" "$DEST/.cursor/rules"
161
+ tool_selected claude && mkdir -p "$DEST/.claude/commands"
162
+ tool_selected cursor && mkdir -p "$DEST/.cursor/commands" "$DEST/.cursor/rules"
120
163
  for f in "$DEST"/commands.src/*.md; do
121
164
  [ -f "$f" ] || continue
122
165
  n=$(basename "$f")
123
- act "claude .claude/commands/$n"
124
- act "cursor .cursor/commands/$n"
125
- [ "$DRY" = 1 ] && continue
126
- cp -p "$f" "$DEST/.claude/commands/$n"
127
- awk 'BEGIN{fm=0} NR==1 && $0=="---"{fm=1; next} fm==1 && $0=="---"{fm=0; next} fm==0{print}' "$f" \
128
- | sed '/^$/{1d}' > "$DEST/.cursor/commands/$n"
166
+ if tool_selected claude; then
167
+ act "claude .claude/commands/$n"
168
+ [ "$DRY" = 1 ] || cp -p "$f" "$DEST/.claude/commands/$n"
169
+ fi
170
+ if tool_selected cursor; then
171
+ act "cursor .cursor/commands/$n"
172
+ [ "$DRY" = 1 ] || awk 'BEGIN{fm=0} NR==1 && $0=="---"{fm=1; next} fm==1 && $0=="---"{fm=0; next} fm==0{print}' "$f" \
173
+ | sed '/^$/{1d}' > "$DEST/.cursor/commands/$n"
174
+ fi
129
175
  done
130
176
  }
131
177
  gen_adapters
132
178
 
133
- # ---- hybrid JSON merge: ensure the 3 hook entries, never drop user keys
134
- say ""; say "[3/6] wire hooks (idempotent merge):"
179
+ # ---- hybrid JSON merge: ensure the hook entries for selected tools, never drop user keys
180
+ say ""; say "[3/6] wire hooks (idempotent merge, tools: $TOOLS):"
135
181
  merge_settings() {
136
- [ "$DRY" = 1 ] && { act "merge .claude/settings.json + .cursor/hooks.json"; return 0; }
137
- backup_one "$DEST/.claude/settings.json"; backup_one "$DEST/.cursor/hooks.json"
182
+ [ "$DRY" = 1 ] && { act "merge $(tool_selected claude && echo -n '.claude/settings.json ')$(tool_selected cursor && echo -n '.cursor/hooks.json')"; return 0; }
183
+ tool_selected claude && backup_one "$DEST/.claude/settings.json"
184
+ tool_selected cursor && backup_one "$DEST/.cursor/hooks.json"
185
+ CLAUDE_SEL=$(tool_selected claude && echo 1 || echo 0) \
186
+ CURSOR_SEL=$(tool_selected cursor && echo 1 || echo 0) \
138
187
  node - "$DEST" <<'NODE'
139
188
  const fs=require('fs'),path=require('path');
140
189
  const dest=process.argv[2];
190
+ const claudeSel=process.env.CLAUDE_SEL==='1', cursorSel=process.env.CURSOR_SEL==='1';
141
191
  const load=(p,d)=>{try{return JSON.parse(fs.readFileSync(p,'utf8'))}catch(e){return d}};
142
192
  const save=(p,o)=>{fs.mkdirSync(path.dirname(p),{recursive:true});fs.writeFileSync(p,JSON.stringify(o,null,2)+"\n")};
193
+ const out=[];
143
194
  // Claude settings.json
144
- const sp=path.join(dest,'.claude/settings.json');
145
- const s=load(sp,{});
146
- if(!s.$schema)s.$schema='https://json.schemastore.org/claude-code-settings.json';
147
- s.hooks=s.hooks||{};
148
- const ensure=(event,matcher,cmd)=>{
149
- const arr=s.hooks[event]=s.hooks[event]||[];
150
- for(const blk of arr)for(const h of (blk.hooks||[]))if(h.command===cmd)return;
151
- const blk={hooks:[{type:'command',command:cmd}]};
152
- if(matcher)blk.matcher=matcher;
153
- arr.push(blk);
154
- };
155
- ensure('SessionStart','startup|clear|compact','bash .claude/hooks/session-start.sh');
156
- ensure('PreCompact','auto|manual','bash .claude/hooks/pre-compact.sh');
157
- ensure('SubagentStop','','bash .claude/hooks/subagent-stop.sh');
158
- save(sp,s);
195
+ if(claudeSel){
196
+ const sp=path.join(dest,'.claude/settings.json');
197
+ const s=load(sp,{});
198
+ if(!s.$schema)s.$schema='https://json.schemastore.org/claude-code-settings.json';
199
+ s.hooks=s.hooks||{};
200
+ const ensure=(event,matcher,cmd)=>{
201
+ const arr=s.hooks[event]=s.hooks[event]||[];
202
+ for(const blk of arr)for(const h of (blk.hooks||[]))if(h.command===cmd)return;
203
+ const blk={hooks:[{type:'command',command:cmd}]};
204
+ if(matcher)blk.matcher=matcher;
205
+ arr.push(blk);
206
+ };
207
+ ensure('SessionStart','startup|clear|compact','bash .claude/hooks/session-start.sh');
208
+ ensure('PreCompact','auto|manual','bash .claude/hooks/pre-compact.sh');
209
+ ensure('SubagentStop','','bash .claude/hooks/subagent-stop.sh');
210
+ save(sp,s);
211
+ out.push('.claude/settings.json');
212
+ }
159
213
  // Cursor hooks.json
160
- const cp=path.join(dest,'.cursor/hooks.json');
161
- const c=load(cp,{version:1,hooks:{}});
162
- c.hooks=c.hooks||{};
163
- const cursorEnsure=(event,cmd)=>{
164
- const arr=c.hooks[event]=c.hooks[event]||[];
165
- if(!arr.some(h=>h.command===cmd))arr.push({command:cmd});
166
- };
167
- cursorEnsure('sessionStart','bash .claude/hooks/cursor-session-start.sh');
168
- cursorEnsure('preCompact','bash .claude/hooks/pre-compact.sh');
169
- cursorEnsure('subagentStop','bash .claude/hooks/subagent-stop.sh');
170
- save(cp,c);
171
- console.log(' merged .claude/settings.json + .cursor/hooks.json');
214
+ if(cursorSel){
215
+ const cp=path.join(dest,'.cursor/hooks.json');
216
+ const c=load(cp,{version:1,hooks:{}});
217
+ c.hooks=c.hooks||{};
218
+ const cursorEnsure=(event,cmd)=>{
219
+ const arr=c.hooks[event]=c.hooks[event]||[];
220
+ if(!arr.some(h=>h.command===cmd))arr.push({command:cmd});
221
+ };
222
+ cursorEnsure('sessionStart','bash .claude/hooks/cursor-session-start.sh');
223
+ cursorEnsure('preCompact','bash .claude/hooks/pre-compact.sh');
224
+ cursorEnsure('subagentStop','bash .claude/hooks/subagent-stop.sh');
225
+ save(cp,c);
226
+ out.push('.cursor/hooks.json');
227
+ }
228
+ console.log(' merged '+(out.join(' + ')||'(nothing — no tool selected hooks)'));
172
229
  NODE
173
230
  }
174
231
  merge_settings
@@ -180,13 +237,17 @@ say ""; say "[4/6] AGENTS.md / CLAUDE.md verify:"
180
237
  verify_import() {
181
238
  local CL="$DEST/CLAUDE.md" AG="$DEST/AGENTS.md"
182
239
  [ -f "$AG" ] || { echo "FATAL VERIFY: AGENTS.md not on disk"; exit 1; }
183
- # bare top-level @AGENTS.md import (not indented, not fenced)
184
- if ! grep -qE '^@AGENTS\.md[[:space:]]*$' "$CL"; then
185
- echo "FATAL VERIFY: CLAUDE.md is missing a bare top-level '@AGENTS.md' import line"; exit 1; fi
186
- # sentinel present in AGENTS.md
240
+ # sentinel present in AGENTS.md (shared both tools read AGENTS.md)
187
241
  if ! grep -q 'ANPUNKIT-AGENTS-SENTINEL' "$AG"; then
188
242
  echo "FATAL VERIFY: AGENTS.md sentinel missing (file clobbered or empty?)"; exit 1; fi
189
- say " VERIFY ok: bare @AGENTS.md import + AGENTS.md on disk + sentinel present."
243
+ # CLAUDE.md bare @AGENTS.md import is Claude-specific only verify if claude selected
244
+ if tool_selected claude; then
245
+ if ! grep -qE '^@AGENTS\.md[[:space:]]*$' "$CL"; then
246
+ echo "FATAL VERIFY: CLAUDE.md is missing a bare top-level '@AGENTS.md' import line"; exit 1; fi
247
+ say " VERIFY ok: bare @AGENTS.md import + AGENTS.md on disk + sentinel present."
248
+ else
249
+ say " VERIFY ok: AGENTS.md on disk + sentinel present (claude not selected — CLAUDE.md import not required)."
250
+ fi
190
251
  }
191
252
  verify_import
192
253
 
@@ -194,7 +255,7 @@ verify_import
194
255
  say ""; say "[5/6] .gitignore patch:"
195
256
  patch_gitignore() {
196
257
  local gi="$DEST/.gitignore"
197
- local -a needles=("docs/.snapshots/" "docs/.kb-snapshot.md" ".anpunkit-backup-*/" "*.anpunkit-new")
258
+ local -a needles=("docs/.snapshots/" "docs/.kb-snapshot.md" "docs/evidence/" ".anpunkit-backup-*/" "*.anpunkit-new")
198
259
  [ "$DRY" = 1 ] && { act "patch .gitignore (${#needles[@]} entries)"; return 0; }
199
260
  touch "$gi"
200
261
  for n in "${needles[@]}"; do grep -qxF "$n" "$gi" || printf '%s\n' "$n" >> "$gi"; done
@@ -244,12 +305,14 @@ configure_kb() {
244
305
 
245
306
  configure_kb
246
307
 
247
- # ---- finalize: stamp the installed manifest (copy the new one)
308
+ # ---- finalize: stamp the installed manifest (copy the new one) + record tools
248
309
  if [ "$DRY" != 1 ]; then
249
310
  cp -p "$MAN_SRC" "$MAN_DEST" 2>/dev/null || true
311
+ mkdir -p "$DEST/.claude"
312
+ node -e "require('fs').writeFileSync(process.argv[1],JSON.stringify({tools:process.argv[2].split(' ').filter(Boolean)},null,2)+'\n')" "$TOOLS_FILE" "$TOOLS" 2>/dev/null || true
250
313
  [ -d "$BACKUP" ] && say "" && say "backup written: ${BACKUP#$DEST/}"
251
314
  fi
252
315
 
253
- say ""; say "anpunkit setup complete ($MODE). Open Claude Code or Cursor — the SessionStart/sessionStart hook fires automatically."
316
+ say ""; say "anpunkit setup complete ($MODE, tools: $TOOLS). Open Claude Code or Cursor — the SessionStart/sessionStart hook fires automatically."
254
317
  if [ "$DRY" = 1 ]; then say "(dry-run: no files were written.)"; fi
255
318
  exit 0
@@ -0,0 +1,162 @@
1
+ """spec-assert.py — kit-versioned matcher-aware deep-equality comparator (v2.2).
2
+
3
+ The generated `data` boundary harness (test-author) asserts an actual response
4
+ against `fixtures/<case-id>-expected.json` via `spec_assert(actual, expected)`.
5
+ Deep equality, EXCEPT that volatile fields in the expected fixture carry a matcher
6
+ token instead of a literal (see §5.49). This is the ONLY comparator — no per-project
7
+ assertion logic. One file per supported test language; this is the Python one.
8
+
9
+ Matcher tokens (string values in the expected fixture):
10
+
11
+ "<UUID>" any UUID v4 string
12
+ "<ISO8601>" any ISO 8601 datetime string
13
+ "<ANY_STRING>" any non-null string
14
+ "<ANY_NUMBER>" any finite number (not bool)
15
+ "<UNORDERED>" any array (presence + shape only)
16
+ "<MATCHES:regex>" any string matching the pattern (re.search)
17
+
18
+ Order-insensitive array WITH item checking: wrap the expected array as
19
+ {"<UNORDERED>": [item, item, ...]}
20
+ and the actual must be a list that is an order-insensitive deep-equal multiset of
21
+ those items.
22
+
23
+ A matcher token asserts PRESENCE + SHAPE — never "ignore this field". A missing
24
+ volatile field still fails (strict key sets). Extra actual keys also fail.
25
+
26
+ Usage (pytest or any runner):
27
+
28
+ from importlib import import_module
29
+ spec_assert = import_module("tests.helpers.spec-assert").spec_assert # if importable
30
+ # or load directly:
31
+ # import importlib.util, pathlib
32
+ # spec = importlib.util.spec_from_file_location("spec_assert", ".../spec-assert.py")
33
+ ...
34
+ spec_assert(actual, expected) # raises AssertionError with a JSON path on mismatch
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import json
40
+ import re
41
+ from numbers import Number
42
+ from pathlib import Path
43
+ from typing import Any
44
+
45
+ _UUID4 = re.compile(
46
+ r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"
47
+ )
48
+ _ISO8601 = re.compile(
49
+ r"^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:?\d{2})?$"
50
+ )
51
+ _MATCHES_PREFIX = "<MATCHES:"
52
+
53
+
54
+ class SpecMismatch(AssertionError):
55
+ """Raised when actual does not satisfy the expected fixture (with a JSON path)."""
56
+
57
+
58
+ def load_fixture(path: str | Path) -> Any:
59
+ """Convenience loader for fixtures/<case-id>-*.json."""
60
+ return json.loads(Path(path).read_text(encoding="utf-8"))
61
+
62
+
63
+ def _is_real_number(v: Any) -> bool:
64
+ # bool is a subclass of int in Python — exclude it explicitly.
65
+ return isinstance(v, Number) and not isinstance(v, bool)
66
+
67
+
68
+ def _fail(path: str, msg: str) -> None:
69
+ raise SpecMismatch(f"spec-assert mismatch at {path}: {msg}")
70
+
71
+
72
+ def _match_token(actual: Any, token: str, path: str) -> bool:
73
+ """Return True if `token` is a known matcher and `actual` satisfies it.
74
+ Raises on a known token that is NOT satisfied. Returns False if not a token."""
75
+ if token == "<UUID>":
76
+ if not (isinstance(actual, str) and _UUID4.match(actual)):
77
+ _fail(path, f"expected a UUID v4 string, got {actual!r}")
78
+ return True
79
+ if token == "<ISO8601>":
80
+ if not (isinstance(actual, str) and _ISO8601.match(actual)):
81
+ _fail(path, f"expected an ISO 8601 datetime string, got {actual!r}")
82
+ return True
83
+ if token == "<ANY_STRING>":
84
+ if not isinstance(actual, str):
85
+ _fail(path, f"expected any string, got {type(actual).__name__}")
86
+ return True
87
+ if token == "<ANY_NUMBER>":
88
+ if not _is_real_number(actual):
89
+ _fail(path, f"expected any finite number, got {actual!r}")
90
+ return True
91
+ if token == "<UNORDERED>":
92
+ if not isinstance(actual, list):
93
+ _fail(path, f"expected any array, got {type(actual).__name__}")
94
+ return True
95
+ if token.startswith(_MATCHES_PREFIX) and token.endswith(">"):
96
+ pattern = token[len(_MATCHES_PREFIX):-1]
97
+ if not (isinstance(actual, str) and re.search(pattern, actual)):
98
+ _fail(path, f"expected a string matching /{pattern}/, got {actual!r}")
99
+ return True
100
+ return False
101
+
102
+
103
+ def _unordered_equal(actual: list, items: list, path: str) -> None:
104
+ if not isinstance(actual, list):
105
+ _fail(path, f"expected an array (unordered), got {type(actual).__name__}")
106
+ if len(actual) != len(items):
107
+ _fail(path, f"array length {len(actual)} != expected {len(items)} (unordered)")
108
+ remaining = list(actual)
109
+ for i, exp in enumerate(items):
110
+ found = -1
111
+ for j, act in enumerate(remaining):
112
+ try:
113
+ spec_assert(act, exp, f"{path}[unordered:{i}]")
114
+ found = j
115
+ break
116
+ except SpecMismatch:
117
+ continue
118
+ if found < 0:
119
+ _fail(path, f"no actual element matches expected item #{i}: {exp!r}")
120
+ remaining.pop(found)
121
+
122
+
123
+ def spec_assert(actual: Any, expected: Any, path: str = "$") -> None:
124
+ """Assert `actual` deep-equals `expected`, honoring matcher tokens. Raises
125
+ SpecMismatch (an AssertionError) with a JSON path on the first divergence."""
126
+ # scalar matcher token
127
+ if isinstance(expected, str) and _match_token(actual, expected, path):
128
+ return
129
+
130
+ # {"<UNORDERED>": [...]} wrapper → order-insensitive item compare
131
+ if isinstance(expected, dict) and list(expected.keys()) == ["<UNORDERED>"]:
132
+ _unordered_equal(actual, expected["<UNORDERED>"], path)
133
+ return
134
+
135
+ if isinstance(expected, dict):
136
+ if not isinstance(actual, dict):
137
+ _fail(path, f"expected object, got {type(actual).__name__}")
138
+ exp_keys, act_keys = set(expected), set(actual)
139
+ if exp_keys != act_keys:
140
+ missing = exp_keys - act_keys
141
+ extra = act_keys - exp_keys
142
+ _fail(path, f"key mismatch (missing={sorted(missing)}, extra={sorted(extra)})")
143
+ for k in expected:
144
+ spec_assert(actual[k], expected[k], f"{path}.{k}")
145
+ return
146
+
147
+ if isinstance(expected, list):
148
+ if not isinstance(actual, list):
149
+ _fail(path, f"expected array, got {type(actual).__name__}")
150
+ if len(actual) != len(expected):
151
+ _fail(path, f"array length {len(actual)} != expected {len(expected)}")
152
+ for i, (a, e) in enumerate(zip(actual, expected)):
153
+ spec_assert(a, e, f"{path}[{i}]")
154
+ return
155
+
156
+ # scalar literal — strict equality (bool/int kept distinct)
157
+ if type(actual) is not type(expected) and not (
158
+ _is_real_number(actual) and _is_real_number(expected)
159
+ ):
160
+ _fail(path, f"type {type(actual).__name__} != expected {type(expected).__name__}")
161
+ if actual != expected:
162
+ _fail(path, f"{actual!r} != expected {expected!r}")