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.
- package/bin/cli.js +3 -2
- package/package.json +1 -1
- package/template/.claude/agents/e2e-runner.md +44 -13
- package/template/.claude/agents/implementer.md +13 -7
- package/template/.claude/agents/infra-provisioner.md +33 -2
- package/template/.claude/agents/planner.md +79 -6
- package/template/.claude/agents/researcher.md +19 -8
- package/template/.claude/agents/spec-author.md +118 -0
- package/template/.claude/agents/test-author.md +76 -43
- package/template/.claude/anpunkit-manifest.json +47 -27
- package/template/.claude/commands/infra.md +28 -3
- package/template/.claude/commands/overview.md +84 -24
- package/template/.claude/commands/phase.md +139 -40
- package/template/.claude/commands/replan.md +23 -9
- package/template/.cursor/commands/infra.md +26 -1
- package/template/.cursor/commands/overview.md +83 -23
- package/template/.cursor/commands/phase.md +138 -39
- package/template/.cursor/commands/replan.md +22 -8
- package/template/.gitattributes +1 -0
- package/template/AGENTS.md +215 -36
- package/template/CLAUDE.md +1 -0
- package/template/README.md +59 -23
- package/template/anpunkit.png +0 -0
- package/template/commands.src/infra.md +28 -3
- package/template/commands.src/overview.md +84 -24
- package/template/commands.src/phase.md +139 -40
- package/template/commands.src/replan.md +23 -9
- package/template/docs/DESIGN_LOG.md +192 -1
- package/template/index.html +339 -0
- package/template/scripts/spec-conformance.sh +93 -0
- package/template/scripts/spec-staleness.sh +115 -0
- package/template/setup.sh +110 -47
- package/template/tests/helpers/spec-assert.py +162 -0
- 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"
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
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
|
|
137
|
-
|
|
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
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
s
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
ensure('
|
|
157
|
-
ensure('
|
|
158
|
-
|
|
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
|
-
|
|
161
|
-
const
|
|
162
|
-
c
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
cursorEnsure('
|
|
169
|
-
cursorEnsure('
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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}")
|