ticketwright 2.0.0__py3-none-any.whl
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.
- ticketwright/__init__.py +9 -0
- ticketwright/_kit/.claude/agents/qc-reviewer.md +50 -0
- ticketwright/_kit/.claude/commands/build-context-pack.md +8 -0
- ticketwright/_kit/.claude/commands/build-ticket-index.md +8 -0
- ticketwright/_kit/.claude/commands/configure-workspace.md +8 -0
- ticketwright/_kit/.claude/commands/deliver-ticket.md +8 -0
- ticketwright/_kit/.claude/commands/onboard-teammate.md +8 -0
- ticketwright/_kit/.claude/commands/prime-domain.md +8 -0
- ticketwright/_kit/.claude/commands/prime-ticket.md +8 -0
- ticketwright/_kit/.claude/commands/prime-warehouse.md +8 -0
- ticketwright/_kit/.claude/commands/productize-workflow.md +8 -0
- ticketwright/_kit/.claude/commands/qc-review.md +8 -0
- ticketwright/_kit/.claude/commands/recall.md +8 -0
- ticketwright/_kit/.claude/commands/start-ticket.md +8 -0
- ticketwright/_kit/.claude/config/stack.example.asana-bq.yaml +67 -0
- ticketwright/_kit/.claude/config/stack.example.azure.yaml +73 -0
- ticketwright/_kit/.claude/config/stack.schema.md +76 -0
- ticketwright/_kit/.claude/config/stack.yaml +76 -0
- ticketwright/_kit/.claude/hooks/db_write_guard.py +145 -0
- ticketwright/_kit/.claude/hooks/regenerate_ticket_index.py +74 -0
- ticketwright/_kit/.claude/hooks/session_context.py +78 -0
- ticketwright/_kit/.claude/hooks/ticket_index_context.py +99 -0
- ticketwright/_kit/.claude/settings.json.tmpl +59 -0
- ticketwright/_kit/.claude/skills/productize/SKILL.md +58 -0
- ticketwright/_kit/.claude/skills/productize/authoring.md +36 -0
- ticketwright/_kit/.claude/skills/refresh/SKILL.md +40 -0
- ticketwright/_kit/.claude/skills/refresh/context-pack.md +34 -0
- ticketwright/_kit/.claude/skills/refresh/index.md +45 -0
- ticketwright/_kit/.claude/skills/review/SKILL.md +76 -0
- ticketwright/_kit/.claude/skills/setup/SKILL.md +59 -0
- ticketwright/_kit/.claude/skills/setup/adopt.md +43 -0
- ticketwright/_kit/.claude/skills/setup/scaffold.md +38 -0
- ticketwright/_kit/.claude/skills/setup/teammate.md +33 -0
- ticketwright/_kit/.claude/skills/ship/SKILL.md +53 -0
- ticketwright/_kit/.claude/skills/spec-and-build/SKILL.md +54 -0
- ticketwright/_kit/.claude/skills/ticket/SKILL.md +59 -0
- ticketwright/_kit/.claude/skills/ticket/priming.md +47 -0
- ticketwright/_kit/.claude/statusline.sh +22 -0
- ticketwright/_kit/adapters/README.md +103 -0
- ticketwright/_kit/adapters/chat/slack.md +46 -0
- ticketwright/_kit/adapters/chat/teams.md +38 -0
- ticketwright/_kit/adapters/docstore/gdrive.md +39 -0
- ticketwright/_kit/adapters/docstore/sharepoint.md +41 -0
- ticketwright/_kit/adapters/tracker/asana.md +55 -0
- ticketwright/_kit/adapters/tracker/azure-devops.md +68 -0
- ticketwright/_kit/adapters/tracker/github-issues.md +59 -0
- ticketwright/_kit/adapters/tracker/jira.md +71 -0
- ticketwright/_kit/adapters/tracker/linear.md +56 -0
- ticketwright/_kit/adapters/tracker/monday.md +62 -0
- ticketwright/_kit/adapters/vcs/azure-repos.md +58 -0
- ticketwright/_kit/adapters/vcs/github.md +55 -0
- ticketwright/_kit/adapters/vcs/gitlab.md +52 -0
- ticketwright/_kit/adapters/warehouse/bigquery.md +44 -0
- ticketwright/_kit/adapters/warehouse/databricks.md +58 -0
- ticketwright/_kit/adapters/warehouse/postgres.md +44 -0
- ticketwright/_kit/adapters/warehouse/redshift.md +49 -0
- ticketwright/_kit/adapters/warehouse/snowflake.md +54 -0
- ticketwright/_kit/adapters/warehouse/synapse.md +52 -0
- ticketwright/_kit/bin/build_ticket_index.py +498 -0
- ticketwright/_kit/bin/enrich_ticket.py +138 -0
- ticketwright/_kit/bin/ingest_index_records.py +149 -0
- ticketwright/_kit/bin/recall.py +268 -0
- ticketwright/_kit/bin/render.sh +54 -0
- ticketwright/_kit/bin/render_and_validate.sh +139 -0
- ticketwright/_kit/bin/selftest.sh +408 -0
- ticketwright/_kit/bin/split_and_export.sh +138 -0
- ticketwright/_kit/bin/verify_stack.sh +74 -0
- ticketwright/_kit/templates/AGENTS.md.tmpl +74 -0
- ticketwright/_kit/templates/gitignore.tmpl +35 -0
- ticketwright/_kit/templates/plan.md.tmpl +22 -0
- ticketwright/_kit/templates/productized-skill/SKILL.md.tmpl +67 -0
- ticketwright/_kit/templates/productized-skill/bin/drift_check.sh +30 -0
- ticketwright/_kit/templates/productized-skill/golden/example.json +13 -0
- ticketwright/_kit/templates/productized-skill/sql/qc.sql.tmpl +21 -0
- ticketwright/_kit/templates/productized-skill/sql/step.sql.tmpl +20 -0
- ticketwright/_kit/templates/productized-skill/templates/README.md.tmpl +20 -0
- ticketwright/_kit/templates/productized-skill/templates/tracker_comment.txt.tmpl +14 -0
- ticketwright/_kit/templates/roles/analyst.md +6 -0
- ticketwright/_kit/templates/roles/engineer.md +6 -0
- ticketwright/_kit/templates/roles/generalist.md +3 -0
- ticketwright/_kit/templates/roles/scientist.md +5 -0
- ticketwright/_kit/templates/spec.md.tmpl +34 -0
- ticketwright/_kit/templates/ticket-README.md.tmpl +29 -0
- ticketwright/cli.py +115 -0
- ticketwright-2.0.0.dist-info/METADATA +139 -0
- ticketwright-2.0.0.dist-info/RECORD +89 -0
- ticketwright-2.0.0.dist-info/WHEEL +4 -0
- ticketwright-2.0.0.dist-info/entry_points.txt +2 -0
- ticketwright-2.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# selftest.sh — the starter kit's own test suite (evals-as-first-class).
|
|
3
|
+
# Validates the foundation, skills, adapters, templates, and HOOKS so a reviewer (or CI) can
|
|
4
|
+
# trust the kit without manual poking. Read-only; no MFA/network. Exit non-zero on any failure.
|
|
5
|
+
#
|
|
6
|
+
# Run from anywhere: bash bin/selftest.sh
|
|
7
|
+
set -uo pipefail
|
|
8
|
+
|
|
9
|
+
# cd to kit root (this script lives in <kit>/bin/)
|
|
10
|
+
cd "$(dirname "$0")/.." || exit 2
|
|
11
|
+
KIT="$(pwd)"
|
|
12
|
+
PASS=0; FAIL=0; TMP="$(mktemp -d)"
|
|
13
|
+
trap 'rm -rf "$TMP"' EXIT
|
|
14
|
+
|
|
15
|
+
ok() { PASS=$((PASS+1)); printf " \033[32m✓\033[0m %s\n" "$1"; }
|
|
16
|
+
bad() { FAIL=$((FAIL+1)); printf " \033[31m✗\033[0m %s\n" "$1"; [ -n "${2:-}" ] && printf " %s\n" "$2"; }
|
|
17
|
+
hdr() { printf "\n\033[1m%s\033[0m\n" "$1"; }
|
|
18
|
+
|
|
19
|
+
hdr "0 · tooling"
|
|
20
|
+
command -v yq >/dev/null 2>&1 && ok "yq present" || bad "yq missing (brew install yq)"
|
|
21
|
+
command -v python3 >/dev/null 2>&1 && ok "python3 present" || bad "python3 missing"
|
|
22
|
+
|
|
23
|
+
hdr "1 · config parses + every seam resolves to an adapter (all stacks)"
|
|
24
|
+
for s in .claude/config/stack.yaml .claude/config/stack.example.*.yaml; do
|
|
25
|
+
if yq -e '.seams|keys' "$s" >/dev/null 2>&1; then ok "parses: $s"; else bad "parse error: $s"; fi
|
|
26
|
+
out="$(bash bin/verify_stack.sh "$s" --dry-run 2>&1)"
|
|
27
|
+
if grep -q "All seams OK" <<<"$out" && ! grep -q "adapter missing" <<<"$out"; then
|
|
28
|
+
ok "all seams resolve: $s"
|
|
29
|
+
else bad "seam resolution failed: $s" "$(grep -E 'missing|UNREACHABLE' <<<"$out" | head -2)"; fi
|
|
30
|
+
done
|
|
31
|
+
|
|
32
|
+
hdr "2 · adapter verb coverage matches the contract"
|
|
33
|
+
verbs_expected() { # bash 3.2-safe (no associative arrays)
|
|
34
|
+
case "$1" in
|
|
35
|
+
tracker) echo 6;; warehouse) echo 3;; chat) echo 4;; docstore) echo 2;; vcs) echo 4;; *) echo 0;;
|
|
36
|
+
esac
|
|
37
|
+
}
|
|
38
|
+
for f in adapters/*/*.md; do
|
|
39
|
+
[ "$(basename "$f")" = "README.md" ] && continue
|
|
40
|
+
seam="$(basename "$(dirname "$f")")"; want="$(verbs_expected "$seam")"
|
|
41
|
+
got="$(grep -c '^## verb:' "$f")"
|
|
42
|
+
[ "$got" -eq "$want" ] && ok "$f ($got/$want verbs)" || bad "$f has $got verbs, expected $want"
|
|
43
|
+
done
|
|
44
|
+
|
|
45
|
+
hdr "3 · no tool names leak into skill/command orchestration"
|
|
46
|
+
# Two intentional matches are allowed: the CLI *detector* and the self-test *instruction* line.
|
|
47
|
+
leaks="$(grep -REn -i 'acli|\bsnow \b|snow sql|mcp__slack|slack_send|\bgh pr\b|\bgh auth\b|ACCOUNT_USAGE|SHOW VIEWS' \
|
|
48
|
+
.claude/skills .claude/commands 2>/dev/null \
|
|
49
|
+
| grep -v 'for c in snow acli gh' \
|
|
50
|
+
| grep -v 'grep -REn "acli|snow ' || true)"
|
|
51
|
+
[ -z "$leaks" ] && ok "skills/commands are tool-neutral" || bad "tool name leaked into a skill" "$leaks"
|
|
52
|
+
|
|
53
|
+
hdr "4 · frontmatter valid (skills + agents)"
|
|
54
|
+
for f in .claude/skills/*/SKILL.md .claude/agents/*.md; do
|
|
55
|
+
[ -f "$f" ] || continue
|
|
56
|
+
if [ "$(head -1 "$f")" = "---" ] && grep -q '^name:' "$f" && grep -q '^description:' "$f"; then
|
|
57
|
+
ok "frontmatter: $f"
|
|
58
|
+
else bad "bad frontmatter: $f"; fi
|
|
59
|
+
done
|
|
60
|
+
|
|
61
|
+
hdr "5 · render.sh round-trip (AGENTS.md.tmpl → no unresolved tokens)"
|
|
62
|
+
cat > "$TMP/vars.env" <<'EOF'
|
|
63
|
+
repo_name=demo
|
|
64
|
+
domain=data
|
|
65
|
+
ticket_path=tickets/{assignee}/{id}
|
|
66
|
+
tracker_tool=jira
|
|
67
|
+
warehouse_tool=snowflake
|
|
68
|
+
chat_tool=slack
|
|
69
|
+
docstore_tool=gdrive
|
|
70
|
+
vcs_tool=github
|
|
71
|
+
key_prefix=ENG
|
|
72
|
+
terminal_status=Done
|
|
73
|
+
wl_tracker_comment=100
|
|
74
|
+
wl_chat=100
|
|
75
|
+
wl_pr=200
|
|
76
|
+
wl_ticket=200
|
|
77
|
+
chat_always_include=Alice
|
|
78
|
+
default_branch=main
|
|
79
|
+
role_focus=**You are a senior engineer** doing ticket-driven work (filled from templates/roles/<role>.md).
|
|
80
|
+
EOF
|
|
81
|
+
err="$(bash bin/render.sh templates/AGENTS.md.tmpl --vars "$TMP/vars.env" 2>&1 >/dev/null)"
|
|
82
|
+
[ -z "$err" ] && ok "AGENTS.md renders with zero leftover tokens" || bad "unresolved tokens in AGENTS.md" "$err"
|
|
83
|
+
# zero KEY=VALUE pairs must not crash (bash 3.2 empty-array under set -u)
|
|
84
|
+
bash bin/render.sh templates/spec.md.tmpl >/dev/null 2>"$TMP/rz.err"; rc=$?
|
|
85
|
+
[ "$rc" -eq 0 ] && ok "render.sh with no vars doesn't crash (bash 3.2 empty array)" || bad "render.sh crashes with zero pairs" "$(cat "$TMP/rz.err")"
|
|
86
|
+
|
|
87
|
+
hdr "6 · db_write_guard hook (PreToolUse policy enforcement)"
|
|
88
|
+
guard() { python3 .claude/hooks/db_write_guard.py; }
|
|
89
|
+
# read-only inline SELECT → no decision (allow through)
|
|
90
|
+
out="$(echo '{"tool_name":"Bash","tool_input":{"command":"snow sql -q \"SELECT * FROM t LIMIT 5\""}}' | guard)"
|
|
91
|
+
[ -z "$out" ] && ok "SELECT passes through (no prompt)" || bad "SELECT wrongly gated" "$out"
|
|
92
|
+
# inline destructive UPDATE → ask
|
|
93
|
+
out="$(echo '{"tool_name":"Bash","tool_input":{"command":"snow sql -q \"UPDATE t SET x=1\""}}' | guard)"
|
|
94
|
+
grep -q '"permissionDecision": "ask"' <<<"$out" && grep -q UPDATE <<<"$out" && ok "inline UPDATE → ask" || bad "UPDATE not gated" "$out"
|
|
95
|
+
# destructive SQL inside a -f file → ask (the strengthened file scan)
|
|
96
|
+
echo "CREATE OR REPLACE TABLE foo AS SELECT 1;" > "$TMP/deploy.sql"
|
|
97
|
+
out="$(echo "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"snow sql -f $TMP/deploy.sql\"},\"cwd\":\"/\"}" | guard)"
|
|
98
|
+
grep -q '"permissionDecision": "ask"' <<<"$out" && ok "-f deploy.sql (CREATE OR REPLACE) → ask" || bad "file-based write not gated" "$out"
|
|
99
|
+
# read-only -f file → no decision
|
|
100
|
+
echo "SELECT count(*) FROM t;" > "$TMP/qc.sql"
|
|
101
|
+
out="$(echo "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"snow sql -f $TMP/qc.sql\"},\"cwd\":\"/\"}" | guard)"
|
|
102
|
+
[ -z "$out" ] && ok "-f qc.sql (SELECT only) passes through" || bad "read-only file wrongly gated" "$out"
|
|
103
|
+
# non-warehouse bash → no decision
|
|
104
|
+
out="$(echo '{"tool_name":"Bash","tool_input":{"command":"ls -la"}}' | guard)"
|
|
105
|
+
[ -z "$out" ] && ok "non-warehouse bash passes through" || bad "non-warehouse wrongly gated" "$out"
|
|
106
|
+
# non-Bash tool → no decision
|
|
107
|
+
out="$(echo '{"tool_name":"Read","tool_input":{"file_path":"x"}}' | guard)"
|
|
108
|
+
[ -z "$out" ] && ok "non-Bash tool passes through" || bad "non-Bash wrongly gated" "$out"
|
|
109
|
+
# destructive SQL via stdin redirect (psql < file.sql) → ask (the strengthened stdin scan)
|
|
110
|
+
echo "DELETE FROM t WHERE 1=1;" > "$TMP/wipe.sql"
|
|
111
|
+
out="$(echo "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"psql mydb < $TMP/wipe.sql\"},\"cwd\":\"/\"}" | guard)"
|
|
112
|
+
grep -q '"permissionDecision": "ask"' <<<"$out" && ok "psql < wipe.sql (stdin redirect) → ask" || bad "stdin-redirect write not gated" "$out"
|
|
113
|
+
|
|
114
|
+
hdr "7 · session_context hook (SessionStart priming)"
|
|
115
|
+
out="$(echo '{"hook_event_name":"SessionStart"}' | CLAUDE_PROJECT_DIR="$KIT" python3 .claude/hooks/session_context.py 2>&1)"
|
|
116
|
+
grep -q "ENG" <<<"$out" && grep -qi "Lifecycle" <<<"$out" && ok "emits stack + lifecycle summary" || bad "session context missing/empty" "$out"
|
|
117
|
+
|
|
118
|
+
hdr "8 · statusline renders"
|
|
119
|
+
out="$(echo '{}' | CLAUDE_PROJECT_DIR="$KIT" bash .claude/statusline.sh 2>&1)"
|
|
120
|
+
grep -q "ENG" <<<"$out" && ok "statusline: $out" || bad "statusline empty/broken" "$out"
|
|
121
|
+
|
|
122
|
+
hdr "9 · productize stamp smoke (SKILL.md.tmpl → 0 leftover tokens)"
|
|
123
|
+
err="$(bash bin/render.sh templates/productized-skill/SKILL.md.tmpl \
|
|
124
|
+
skill_name=x one_line_description=x argument_hint=x workflow_name=x params_table=x \
|
|
125
|
+
param_validation=x precondition=x render_run_steps=x qc_table=x output_filenames=x \
|
|
126
|
+
golden_invocation=x golden_fixture=x golden_assertions=x failure_mode_tests=x side_effects=x \
|
|
127
|
+
2>&1 >/dev/null)"
|
|
128
|
+
[ -z "$err" ] && ok "productized SKILL.md stamps clean" || bad "leftover tokens in stamped SKILL.md" "$err"
|
|
129
|
+
|
|
130
|
+
hdr "10 · ticket index (renderer + url template + hooks)"
|
|
131
|
+
P="$TMP/proj"
|
|
132
|
+
mkdir -p "$P/.claude/config" "$P/bin" "$P/tickets/alice/ENG-1"
|
|
133
|
+
cp bin/build_ticket_index.py bin/ingest_index_records.py "$P/bin/"
|
|
134
|
+
cat > "$P/.claude/config/stack.yaml" <<'EOF'
|
|
135
|
+
project:
|
|
136
|
+
key_prefix: ENG
|
|
137
|
+
key_prefixes: [ENG]
|
|
138
|
+
ticket_url_template: "https://acme.example/browse/{id}"
|
|
139
|
+
EOF
|
|
140
|
+
printf '# ENG-1: Demo index ticket\n\nA demo ticket used by the kit self-test to exercise the index renderer.\n' > "$P/tickets/alice/ENG-1/README.md"
|
|
141
|
+
CLAUDE_PROJECT_DIR="$P" python3 bin/build_ticket_index.py >/dev/null 2>&1
|
|
142
|
+
if grep -q 'ENG-1' "$P/tickets/INDEX.md" 2>/dev/null && grep -q '▱' "$P/tickets/INDEX.md" 2>/dev/null; then
|
|
143
|
+
ok "renderer writes INDEX.md with an un-enriched row"; else bad "renderer did not produce expected INDEX.md"; fi
|
|
144
|
+
grep -q 'acme.example/browse/ENG-1' "$P/tickets/INDEX.md" 2>/dev/null && ok "ticket_url_template applied" || bad "ticket_url_template not applied"
|
|
145
|
+
if CLAUDE_PROJECT_DIR="$P" python3 bin/build_ticket_index.py --check >/dev/null 2>&1; then
|
|
146
|
+
ok "--check passes after render (deterministic)"; else bad "--check reported stale immediately after render"; fi
|
|
147
|
+
mkdir -p "$P/tickets/alice/ENG-2"
|
|
148
|
+
printf '# ENG-2: Second demo ticket\n\nAnother demo ticket.\n' > "$P/tickets/alice/ENG-2/README.md"
|
|
149
|
+
echo "{\"tool_input\":{\"file_path\":\"$P/tickets/alice/ENG-2/README.md\"},\"cwd\":\"$P\"}" | CLAUDE_PROJECT_DIR="$P" python3 .claude/hooks/regenerate_ticket_index.py >/dev/null 2>&1
|
|
150
|
+
grep -q 'ENG-2' "$P/tickets/INDEX.md" 2>/dev/null && ok "PostToolUse hook auto-adds a new ticket row" || bad "PostToolUse hook did not regenerate"
|
|
151
|
+
out="$(echo '{}' | CLAUDE_PROJECT_DIR="$P" python3 .claude/hooks/ticket_index_context.py 2>&1)"
|
|
152
|
+
grep -qi 'INDEX.md' <<<"$out" && ok "SessionStart index hook emits a catalog pointer" || bad "SessionStart index hook silent" "$out"
|
|
153
|
+
|
|
154
|
+
hdr "11 · prior-art recall + object reverse-index"
|
|
155
|
+
R="$TMP/recall"
|
|
156
|
+
mkdir -p "$R/.claude/config" "$R/tickets/dana/ENG-1" "$R/tickets/dana/ENG-2" "$R/tickets/dana/ENG-3"
|
|
157
|
+
printf 'project:\n key_prefix: ENG\n' > "$R/.claude/config/stack.yaml"
|
|
158
|
+
printf '# ENG-1: Loan tape base\n\nbase pull.\n' > "$R/tickets/dana/ENG-1/README.md"
|
|
159
|
+
printf 'SELECT * FROM BI.ANALYTICS.VW_LOAN;\n' > "$R/tickets/dana/ENG-1/q.sql"
|
|
160
|
+
printf '# ENG-2: Loan tape follow-up\n\nFollow-on to ENG-1.\n' > "$R/tickets/dana/ENG-2/README.md"
|
|
161
|
+
printf 'SELECT * FROM BI.ANALYTICS.VW_LOAN;\n' > "$R/tickets/dana/ENG-2/q.sql"
|
|
162
|
+
printf '# ENG-3: Genesys call metrics\n\nunrelated work.\n' > "$R/tickets/dana/ENG-3/README.md"
|
|
163
|
+
printf 'SELECT * FROM BI.OPS.VW_CALL;\n' > "$R/tickets/dana/ENG-3/q.sql"
|
|
164
|
+
printf 'from os.path import join\nimport collections.abc\n' > "$R/tickets/dana/ENG-3/munge.py" # must NOT be indexed
|
|
165
|
+
CLAUDE_PROJECT_DIR="$R" python3 bin/build_ticket_index.py >/dev/null 2>&1
|
|
166
|
+
if grep 'VW_LOAN' "$R/tickets/OBJECTS.md" 2>/dev/null | grep -q 'ENG-1' && grep 'VW_LOAN' "$R/tickets/OBJECTS.md" | grep -q 'ENG-2'; then
|
|
167
|
+
ok "OBJECTS.md maps shared object → both tickets"; else bad "OBJECTS.md reverse map wrong" "$(cat "$R/tickets/OBJECTS.md" 2>/dev/null)"; fi
|
|
168
|
+
CLAUDE_PROJECT_DIR="$R" python3 bin/build_ticket_index.py --check >/dev/null 2>&1 && ok "--check covers INDEX.md + OBJECTS.md" || bad "--check failed after render"
|
|
169
|
+
rj="$(CLAUDE_PROJECT_DIR="$R" python3 bin/recall.py --for ENG-1 --json 2>/dev/null)"
|
|
170
|
+
top="$(python3 -c "import json,sys; d=json.loads(sys.stdin.read() or '[]'); print(d[0]['id'] if d else '')" <<<"$rj")"
|
|
171
|
+
ids="$(python3 -c "import json,sys; d=json.loads(sys.stdin.read() or '[]'); print(','.join(x['id'] for x in d))" <<<"$rj")"
|
|
172
|
+
[ "$top" = "ENG-2" ] && ok "recall ranks the related ticket first (ENG-2)" || bad "recall mis-ranked" "top=$top ids=$ids"
|
|
173
|
+
grep -q 'ENG-3' <<<"$ids" && bad "recall surfaced the unrelated ticket (ENG-3)" || ok "recall excludes the unrelated ticket"
|
|
174
|
+
rl="$(CLAUDE_PROJECT_DIR="$R" python3 bin/recall.py --object BI.ANALYTICS.VW_LOAN --json 2>/dev/null | python3 -c "import json,sys; print(sorted(x['id'] for x in json.load(sys.stdin)))")"
|
|
175
|
+
[ "$rl" = "['ENG-1', 'ENG-2']" ] && ok "recall --object reverse lookup → ENG-1, ENG-2" || bad "reverse lookup wrong" "$rl"
|
|
176
|
+
# regression: unqualified --object must leaf-match the qualified stored object
|
|
177
|
+
ul="$(CLAUDE_PROJECT_DIR="$R" python3 bin/recall.py --object VW_LOAN --json 2>/dev/null | python3 -c "import json,sys; print(sorted(x['id'] for x in json.load(sys.stdin)))")"
|
|
178
|
+
[ "$ul" = "['ENG-1', 'ENG-2']" ] && ok "recall --object leaf match (unqualified VW_LOAN → ENG-1, ENG-2)" || bad "leaf-match lookup wrong" "$ul"
|
|
179
|
+
# regression: Python `from os.path import` must not be indexed as a data object
|
|
180
|
+
grep -qi 'os\.path\|collections\.abc' "$R/tickets/OBJECTS.md" 2>/dev/null && bad "Python import indexed as object" "$(grep -i 'os.path\|collections' "$R/tickets/OBJECTS.md")" || ok "Python import lines excluded from object index"
|
|
181
|
+
grep -q 'recall.py' .claude/skills/ticket/priming.md && ok "/ticket priming wires the recall engine" || bad "/ticket priming missing recall wiring"
|
|
182
|
+
# --eval diagnostic: ENG-2's README references ENG-1, so there's one labeled seed to score
|
|
183
|
+
ev="$(CLAUDE_PROJECT_DIR="$R" python3 bin/recall.py --eval 2>/dev/null)"
|
|
184
|
+
grep -q 'MRR=' <<<"$ev" && ok "recall --eval reports recall-quality metrics" || bad "recall --eval produced no metrics" "$ev"
|
|
185
|
+
# IDF down-weighting: a ticket sharing a RARE object must outrank tickets sharing a UBIQUITOUS one
|
|
186
|
+
I="$TMP/idf"; mkdir -p "$I/.claude/config"
|
|
187
|
+
printf 'project:\n key_prefix: ENG\n' > "$I/.claude/config/stack.yaml"
|
|
188
|
+
for n in 1 2 3 4 5 6; do mkdir -p "$I/tickets/dana/ENG-$n"; printf '# ENG-%s x\n\nx.\n' "$n" > "$I/tickets/dana/ENG-$n/README.md"; printf 'SELECT * FROM S.VW_COMMON;\n' > "$I/tickets/dana/ENG-$n/q.sql"; done
|
|
189
|
+
mkdir -p "$I/tickets/dana/ENG-7"; printf '# ENG-7 x\n\nx.\n' > "$I/tickets/dana/ENG-7/README.md"; printf 'SELECT * FROM S.VW_RARE;\n' > "$I/tickets/dana/ENG-7/q.sql"
|
|
190
|
+
mkdir -p "$I/tickets/dana/ENG-9"; printf '# ENG-9 x\n\nx.\n' > "$I/tickets/dana/ENG-9/README.md"; printf 'SELECT * FROM S.VW_COMMON JOIN S.VW_RARE;\n' > "$I/tickets/dana/ENG-9/q.sql"
|
|
191
|
+
CLAUDE_PROJECT_DIR="$I" python3 bin/build_ticket_index.py >/dev/null 2>&1
|
|
192
|
+
idf="$(CLAUDE_PROJECT_DIR="$I" python3 bin/recall.py --for ENG-9 --json 2>/dev/null | python3 -c "import json,sys; d=json.loads(sys.stdin.read() or '[]'); print(d[0]['id'] if d else '')")"
|
|
193
|
+
[ "$idf" = "ENG-7" ] && ok "IDF down-weighting ranks the rare-object ticket first (ENG-7 over VW_COMMON crowd)" || bad "IDF ranking wrong" "top=$idf"
|
|
194
|
+
# regression: same id under two owners — --for --owner must keep the OTHER owner's ticket as a candidate
|
|
195
|
+
M="$TMP/multiowner"; mkdir -p "$M/.claude/config" "$M/tickets/alice/ENG-5" "$M/tickets/bob/ENG-5"
|
|
196
|
+
printf 'project:\n key_prefix: ENG\n' > "$M/.claude/config/stack.yaml"
|
|
197
|
+
printf '# ENG-5: alice payment recovery\n\nshared payment recovery loan tape work.\n' > "$M/tickets/alice/ENG-5/README.md"
|
|
198
|
+
printf '# ENG-5: bob payment recovery\n\nshared payment recovery loan tape work.\n' > "$M/tickets/bob/ENG-5/README.md"
|
|
199
|
+
mo="$(CLAUDE_PROJECT_DIR="$M" python3 bin/recall.py --for ENG-5 --owner alice --json 2>/dev/null | python3 -c "import json,sys; print(','.join(x['owner']+'/'+x['id'] for x in json.load(sys.stdin)))")"
|
|
200
|
+
grep -q 'bob/ENG-5' <<<"$mo" && ok "recall --for keeps same-id ticket under another owner" || bad "seed exclusion dropped same-id/other-owner" "$mo"
|
|
201
|
+
amb="$(CLAUDE_PROJECT_DIR="$M" python3 bin/recall.py --for ENG-5 2>&1 >/dev/null)"
|
|
202
|
+
grep -q 'multiple owners' <<<"$amb" && ok "recall errors on ambiguous seed (no --owner)" || bad "ambiguous seed not flagged" "$amb"
|
|
203
|
+
# --recurring surfaces a frequently-touched object (VW_COMMON is in 7 of the IDF fixture's tickets)
|
|
204
|
+
rec="$(CLAUDE_PROJECT_DIR="$I" python3 bin/build_ticket_index.py --recurring --min-tickets 3 2>/dev/null)"
|
|
205
|
+
grep -q 'VW_COMMON' <<<"$rec" && ok "--recurring lists a frequently-touched object" || bad "--recurring missed it" "$rec"
|
|
206
|
+
|
|
207
|
+
hdr "12 · ingest validators (the LLM-record trust boundary)"
|
|
208
|
+
G="$TMP/ingest"; mkdir -p "$G/.claude/config" "$G/tickets/dana/ENG-1"
|
|
209
|
+
printf 'project:\n key_prefix: ENG\n' > "$G/.claude/config/stack.yaml"
|
|
210
|
+
printf '# ENG-1 x\n\nx.\n' > "$G/tickets/dana/ENG-1/README.md"
|
|
211
|
+
printf '%s' '{"records":[{"id":"ENG-1","owner":"dana","title":"T","status":"Completed","date":"not-a-date","objects":["bare_name","S.VW_X"],"tags":["Has Spaces!","Has Spaces!"],"summary":"s"}]}' \
|
|
212
|
+
| CLAUDE_PROJECT_DIR="$G" python3 bin/ingest_index_records.py --from-json - >/dev/null 2>&1
|
|
213
|
+
chk="$(python3 -c "import json; t=json.load(open('$G/tickets/index_data.json'))['tickets'][0]; print(repr(t['date']), t['objects'], t['tags'])")"
|
|
214
|
+
[ "$chk" = "None ['S.VW_X'] ['has-spaces']" ] && ok "ingest drops bad date + bare object, coerces/dedups tags" || bad "ingest validators wrong" "$chk"
|
|
215
|
+
|
|
216
|
+
hdr "13 · privacy guard (no real ticket store committed to the public kit)"
|
|
217
|
+
# The store is per-install PRIVATE business data. It must be gitignored here; if it is ever tracked,
|
|
218
|
+
# it must be empty or fixture-only (ENG-/DEMO-/TEST-/SAMPLE-). This catches an accidental `cp` of a
|
|
219
|
+
# real index_data.json + commit before it reaches the public repo.
|
|
220
|
+
if git ls-files --error-unmatch tickets/index_data.json >/dev/null 2>&1; then
|
|
221
|
+
realids="$(python3 - <<'PY'
|
|
222
|
+
import json, re
|
|
223
|
+
try:
|
|
224
|
+
d = json.load(open("tickets/index_data.json"))
|
|
225
|
+
except Exception:
|
|
226
|
+
d = {}
|
|
227
|
+
ts = d.get("tickets", []) if isinstance(d, dict) else []
|
|
228
|
+
bad = [str(t.get("id", "")) for t in ts if not re.match(r"^(ENG|DEMO|TEST|SAMPLE)-", str(t.get("id", "")))]
|
|
229
|
+
print(" ".join(bad))
|
|
230
|
+
PY
|
|
231
|
+
)"
|
|
232
|
+
[ -z "$realids" ] && ok "tracked index_data.json is empty/fixture-only" \
|
|
233
|
+
|| bad "REAL ticket ids committed to the public kit — scrub before pushing" "$realids"
|
|
234
|
+
else
|
|
235
|
+
ok "tickets/index_data.json is gitignored (a private store can't be committed)"
|
|
236
|
+
fi
|
|
237
|
+
[ -f tickets/index_data.example.json ] && ok "index_data.example.json shipped as the schema reference" \
|
|
238
|
+
|| bad "tickets/index_data.example.json missing"
|
|
239
|
+
|
|
240
|
+
hdr "14 · scrub + structure (public-kit hygiene)"
|
|
241
|
+
# scrub: generic secret / PII patterns must not appear in tracked kit files (selftest excluded — it
|
|
242
|
+
# carries the patterns themselves; we deliberately do NOT hardcode any employer name here).
|
|
243
|
+
scrub="$(grep -rIlE 'AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----|[0-9]{3}-[0-9]{2}-[0-9]{4}' \
|
|
244
|
+
--exclude-dir=.git --exclude=selftest.sh . 2>/dev/null || true)"
|
|
245
|
+
[ -z "$scrub" ] && ok "no secret/PII patterns in tracked files" || bad "scrub hit" "$scrub"
|
|
246
|
+
# structure: every command/skill has PARSEABLE frontmatter (a substring grep false-greens on broken
|
|
247
|
+
# YAML — e.g. `argument-hint: [a] [b]` parses as a flow seq + trailing junk and drops ALL metadata when
|
|
248
|
+
# loaded as a plugin). Validate flow-node values are complete + a description is present. Stdlib only.
|
|
249
|
+
fm_bad="$(python3 - <<'PY'
|
|
250
|
+
import re, glob
|
|
251
|
+
def check(f):
|
|
252
|
+
m = re.match(r'^---\n(.*?)\n---', open(f, encoding='utf-8').read(), re.S)
|
|
253
|
+
if not m:
|
|
254
|
+
return f + " (no frontmatter)"
|
|
255
|
+
desc = False
|
|
256
|
+
for ln in m.group(1).splitlines():
|
|
257
|
+
mm = re.match(r'^([A-Za-z_][\w-]*):\s*(.*)$', ln)
|
|
258
|
+
if not mm:
|
|
259
|
+
continue
|
|
260
|
+
k, v = mm.group(1), mm.group(2).strip()
|
|
261
|
+
if k == "description" and v:
|
|
262
|
+
desc = True
|
|
263
|
+
if v[:1] in '["\'': # a flow node must be the WHOLE value (only trailing whitespace)
|
|
264
|
+
if v[0] == "[":
|
|
265
|
+
depth = idx = 0; idx = -1
|
|
266
|
+
for i, c in enumerate(v):
|
|
267
|
+
if c == "[": depth += 1
|
|
268
|
+
elif c == "]":
|
|
269
|
+
depth -= 1
|
|
270
|
+
if depth == 0: idx = i; break
|
|
271
|
+
else:
|
|
272
|
+
idx = v.find(v[0], 1)
|
|
273
|
+
if idx == -1 or v[idx + 1:].strip():
|
|
274
|
+
return f + ": invalid YAML flow value for '" + k + "'"
|
|
275
|
+
return None if desc else f + " (no description)"
|
|
276
|
+
bad = [r for r in (check(f) for f in glob.glob('.claude/commands/*.md') + glob.glob('.claude/skills/*/SKILL.md')) if r]
|
|
277
|
+
print("\n".join(bad))
|
|
278
|
+
PY
|
|
279
|
+
)"
|
|
280
|
+
[ -z "$fm_bad" ] && ok "every command/skill has valid, parseable frontmatter (+ a description)" || bad "frontmatter problem (would drop metadata as a plugin)" "$fm_bad"
|
|
281
|
+
afail=""
|
|
282
|
+
for f in adapters/*/*.md; do
|
|
283
|
+
h="$(head -12 "$f")"; { grep -q '^seam:' <<<"$h" && grep -q '^tool:' <<<"$h"; } || afail="$afail $f"
|
|
284
|
+
done
|
|
285
|
+
[ -z "$afail" ] && ok "every adapter declares seam + tool" || bad "adapter frontmatter incomplete" "$afail"
|
|
286
|
+
# role modes: 4 snippets present + the template token is wired
|
|
287
|
+
roles_ok=1; for r in generalist analyst engineer scientist; do [ -f "templates/roles/$r.md" ] || roles_ok=0; done
|
|
288
|
+
{ [ "$roles_ok" = 1 ] && grep -q '{{role_focus}}' templates/AGENTS.md.tmpl; } \
|
|
289
|
+
&& ok "role-mode snippets present + {{role_focus}} wired into AGENTS.md.tmpl" || bad "role modes incomplete"
|
|
290
|
+
|
|
291
|
+
hdr "14b · v2 skill surface (7 skills + deprecated aliases route correctly)"
|
|
292
|
+
sk_missing=""
|
|
293
|
+
for s in setup ticket spec-and-build review ship productize refresh; do
|
|
294
|
+
[ -f ".claude/skills/$s/SKILL.md" ] || sk_missing="$sk_missing $s"
|
|
295
|
+
done
|
|
296
|
+
[ -z "$sk_missing" ] && ok "all 7 v2 skills present (setup ticket spec-and-build review ship productize refresh)" \
|
|
297
|
+
|| bad "v2 skill missing:$sk_missing"
|
|
298
|
+
extra="$(ls -d .claude/skills/*/ | grep -Ev '/(setup|ticket|spec-and-build|review|ship|productize|refresh)/$' || true)"
|
|
299
|
+
[ -z "$extra" ] && ok "no stray skill folders beyond the 7" || bad "unexpected skill folder (v1 leftover?)" "$extra"
|
|
300
|
+
al_bad=""
|
|
301
|
+
for a in configure-workspace onboard-teammate start-ticket qc-review deliver-ticket productize-workflow \
|
|
302
|
+
build-ticket-index build-context-pack prime-ticket prime-warehouse prime-domain recall; do
|
|
303
|
+
{ [ -f ".claude/commands/$a.md" ] && grep -q 'Deprecated' ".claude/commands/$a.md"; } || al_bad="$al_bad $a"
|
|
304
|
+
done
|
|
305
|
+
[ -z "$al_bad" ] && ok "all 12 v1 names ship as deprecated alias stubs" || bad "alias stub missing/unmarked:$al_bad"
|
|
306
|
+
|
|
307
|
+
hdr "15 · plugin manifest (Claude Code plugin packaging)"
|
|
308
|
+
python3 -c "import json; m=json.load(open('.claude-plugin/plugin.json')); assert m['name']=='ticketwright' and m.get('version') and 'hooks' in m" 2>/dev/null \
|
|
309
|
+
&& ok "plugin.json valid + has name/version/hooks" || bad "plugin.json invalid/missing fields"
|
|
310
|
+
python3 -c "import json; d=json.load(open('.claude-plugin/marketplace.json')); assert any(p.get('name')=='ticketwright' for p in d['plugins'])" 2>/dev/null \
|
|
311
|
+
&& ok "marketplace.json valid + lists ticketwright" || bad "marketplace.json invalid"
|
|
312
|
+
# auto-discovery symlinks must resolve into .claude/* (loader rejected custom .claude paths in the manifest)
|
|
313
|
+
{ [ -L commands ] && [ -L skills ] && [ -L agents ] && [ -d commands ] && [ -d skills ] && [ -d agents ]; } \
|
|
314
|
+
&& ok "component symlinks resolve (commands/skills/agents → .claude/*)" || bad "plugin component symlinks broken"
|
|
315
|
+
# every hook script the plugin manifest declares must exist
|
|
316
|
+
hk=1; for h in db_write_guard regenerate_ticket_index session_context ticket_index_context; do [ -f ".claude/hooks/$h.py" ] || hk=0; done
|
|
317
|
+
[ "$hk" = 1 ] && ok "all plugin-declared hook scripts present" || bad "a plugin-declared hook script is missing"
|
|
318
|
+
|
|
319
|
+
hdr "16 · PyPI package (manifest + version sync + CLI)"
|
|
320
|
+
{ [ -f pyproject.toml ] && [ -f ticketwright/__init__.py ] && [ -f ticketwright/cli.py ]; } \
|
|
321
|
+
&& ok "package files present (pyproject + ticketwright/)" || bad "package files missing"
|
|
322
|
+
# pyproject sources its version dynamically from __init__.py, so __init__ is the ONE source of truth;
|
|
323
|
+
# plugin.json + marketplace.json must agree with it (release bumps these three in lockstep).
|
|
324
|
+
grep -q 'dynamic = \["version"\]' pyproject.toml \
|
|
325
|
+
&& ok "pyproject version is dynamic (single source: ticketwright/__init__.py)" \
|
|
326
|
+
|| bad "pyproject should declare dynamic = [\"version\"] (no static version)"
|
|
327
|
+
iv="$(grep '__version__' ticketwright/__init__.py | sed 's/[^0-9.]//g')"
|
|
328
|
+
jv="$(grep -m1 '"version"' .claude-plugin/plugin.json | sed 's/[^0-9.]//g')"
|
|
329
|
+
mv="$(grep -m1 '"version"' .claude-plugin/marketplace.json | sed 's/[^0-9.]//g')"
|
|
330
|
+
{ [ -n "$iv" ] && [ "$iv" = "$jv" ] && [ "$iv" = "$mv" ]; } \
|
|
331
|
+
&& ok "version synced across __init__/plugin/marketplace ($iv)" \
|
|
332
|
+
|| bad "version drift" "init=$iv plugin=$jv market=$mv"
|
|
333
|
+
# the CLI module imports + exposes main(); console-script entry point declared
|
|
334
|
+
python3 -c "import sys; sys.path.insert(0,'.'); import ticketwright.cli as c; raise SystemExit(0 if callable(c.main) else 1)" 2>/dev/null \
|
|
335
|
+
&& ok "ticketwright.cli imports + exposes main()" || bad "ticketwright.cli broken"
|
|
336
|
+
grep -q 'ticketwright = "ticketwright.cli:main"' pyproject.toml \
|
|
337
|
+
&& ok "console_script entry point declared" || bad "console_script entry point missing"
|
|
338
|
+
|
|
339
|
+
hdr "17 · render-validation gate (render_and_validate.sh) — items 1+2"
|
|
340
|
+
RV="bin/render_and_validate.sh"
|
|
341
|
+
cat > "$TMP/clean.sql.tmpl" <<'EOF'
|
|
342
|
+
-- step SQL — params described in prose, never as tokens
|
|
343
|
+
SET d = '{{asof}}'::DATE;
|
|
344
|
+
SELECT id FROM {{src}} WHERE dt <= '{{asof}}' ORDER BY id;
|
|
345
|
+
EOF
|
|
346
|
+
bash "$RV" "$TMP/clean.sql.tmpl" asof=2026-06-30 src=T >/dev/null 2>&1 \
|
|
347
|
+
&& ok "passes a clean template (quoted literal, no comment tokens)" || bad "clean template wrongly rejected"
|
|
348
|
+
cat > "$TMP/cmt.sql.tmpl" <<'EOF'
|
|
349
|
+
-- NOTE: substitutes {{vals}} before the run
|
|
350
|
+
SELECT * FROM t WHERE x IN ({{vals}}) ORDER BY 1;
|
|
351
|
+
EOF
|
|
352
|
+
bash "$RV" "$TMP/cmt.sql.tmpl" vals="1,2" >/dev/null 2>&1 \
|
|
353
|
+
&& bad "token-in-comment NOT caught (the Fortress hard-failure class)" || ok "rejects a {{token}} inside a SQL comment"
|
|
354
|
+
cat > "$TMP/unq.sql.tmpl" <<'EOF'
|
|
355
|
+
SET d = {{asof}};
|
|
356
|
+
SELECT 1 AS x ORDER BY 1;
|
|
357
|
+
EOF
|
|
358
|
+
bash "$RV" "$TMP/unq.sql.tmpl" asof=2026-06-30 >/dev/null 2>&1 \
|
|
359
|
+
&& ok "unquoted SQL literal is a warning (non-strict passes)" || bad "unquoted literal wrongly failed non-strict"
|
|
360
|
+
bash "$RV" "$TMP/unq.sql.tmpl" asof=2026-06-30 --strict >/dev/null 2>&1 \
|
|
361
|
+
&& bad "unquoted literal NOT escalated under --strict" || ok "unquoted literal fails under --strict"
|
|
362
|
+
cat > "$TMP/par.sql.tmpl" <<'EOF'
|
|
363
|
+
SELECT count(* FROM {{src}} ORDER BY 1;
|
|
364
|
+
EOF
|
|
365
|
+
bash "$RV" "$TMP/par.sql.tmpl" src=T >/dev/null 2>&1 \
|
|
366
|
+
&& bad "unbalanced parens NOT caught" || ok "rejects unbalanced parens in rendered SQL"
|
|
367
|
+
# the SHIPPED productized templates must obey their own rules (render clean under --strict)
|
|
368
|
+
bash "$RV" templates/productized-skill/sql/step.sql.tmpl --strict \
|
|
369
|
+
period=Q src=T as_of_date=2026-06-30 select_columns=id source_object=T filter="1=1" order_key=id >/dev/null 2>&1 \
|
|
370
|
+
&& ok "shipped step.sql.tmpl passes its own gate (--strict)" || bad "step.sql.tmpl fails the render gate"
|
|
371
|
+
bash "$RV" templates/productized-skill/sql/qc.sql.tmpl --strict \
|
|
372
|
+
grain_key=id output_object=O source_object=S filter="1=1" required_col=id >/dev/null 2>&1 \
|
|
373
|
+
&& ok "shipped qc.sql.tmpl passes its own gate (--strict)" || bad "qc.sql.tmpl fails the render gate"
|
|
374
|
+
|
|
375
|
+
hdr "18 · export helpers (split_and_export.sh) — items 3+4"
|
|
376
|
+
SE="bin/split_and_export.sh"
|
|
377
|
+
cat > "$TMP/multi.sql" <<'EOF'
|
|
378
|
+
USE WAREHOUSE WH;
|
|
379
|
+
-- Query 1: alpha
|
|
380
|
+
SELECT 1 AS a ORDER BY 1;
|
|
381
|
+
-- Query 2: beta
|
|
382
|
+
SELECT 2 AS b ORDER BY 1;
|
|
383
|
+
EOF
|
|
384
|
+
bash "$SE" "$TMP/multi.sql" "$TMP/mout" >/dev/null 2>&1
|
|
385
|
+
n="$(ls "$TMP/mout"/*.sql 2>/dev/null | wc -l | tr -d ' ')"
|
|
386
|
+
{ [ "$n" = "2" ] && grep -q 'USE WAREHOUSE WH' "$TMP/mout/02-beta.sql"; } \
|
|
387
|
+
&& ok "split on -- Query N markers → N files, shared preamble replicated into each" \
|
|
388
|
+
|| bad "split/preamble wrong (n=$n)" "$(ls "$TMP/mout" 2>/dev/null)"
|
|
389
|
+
printf 'status\nStatement executed successfully.\nStatement executed successfully.\n\nID,X\n1,a\n' > "$TMP/raw.csv"
|
|
390
|
+
bash "$SE" --strip-only "$TMP/raw.csv" >/dev/null 2>&1
|
|
391
|
+
[ "$(head -1 "$TMP/raw.csv")" = "ID,X" ] \
|
|
392
|
+
&& ok "--strip-only drops the multi-statement CLI preamble (header is row 1)" \
|
|
393
|
+
|| bad "strip-only left preamble" "$(head -1 "$TMP/raw.csv")"
|
|
394
|
+
|
|
395
|
+
hdr "19 · gitignore template (PII leak fix) — item 6"
|
|
396
|
+
grep -q '^\*\*/final_deliverables/\*\.csv' templates/gitignore.tmpl \
|
|
397
|
+
&& ok "gitignore.tmpl ships the anchored **/final_deliverables/*.csv rule" || bad "gitignore.tmpl missing anchored export rule"
|
|
398
|
+
GI="$TMP/gi"; mkdir -p "$GI/tickets/d/ENG-1/final_deliverables"
|
|
399
|
+
git -C "$GI" init -q 2>/dev/null
|
|
400
|
+
cp templates/gitignore.tmpl "$GI/.gitignore"
|
|
401
|
+
: > "$GI/tickets/d/ENG-1/final_deliverables/x.csv"; : > "$GI/tickets/d/ENG-1/final_deliverables/x.sql"
|
|
402
|
+
{ git -C "$GI" check-ignore -q tickets/d/ENG-1/final_deliverables/x.csv \
|
|
403
|
+
&& ! git -C "$GI" check-ignore -q tickets/d/ENG-1/final_deliverables/x.sql; } \
|
|
404
|
+
&& ok "anchored pattern ignores a NESTED export CSV but keeps the deliverable SQL" \
|
|
405
|
+
|| bad "gitignore anchoring wrong (nested CSV not ignored, or SQL ignored)"
|
|
406
|
+
|
|
407
|
+
printf "\n\033[1mselftest: %d passed, %d failed\033[0m\n" "$PASS" "$FAIL"
|
|
408
|
+
[ "$FAIL" -eq 0 ] || exit 1
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# split_and_export.sh — the export-phase helper productized multi-deliverable skills kept re-improvising.
|
|
3
|
+
#
|
|
4
|
+
# Two jobs, both about getting clean CSVs out of a warehouse CLI:
|
|
5
|
+
#
|
|
6
|
+
# SPLIT one multi-result SQL file (N independent SELECT blocks delimited by `-- Query N` markers)
|
|
7
|
+
# into N runnable .sql files, each carrying the shared preamble (USE WAREHOUSE / USE ROLE /
|
|
8
|
+
# any SPLIT_TO_TABLE sample that sits above the first marker). Optionally --run each through
|
|
9
|
+
# your warehouse adapter and strip the CLI preamble from every CSV.
|
|
10
|
+
#
|
|
11
|
+
# STRIP the multi-statement preamble a `-f` run prints ahead of the real result
|
|
12
|
+
# (`status` / `Statement executed successfully.` / blank lines). Robust: drops through the
|
|
13
|
+
# LAST "Statement executed successfully." then leading blanks, or anchors on a known header.
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
# bin/split_and_export.sh INPUT.sql OUTDIR [--marker REGEX] [--ext csv] [--run 'CMD']
|
|
17
|
+
# bin/split_and_export.sh --strip-only FILE [--header REGEX] # FILE may be - for stdin→stdout
|
|
18
|
+
#
|
|
19
|
+
# --run CMD : a command template run once per split file. {sql} → the chunk path, {csv} → its output
|
|
20
|
+
# path. CMD's stdout is captured as the CSV (then stripped); or CMD may write {csv} itself.
|
|
21
|
+
# Tool-agnostic — you supply your adapter's query verb, e.g.
|
|
22
|
+
# --run 'snow sql -f {sql} --format csv'
|
|
23
|
+
# (a leaf helper may name your warehouse CLI; the skill orchestration stays neutral.)
|
|
24
|
+
# --marker : default '^\s*--+\s*Query\s+\d+' (case-insensitive). --ext: output extension (csv).
|
|
25
|
+
set -uo pipefail
|
|
26
|
+
|
|
27
|
+
py_strip() { # py_strip FILE HEADER_REGEX — strips the preamble from FILE in place (real path only)
|
|
28
|
+
python3 - "$1" "$2" <<'PY'
|
|
29
|
+
import re, sys
|
|
30
|
+
path, header = sys.argv[1], (sys.argv[2] or None)
|
|
31
|
+
data = open(path, encoding="utf-8").read().splitlines()
|
|
32
|
+
idx = [i for i, l in enumerate(data) if l.strip() == "Statement executed successfully."]
|
|
33
|
+
if idx: # drop through the LAST "Statement executed…"
|
|
34
|
+
start = idx[-1] + 1
|
|
35
|
+
elif header: # …or anchor on a known header row
|
|
36
|
+
h = re.compile(header); hit = [i for i, l in enumerate(data) if h.search(l)]
|
|
37
|
+
start = hit[0] if hit else 0
|
|
38
|
+
else:
|
|
39
|
+
start = 0
|
|
40
|
+
while start < len(data) and data[start].strip() == "": # …then leading blanks
|
|
41
|
+
start += 1
|
|
42
|
+
out = "\n".join(data[start:])
|
|
43
|
+
open(path, "w", encoding="utf-8").write(out + "\n" if out else out)
|
|
44
|
+
print(f"strip: dropped {start} preamble line(s) from {path}", file=sys.stderr)
|
|
45
|
+
PY
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# ---- strip-only mode ----
|
|
49
|
+
if [[ "${1:-}" == "--strip-only" ]]; then
|
|
50
|
+
file="${2:-}"; header=""
|
|
51
|
+
[[ -n "$file" ]] || { echo "usage: $0 --strip-only FILE [--header REGEX]" >&2; exit 1; }
|
|
52
|
+
shift 2
|
|
53
|
+
while [[ $# -gt 0 ]]; do
|
|
54
|
+
case "$1" in --header) header="${2:-}"; shift 2 ;; *) echo "unrecognized arg: $1" >&2; exit 1 ;; esac
|
|
55
|
+
done
|
|
56
|
+
if [[ "$file" == "-" ]]; then # stdin → stdout (heredoc owns python's stdin, so buffer first)
|
|
57
|
+
tmp="$(mktemp)"; cat > "$tmp"
|
|
58
|
+
py_strip "$tmp" "$header" 2>/dev/null
|
|
59
|
+
cat "$tmp"; rm -f "$tmp"
|
|
60
|
+
else
|
|
61
|
+
[[ -f "$file" ]] || { echo "strip: file not found: $file" >&2; exit 1; }
|
|
62
|
+
py_strip "$file" "$header"
|
|
63
|
+
fi
|
|
64
|
+
exit 0
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
# ---- split (+ optional run) mode ----
|
|
68
|
+
input="${1:-}"; outdir="${2:-}"
|
|
69
|
+
{ [[ -n "$input" && -n "$outdir" ]]; } || { echo "usage: $0 INPUT.sql OUTDIR [--marker REGEX] [--ext csv] [--run 'CMD']" >&2; exit 1; }
|
|
70
|
+
[[ -f "$input" ]] || { echo "split: input not found: $input" >&2; exit 1; }
|
|
71
|
+
shift 2
|
|
72
|
+
marker='^\s*--+\s*Query\s+\d+'; ext="csv"; run=""
|
|
73
|
+
while [[ $# -gt 0 ]]; do
|
|
74
|
+
case "$1" in
|
|
75
|
+
--marker) marker="${2:-}"; shift 2 ;;
|
|
76
|
+
--ext) ext="${2:-}"; shift 2 ;;
|
|
77
|
+
--run) run="${2:-}"; shift 2 ;;
|
|
78
|
+
*) echo "unrecognized arg: $1" >&2; exit 1 ;;
|
|
79
|
+
esac
|
|
80
|
+
done
|
|
81
|
+
mkdir -p "$outdir"
|
|
82
|
+
|
|
83
|
+
# Split into OUTDIR/NN-slug.sql (preamble replicated into each). Print the written paths, one per line.
|
|
84
|
+
made=() # bash-3.2-safe collection (no mapfile/readarray)
|
|
85
|
+
while IFS= read -r _line; do made+=("$_line"); done < <(python3 - "$input" "$outdir" "$marker" <<'PY'
|
|
86
|
+
import re, sys, os
|
|
87
|
+
input_path, outdir, marker = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
88
|
+
lines = open(input_path, encoding="utf-8").read().splitlines()
|
|
89
|
+
mre = re.compile(marker, re.IGNORECASE)
|
|
90
|
+
marks = [i for i, l in enumerate(lines) if mre.search(l)]
|
|
91
|
+
if marks:
|
|
92
|
+
preamble = lines[:marks[0]]
|
|
93
|
+
bounds = marks + [len(lines)]
|
|
94
|
+
chunks = [(marks[k], lines[marks[k]:bounds[k+1]]) for k in range(len(marks))]
|
|
95
|
+
else: # no markers → treat the whole file as one query
|
|
96
|
+
preamble, chunks = [], [(None, lines)]
|
|
97
|
+
pre = ("\n".join(preamble).rstrip() + "\n\n") if any(s.strip() for s in preamble) else ""
|
|
98
|
+
def slug(headline, k):
|
|
99
|
+
if headline is None:
|
|
100
|
+
return "query"
|
|
101
|
+
s = re.sub(r'^\s*--+\s*', '', headline) # drop the leading dashes
|
|
102
|
+
s = re.sub(r'(?i)\bquery\s*\d+\b[:.\-\s]*', '', s, count=1) # drop "Query N"
|
|
103
|
+
s = re.sub(r'[^A-Za-z0-9]+', '-', s).strip('-').lower()
|
|
104
|
+
return s[:48] or "query"
|
|
105
|
+
for k, (mark, body) in enumerate(chunks, 1):
|
|
106
|
+
headline = lines[mark] if mark is not None else None
|
|
107
|
+
name = f"{k:02d}-{slug(headline, k)}.sql"
|
|
108
|
+
path = os.path.join(outdir, name)
|
|
109
|
+
text = pre + "\n".join(body).rstrip() + "\n"
|
|
110
|
+
open(path, "w", encoding="utf-8").write(text)
|
|
111
|
+
print(path)
|
|
112
|
+
PY
|
|
113
|
+
)
|
|
114
|
+
[[ ${#made[@]} -gt 0 ]] || { echo "split: produced no files (empty input?)" >&2; exit 1; }
|
|
115
|
+
echo "split: wrote ${#made[@]} file(s) to $outdir/" >&2
|
|
116
|
+
printf ' %s\n' "${made[@]}" >&2
|
|
117
|
+
|
|
118
|
+
# Optional: run each through the supplied adapter command and strip the preamble from each CSV.
|
|
119
|
+
if [[ -n "$run" ]]; then
|
|
120
|
+
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
|
121
|
+
fail=0
|
|
122
|
+
for sql in "${made[@]}"; do
|
|
123
|
+
csv="${sql%.sql}.$ext"
|
|
124
|
+
sqlq="$(printf '%q' "$sql")"; csvq="$(printf '%q' "$csv")"
|
|
125
|
+
cmd="${run//\{sql\}/$sqlq}"; cmd="${cmd//\{csv\}/$csvq}"
|
|
126
|
+
if bash -c "$cmd" > "$TMP/raw" 2> "$TMP/err"; then
|
|
127
|
+
if [[ -s "$TMP/raw" ]]; then mv "$TMP/raw" "$csv"; fi # CMD wrote to stdout
|
|
128
|
+
if [[ -s "$csv" ]]; then
|
|
129
|
+
py_strip "$csv" "" ; echo "✓ $csv" >&2
|
|
130
|
+
else
|
|
131
|
+
echo "⚠ $sql produced no rows (empty CSV)" >&2; fail=1
|
|
132
|
+
fi
|
|
133
|
+
else
|
|
134
|
+
echo "✗ run failed for $sql (rc=$?)" >&2; sed 's/^/ /' "$TMP/err" >&2; fail=1
|
|
135
|
+
fi
|
|
136
|
+
done
|
|
137
|
+
exit "$fail"
|
|
138
|
+
fi
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# verify_stack.sh — the "verify" half of the hybrid wiring.
|
|
3
|
+
#
|
|
4
|
+
# Reads a stack.yaml, and for each seam: confirms the adapter file exists, then runs the seam's
|
|
5
|
+
# read-only `verify` command (with {token} interpolation) to confirm the tool is reachable.
|
|
6
|
+
# Mirrors the repo's existing bin/view_drift_check.sh halt-on-fail pattern.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# bin/verify_stack.sh [STACK_YAML] # default: .claude/config/stack.yaml
|
|
10
|
+
# bin/verify_stack.sh [STACK_YAML] --dry-run # don't run verify cmds; just resolve + show them
|
|
11
|
+
#
|
|
12
|
+
# Exit: 0 if all seams reachable (or dry-run), 1 if any seam unreachable.
|
|
13
|
+
set -uo pipefail
|
|
14
|
+
|
|
15
|
+
stack="${1:-.claude/config/stack.yaml}"
|
|
16
|
+
dry=0
|
|
17
|
+
[[ "${2:-}" == "--dry-run" || "${1:-}" == "--dry-run" ]] && dry=1
|
|
18
|
+
[[ "${1:-}" == "--dry-run" ]] && stack=".claude/config/stack.yaml"
|
|
19
|
+
|
|
20
|
+
command -v yq >/dev/null 2>&1 || { echo "verify_stack: 'yq' required (brew install yq)" >&2; exit 1; }
|
|
21
|
+
[[ -f "$stack" ]] || { echo "verify_stack: stack file not found: $stack" >&2; exit 1; }
|
|
22
|
+
kit_root="$(cd "$(dirname "$stack")/../.." && pwd)"
|
|
23
|
+
|
|
24
|
+
# Flatten project.* into a token file (key<TAB>value) so verify strings like "{default_epic}" /
|
|
25
|
+
# "{base_path}" resolve. Plain file + loop (no associative arrays) keeps this bash 3.2-compatible.
|
|
26
|
+
BASETOK="$(mktemp)"; trap 'rm -f "$BASETOK"' EXIT
|
|
27
|
+
yq -r '.project | to_entries | .[] | [.key, (.value|tostring)] | @tsv' "$stack" 2>/dev/null > "$BASETOK" || true
|
|
28
|
+
|
|
29
|
+
interp() { # interp "<string>" "<tokfile>": replace {key} with value for each key<TAB>value line
|
|
30
|
+
local s="$1" f="$2" k v
|
|
31
|
+
while IFS=$'\t' read -r k v; do
|
|
32
|
+
[ -n "$k" ] && s="${s//\{$k\}/$v}"
|
|
33
|
+
done < "$f"
|
|
34
|
+
printf '%s' "$s"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
echo "verify_stack: $stack $([[ $dry -eq 1 ]] && echo '(dry-run)')"
|
|
38
|
+
echo "─────────────────────────────────────────────────────────"
|
|
39
|
+
fail=0
|
|
40
|
+
for seam in $(yq -r '.seams | keys | .[]' "$stack"); do
|
|
41
|
+
tool=$(yq -r ".seams.$seam.tool // \"?\"" "$stack")
|
|
42
|
+
adapter=$(yq -r ".seams.$seam.adapter // \"\"" "$stack")
|
|
43
|
+
verify=$(yq -r ".seams.$seam.verify // \"\"" "$stack")
|
|
44
|
+
|
|
45
|
+
# Per-seam token file = base project tokens + this seam's own scalar keys.
|
|
46
|
+
seamtok="$(mktemp)"
|
|
47
|
+
cat "$BASETOK" > "$seamtok"
|
|
48
|
+
yq -r ".seams.$seam | to_entries | .[] | select(.value|type==\"!!str\" or type==\"!!int\" or type==\"!!float\") | [.key,(.value|tostring)] | @tsv" "$stack" 2>/dev/null >> "$seamtok" || true
|
|
49
|
+
|
|
50
|
+
printf "▸ %-10s tool=%-10s" "$seam" "$tool"
|
|
51
|
+
|
|
52
|
+
# 1) adapter present?
|
|
53
|
+
if [[ -z "$adapter" || ! -f "$kit_root/$adapter" ]]; then
|
|
54
|
+
echo " ✗ adapter missing ($adapter)"; fail=1; rm -f "$seamtok"; continue
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
# 2) verify reachable?
|
|
58
|
+
if [[ -z "$verify" || "$verify" == "null" ]]; then
|
|
59
|
+
echo " ⚠ no verify command (skills will warn)"; rm -f "$seamtok"; continue
|
|
60
|
+
fi
|
|
61
|
+
cmd="$(interp "$verify" "$seamtok")"
|
|
62
|
+
rm -f "$seamtok"
|
|
63
|
+
if [[ $dry -eq 1 ]]; then
|
|
64
|
+
echo " → would run: $cmd"; continue
|
|
65
|
+
fi
|
|
66
|
+
if eval "$cmd" >/dev/null 2>&1; then
|
|
67
|
+
echo " ✓ reachable"
|
|
68
|
+
else
|
|
69
|
+
echo " ✗ UNREACHABLE → $cmd"; fail=1
|
|
70
|
+
fi
|
|
71
|
+
done
|
|
72
|
+
echo "─────────────────────────────────────────────────────────"
|
|
73
|
+
if [[ $fail -eq 0 ]]; then echo "All seams OK."; else echo "One or more seams need attention (auth/install)."; fi
|
|
74
|
+
exit $fail
|