create-anpunkit 2.1.0 → 2.3.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 +2 -2
- package/package.json +2 -3
- package/template/.claude/agents/debugger.md +1 -1
- package/template/.claude/agents/e2e-runner.md +56 -29
- package/template/.claude/agents/implementer.md +14 -8
- package/template/.claude/agents/infra-provisioner.md +53 -59
- package/template/.claude/agents/planner.md +99 -30
- package/template/.claude/agents/researcher.md +16 -1
- package/template/.claude/agents/spec-author.md +126 -0
- package/template/.claude/agents/synthesizer.md +1 -1
- package/template/.claude/agents/test-author.md +81 -63
- package/template/.claude/anpunkit-manifest.json +66 -107
- package/template/.claude/commands/infra.md +7 -3
- package/template/.claude/commands/log-decision.md +1 -1
- package/template/.claude/commands/log-issue.md +1 -1
- package/template/.claude/commands/overview.md +55 -13
- package/template/.claude/commands/phase.md +153 -81
- package/template/.claude/commands/quick.md +1 -1
- package/template/.claude/commands/replan.md +1 -1
- package/template/.claude/commands/store-wisdom.md +1 -1
- package/template/.claude/commands/synthesize.md +1 -1
- package/template/.claude/commands/unstuck.md +1 -1
- package/template/.claude/hooks/session-start.sh +5 -11
- package/template/.claude/ref/compression.md +56 -0
- package/template/.claude/skills/karpathy-guidelines/SKILL.md +1 -1
- package/template/.gitattributes +1 -0
- package/template/AGENTS.md +264 -130
- package/template/CLAUDE.md +5 -9
- package/template/README.md +116 -136
- package/template/commands.src/infra.md +7 -3
- package/template/commands.src/log-decision.md +1 -1
- package/template/commands.src/log-issue.md +1 -1
- package/template/commands.src/overview.md +55 -13
- package/template/commands.src/phase.md +153 -81
- package/template/commands.src/quick.md +1 -1
- package/template/commands.src/replan.md +1 -1
- package/template/commands.src/store-wisdom.md +1 -1
- package/template/commands.src/synthesize.md +1 -1
- package/template/commands.src/unstuck.md +1 -1
- package/template/docs/DESIGN_LOG.md +414 -9
- package/template/knowledge/azure.md +161 -0
- package/template/knowledge/webapp.md +265 -0
- package/template/scripts/spec-conformance.sh +93 -0
- package/template/scripts/spec-staleness.sh +115 -0
- package/template/setup.sh +30 -111
- package/template/tests/helpers/spec-assert.py +162 -0
- package/template/tests/helpers/spec-assert.ts +158 -0
- package/template/.claude/hooks/cursor-session-start.sh +0 -17
- package/template/.claude/skills/caveman/SKILL.md +0 -39
- package/template/.cursor/commands/infra.md +0 -82
- package/template/.cursor/commands/log-decision.md +0 -29
- package/template/.cursor/commands/log-issue.md +0 -23
- package/template/.cursor/commands/overview.md +0 -145
- package/template/.cursor/commands/phase.md +0 -249
- package/template/.cursor/commands/quick.md +0 -25
- package/template/.cursor/commands/replan.md +0 -73
- package/template/.cursor/commands/store-wisdom.md +0 -191
- package/template/.cursor/commands/synthesize.md +0 -22
- package/template/.cursor/commands/unstuck.md +0 -36
- package/template/.cursor/hooks.json +0 -14
- package/template/.cursor/rules/anpunkit.md +0 -11
- package/template/anpunkit.png +0 -0
- package/template/docker-compose.test.yml +0 -34
- package/template/e2e/global-setup.ts +0 -57
- package/template/index.html +0 -340
- package/template/playwright.config.ts +0 -28
- package/template/scripts/auth-setup.sh +0 -51
- package/template/scripts/e2e-stack.sh +0 -65
package/template/setup.sh
CHANGED
|
@@ -11,9 +11,8 @@
|
|
|
11
11
|
# user-owned -> NEVER overwritten
|
|
12
12
|
# hybrid -> merged idempotently, never replaced
|
|
13
13
|
#
|
|
14
|
+
# Claude Code only (v2.3 — Cursor support dropped, §5.69).
|
|
14
15
|
# 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)
|
|
17
16
|
set -euo pipefail
|
|
18
17
|
|
|
19
18
|
SRC="."
|
|
@@ -22,9 +21,6 @@ KB_REMOTE="${ANPUNKIT_KB_REMOTE:-}"
|
|
|
22
21
|
NO_KB=0
|
|
23
22
|
FORCE=0
|
|
24
23
|
DRY=0
|
|
25
|
-
TOOLS_FLAG=""
|
|
26
|
-
ADD_TOOL=""
|
|
27
|
-
AVAILABLE_TOOLS="claude cursor" # tools with real adapters; extend here
|
|
28
24
|
while [ $# -gt 0 ]; do
|
|
29
25
|
case "$1" in
|
|
30
26
|
--src) SRC="$2"; shift 2;;
|
|
@@ -33,8 +29,6 @@ while [ $# -gt 0 ]; do
|
|
|
33
29
|
--no-kb) NO_KB=1; shift;;
|
|
34
30
|
--force) FORCE=1; shift;;
|
|
35
31
|
--dry-run) DRY=1; shift;;
|
|
36
|
-
--tools) TOOLS_FLAG="$2"; shift 2;;
|
|
37
|
-
--add-tool) ADD_TOOL="$2"; shift 2;;
|
|
38
32
|
*) echo "unknown flag: $1" >&2; exit 2;;
|
|
39
33
|
esac
|
|
40
34
|
done
|
|
@@ -70,36 +64,6 @@ fi
|
|
|
70
64
|
say "mode: $MODE (installed: ${OLD_VER:-none} -> new: $NEW_VER)"
|
|
71
65
|
[ "$MODE" = "newer-installed" ] && say " ! installed version is newer than this package — proceeding as repair, review carefully."
|
|
72
66
|
|
|
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
|
-
|
|
103
67
|
BACKUP="$DEST/.anpunkit-backup-$(date +%Y%m%d-%H%M%S)"
|
|
104
68
|
backup_one() {
|
|
105
69
|
[ "$MODE" = "fresh" ] && return 0
|
|
@@ -114,11 +78,6 @@ backup_one() {
|
|
|
114
78
|
install_kit_file() {
|
|
115
79
|
local rel="$1" s="$SRC/$1" d="$DEST/$1"
|
|
116
80
|
[ -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
|
|
122
81
|
if [ ! -f "$d" ]; then
|
|
123
82
|
act "write $rel"; [ "$DRY" = 1 ] && return 0
|
|
124
83
|
mkdir -p "$(dirname "$d")"; cp -p "$s" "$d"; return 0
|
|
@@ -156,76 +115,44 @@ else
|
|
|
156
115
|
fi
|
|
157
116
|
|
|
158
117
|
# ---- generate command adapters from commands.src/ (manifest-owned output)
|
|
159
|
-
say ""; say "[2/6] generate command adapters (
|
|
118
|
+
say ""; say "[2/6] generate command adapters (.claude/commands/):"
|
|
160
119
|
gen_adapters() {
|
|
161
|
-
|
|
162
|
-
tool_selected cursor && mkdir -p "$DEST/.cursor/commands" "$DEST/.cursor/rules"
|
|
120
|
+
mkdir -p "$DEST/.claude/commands"
|
|
163
121
|
for f in "$DEST"/commands.src/*.md; do
|
|
164
122
|
[ -f "$f" ] || continue
|
|
165
123
|
n=$(basename "$f")
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
124
|
+
act "claude .claude/commands/$n"
|
|
125
|
+
[ "$DRY" = 1 ] || cp -p "$f" "$DEST/.claude/commands/$n"
|
|
175
126
|
done
|
|
176
127
|
}
|
|
177
128
|
gen_adapters
|
|
178
129
|
|
|
179
|
-
# ---- hybrid JSON merge: ensure the hook entries
|
|
180
|
-
say ""; say "[3/6] wire hooks (idempotent merge,
|
|
130
|
+
# ---- hybrid JSON merge: ensure the Claude hook entries, never drop user keys
|
|
131
|
+
say ""; say "[3/6] wire hooks (idempotent merge, .claude/settings.json):"
|
|
181
132
|
merge_settings() {
|
|
182
|
-
[ "$DRY" = 1 ] && { act "merge
|
|
183
|
-
|
|
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) \
|
|
133
|
+
[ "$DRY" = 1 ] && { act "merge .claude/settings.json"; return 0; }
|
|
134
|
+
backup_one "$DEST/.claude/settings.json"
|
|
187
135
|
node - "$DEST" <<'NODE'
|
|
188
136
|
const fs=require('fs'),path=require('path');
|
|
189
137
|
const dest=process.argv[2];
|
|
190
|
-
const claudeSel=process.env.CLAUDE_SEL==='1', cursorSel=process.env.CURSOR_SEL==='1';
|
|
191
138
|
const load=(p,d)=>{try{return JSON.parse(fs.readFileSync(p,'utf8'))}catch(e){return d}};
|
|
192
139
|
const save=(p,o)=>{fs.mkdirSync(path.dirname(p),{recursive:true});fs.writeFileSync(p,JSON.stringify(o,null,2)+"\n")};
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
if(
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
ensure('SubagentStop','','bash .claude/hooks/subagent-stop.sh');
|
|
210
|
-
save(sp,s);
|
|
211
|
-
out.push('.claude/settings.json');
|
|
212
|
-
}
|
|
213
|
-
// 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)'));
|
|
140
|
+
const sp=path.join(dest,'.claude/settings.json');
|
|
141
|
+
const s=load(sp,{});
|
|
142
|
+
if(!s.$schema)s.$schema='https://json.schemastore.org/claude-code-settings.json';
|
|
143
|
+
s.hooks=s.hooks||{};
|
|
144
|
+
const ensure=(event,matcher,cmd)=>{
|
|
145
|
+
const arr=s.hooks[event]=s.hooks[event]||[];
|
|
146
|
+
for(const blk of arr)for(const h of (blk.hooks||[]))if(h.command===cmd)return;
|
|
147
|
+
const blk={hooks:[{type:'command',command:cmd}]};
|
|
148
|
+
if(matcher)blk.matcher=matcher;
|
|
149
|
+
arr.push(blk);
|
|
150
|
+
};
|
|
151
|
+
ensure('SessionStart','startup|clear|compact','bash .claude/hooks/session-start.sh');
|
|
152
|
+
ensure('PreCompact','auto|manual','bash .claude/hooks/pre-compact.sh');
|
|
153
|
+
ensure('SubagentStop','','bash .claude/hooks/subagent-stop.sh');
|
|
154
|
+
save(sp,s);
|
|
155
|
+
console.log(' merged .claude/settings.json');
|
|
229
156
|
NODE
|
|
230
157
|
}
|
|
231
158
|
merge_settings
|
|
@@ -237,17 +164,11 @@ say ""; say "[4/6] AGENTS.md / CLAUDE.md verify:"
|
|
|
237
164
|
verify_import() {
|
|
238
165
|
local CL="$DEST/CLAUDE.md" AG="$DEST/AGENTS.md"
|
|
239
166
|
[ -f "$AG" ] || { echo "FATAL VERIFY: AGENTS.md not on disk"; exit 1; }
|
|
240
|
-
# sentinel present in AGENTS.md (shared — both tools read AGENTS.md)
|
|
241
167
|
if ! grep -q 'ANPUNKIT-AGENTS-SENTINEL' "$AG"; then
|
|
242
168
|
echo "FATAL VERIFY: AGENTS.md sentinel missing (file clobbered or empty?)"; exit 1; fi
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
169
|
+
if ! grep -qE '^@AGENTS\.md[[:space:]]*$' "$CL"; then
|
|
170
|
+
echo "FATAL VERIFY: CLAUDE.md is missing a bare top-level '@AGENTS.md' import line"; exit 1; fi
|
|
171
|
+
say " VERIFY ok: bare @AGENTS.md import + AGENTS.md on disk + sentinel present."
|
|
251
172
|
}
|
|
252
173
|
verify_import
|
|
253
174
|
|
|
@@ -305,14 +226,12 @@ configure_kb() {
|
|
|
305
226
|
|
|
306
227
|
configure_kb
|
|
307
228
|
|
|
308
|
-
# ---- finalize: stamp the installed manifest (copy the new one)
|
|
229
|
+
# ---- finalize: stamp the installed manifest (copy the new one)
|
|
309
230
|
if [ "$DRY" != 1 ]; then
|
|
310
231
|
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
|
|
313
232
|
[ -d "$BACKUP" ] && say "" && say "backup written: ${BACKUP#$DEST/}"
|
|
314
233
|
fi
|
|
315
234
|
|
|
316
|
-
say ""; say "anpunkit setup complete ($MODE
|
|
235
|
+
say ""; say "anpunkit setup complete ($MODE). Open Claude Code — the SessionStart hook fires automatically."
|
|
317
236
|
if [ "$DRY" = 1 ]; then say "(dry-run: no files were written.)"; fi
|
|
318
237
|
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}")
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spec-assert.ts — kit-versioned matcher-aware deep-equality comparator (v2.2).
|
|
3
|
+
*
|
|
4
|
+
* The generated `data`/`ui` boundary harness asserts an actual value against
|
|
5
|
+
* `fixtures/<case-id>-expected.json` via `specAssert(actual, expected)`. Deep
|
|
6
|
+
* equality, EXCEPT that volatile fields in the expected fixture carry a matcher
|
|
7
|
+
* token instead of a literal (§5.49). This is the ONLY comparator — no per-project
|
|
8
|
+
* assertion logic. One file per supported test language; this is the TS/JS one.
|
|
9
|
+
*
|
|
10
|
+
* Matcher tokens (string values in the expected fixture):
|
|
11
|
+
*
|
|
12
|
+
* "<UUID>" any UUID v4 string
|
|
13
|
+
* "<ISO8601>" any ISO 8601 datetime string
|
|
14
|
+
* "<ANY_STRING>" any string
|
|
15
|
+
* "<ANY_NUMBER>" any finite number
|
|
16
|
+
* "<UNORDERED>" any array (presence + shape only)
|
|
17
|
+
* "<MATCHES:regex>" any string matching the pattern (RegExp.test)
|
|
18
|
+
*
|
|
19
|
+
* Order-insensitive array WITH item checking: wrap the expected array as
|
|
20
|
+
* { "<UNORDERED>": [item, item, ...] }
|
|
21
|
+
* and the actual must be an array that is an order-insensitive deep-equal multiset.
|
|
22
|
+
*
|
|
23
|
+
* A token asserts PRESENCE + SHAPE — never "ignore this field". Missing volatile
|
|
24
|
+
* fields fail (strict key sets); extra actual keys also fail.
|
|
25
|
+
*
|
|
26
|
+
* import { specAssert, loadFixture } from "../helpers/spec-assert";
|
|
27
|
+
* specAssert(actual, loadFixture("fixtures/PH2-ORDER-01-expected.json"));
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { readFileSync } from "node:fs";
|
|
31
|
+
|
|
32
|
+
const UUID4 =
|
|
33
|
+
/^[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}$/;
|
|
34
|
+
const ISO8601 =
|
|
35
|
+
/^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:?\d{2})?$/;
|
|
36
|
+
const MATCHES_PREFIX = "<MATCHES:";
|
|
37
|
+
|
|
38
|
+
export class SpecMismatch extends Error {
|
|
39
|
+
constructor(message: string) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "SpecMismatch";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function loadFixture<T = unknown>(path: string): T {
|
|
46
|
+
return JSON.parse(readFileSync(path, "utf-8")) as T;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fail(path: string, msg: string): never {
|
|
50
|
+
throw new SpecMismatch(`spec-assert mismatch at ${path}: ${msg}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
54
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function typeName(v: unknown): string {
|
|
58
|
+
if (v === null) return "null";
|
|
59
|
+
if (Array.isArray(v)) return "array";
|
|
60
|
+
return typeof v;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Returns true if `token` is a known matcher and `actual` satisfies it.
|
|
64
|
+
* Throws on a known token that is NOT satisfied. Returns false if not a token. */
|
|
65
|
+
function matchToken(actual: unknown, token: string, path: string): boolean {
|
|
66
|
+
switch (token) {
|
|
67
|
+
case "<UUID>":
|
|
68
|
+
if (!(typeof actual === "string" && UUID4.test(actual)))
|
|
69
|
+
fail(path, `expected a UUID v4 string, got ${JSON.stringify(actual)}`);
|
|
70
|
+
return true;
|
|
71
|
+
case "<ISO8601>":
|
|
72
|
+
if (!(typeof actual === "string" && ISO8601.test(actual)))
|
|
73
|
+
fail(path, `expected an ISO 8601 datetime string, got ${JSON.stringify(actual)}`);
|
|
74
|
+
return true;
|
|
75
|
+
case "<ANY_STRING>":
|
|
76
|
+
if (typeof actual !== "string")
|
|
77
|
+
fail(path, `expected any string, got ${typeName(actual)}`);
|
|
78
|
+
return true;
|
|
79
|
+
case "<ANY_NUMBER>":
|
|
80
|
+
if (typeof actual !== "number" || !Number.isFinite(actual))
|
|
81
|
+
fail(path, `expected any finite number, got ${JSON.stringify(actual)}`);
|
|
82
|
+
return true;
|
|
83
|
+
case "<UNORDERED>":
|
|
84
|
+
if (!Array.isArray(actual))
|
|
85
|
+
fail(path, `expected any array, got ${typeName(actual)}`);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
if (token.startsWith(MATCHES_PREFIX) && token.endsWith(">")) {
|
|
89
|
+
const pattern = token.slice(MATCHES_PREFIX.length, -1);
|
|
90
|
+
if (!(typeof actual === "string" && new RegExp(pattern).test(actual)))
|
|
91
|
+
fail(path, `expected a string matching /${pattern}/, got ${JSON.stringify(actual)}`);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function unorderedEqual(actual: unknown, items: unknown[], path: string): void {
|
|
98
|
+
if (!Array.isArray(actual))
|
|
99
|
+
fail(path, `expected an array (unordered), got ${typeName(actual)}`);
|
|
100
|
+
if (actual.length !== items.length)
|
|
101
|
+
fail(path, `array length ${actual.length} != expected ${items.length} (unordered)`);
|
|
102
|
+
const remaining = [...actual];
|
|
103
|
+
items.forEach((exp, i) => {
|
|
104
|
+
let found = -1;
|
|
105
|
+
for (let j = 0; j < remaining.length; j++) {
|
|
106
|
+
try {
|
|
107
|
+
specAssert(remaining[j], exp, `${path}[unordered:${i}]`);
|
|
108
|
+
found = j;
|
|
109
|
+
break;
|
|
110
|
+
} catch (e) {
|
|
111
|
+
if (e instanceof SpecMismatch) continue;
|
|
112
|
+
throw e;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (found < 0) fail(path, `no actual element matches expected item #${i}: ${JSON.stringify(exp)}`);
|
|
116
|
+
remaining.splice(found, 1);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Assert `actual` deep-equals `expected`, honoring matcher tokens. Throws
|
|
121
|
+
* SpecMismatch with a JSON path on the first divergence. */
|
|
122
|
+
export function specAssert(actual: unknown, expected: unknown, path = "$"): void {
|
|
123
|
+
// scalar matcher token
|
|
124
|
+
if (typeof expected === "string" && matchToken(actual, expected, path)) return;
|
|
125
|
+
|
|
126
|
+
// { "<UNORDERED>": [...] } wrapper → order-insensitive item compare
|
|
127
|
+
if (isPlainObject(expected)) {
|
|
128
|
+
const keys = Object.keys(expected);
|
|
129
|
+
if (keys.length === 1 && keys[0] === "<UNORDERED>") {
|
|
130
|
+
unorderedEqual(actual, expected["<UNORDERED>"] as unknown[], path);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (!isPlainObject(actual)) fail(path, `expected object, got ${typeName(actual)}`);
|
|
134
|
+
const expKeys = new Set(keys);
|
|
135
|
+
const actKeys = new Set(Object.keys(actual));
|
|
136
|
+
const missing = [...expKeys].filter((k) => !actKeys.has(k));
|
|
137
|
+
const extra = [...actKeys].filter((k) => !expKeys.has(k));
|
|
138
|
+
if (missing.length || extra.length)
|
|
139
|
+
fail(path, `key mismatch (missing=${JSON.stringify(missing)}, extra=${JSON.stringify(extra)})`);
|
|
140
|
+
for (const k of keys) specAssert(actual[k], expected[k], `${path}.${k}`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (Array.isArray(expected)) {
|
|
145
|
+
if (!Array.isArray(actual)) fail(path, `expected array, got ${typeName(actual)}`);
|
|
146
|
+
if (actual.length !== expected.length)
|
|
147
|
+
fail(path, `array length ${actual.length} != expected ${expected.length}`);
|
|
148
|
+
for (let i = 0; i < expected.length; i++)
|
|
149
|
+
specAssert(actual[i], expected[i], `${path}[${i}]`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// scalar literal — strict equality (typeof must agree; NaN-safe)
|
|
154
|
+
if (typeName(actual) !== typeName(expected))
|
|
155
|
+
fail(path, `type ${typeName(actual)} != expected ${typeName(expected)}`);
|
|
156
|
+
if (actual !== expected && !(Number.isNaN(actual) && Number.isNaN(expected)))
|
|
157
|
+
fail(path, `${JSON.stringify(actual)} != expected ${JSON.stringify(expected)}`);
|
|
158
|
+
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# cursor-session-start.sh — Cursor wiring for the SESSION-OPEN ritual.
|
|
3
|
-
# Cursor's sessionStart hook expects JSON on stdout ({"additional_context": ...}),
|
|
4
|
-
# unlike Claude Code, which injects raw stdout. This wrapper runs the SHARED
|
|
5
|
-
# session-start.sh body (single copy — anti-drift) and wraps its output in the
|
|
6
|
-
# JSON envelope Cursor requires. Cursor sets CLAUDE_PROJECT_DIR (compat alias),
|
|
7
|
-
# so the shared script needs no changes.
|
|
8
|
-
# Uses node for JSON (the kit assumes Node; python3 is a fallback only).
|
|
9
|
-
# Verified against cursor.com/docs/hooks (sessionStart output schema), 2026-06.
|
|
10
|
-
set -euo pipefail
|
|
11
|
-
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
12
|
-
OUT="$(bash "$HERE/session-start.sh" 2>/dev/null || true)"
|
|
13
|
-
if command -v node >/dev/null 2>&1; then
|
|
14
|
-
printf '%s' "$OUT" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(JSON.stringify({additional_context:d})))'
|
|
15
|
-
else
|
|
16
|
-
printf '%s' "$OUT" | python3 -c 'import json,sys; print(json.dumps({"additional_context": sys.stdin.read()}))'
|
|
17
|
-
fi
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: caveman
|
|
3
|
-
description: >
|
|
4
|
-
Ultra-compressed communication mode. Cuts token usage ~75% by dropping
|
|
5
|
-
filler, articles, and pleasantries while keeping full technical accuracy.
|
|
6
|
-
Use when user says "caveman mode", "talk like caveman", "use caveman",
|
|
7
|
-
"less tokens", "be brief", or invokes /caveman.
|
|
8
|
-
---
|
|
9
|
-
|
|
10
|
-
Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
|
11
|
-
|
|
12
|
-
## Persistence
|
|
13
|
-
|
|
14
|
-
ACTIVE EVERY RESPONSE once triggered. No revert after many turns. No filler drift. Still active if unsure. Off only when user says "stop caveman" or "normal mode".
|
|
15
|
-
|
|
16
|
-
## Rules
|
|
17
|
-
|
|
18
|
-
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Abbreviate common terms (DB/auth/config/req/res/fn/impl). Strip conjunctions. Use arrows for causality (X -> Y). One word when one word enough.
|
|
19
|
-
|
|
20
|
-
Technical terms stay exact. Code blocks unchanged. Errors quoted exact.
|
|
21
|
-
|
|
22
|
-
Pattern: `[thing] [action] [reason]. [next step].`
|
|
23
|
-
|
|
24
|
-
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
|
|
25
|
-
Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
|
|
26
|
-
|
|
27
|
-
### Examples
|
|
28
|
-
|
|
29
|
-
**"Why React component re-render?"**
|
|
30
|
-
|
|
31
|
-
> Inline obj prop -> new ref -> re-render. `useMemo`.
|
|
32
|
-
|
|
33
|
-
**"Explain database connection pooling."**
|
|
34
|
-
|
|
35
|
-
> Pool = reuse DB conn. Skip handshake -> fast under load.
|
|
36
|
-
|
|
37
|
-
## Auto-Clarity Exception
|
|
38
|
-
|
|
39
|
-
Drop caveman temporarily for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user asks to clarify or repeats question. Resume caveman after.
|