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.
Files changed (89) hide show
  1. ticketwright/__init__.py +9 -0
  2. ticketwright/_kit/.claude/agents/qc-reviewer.md +50 -0
  3. ticketwright/_kit/.claude/commands/build-context-pack.md +8 -0
  4. ticketwright/_kit/.claude/commands/build-ticket-index.md +8 -0
  5. ticketwright/_kit/.claude/commands/configure-workspace.md +8 -0
  6. ticketwright/_kit/.claude/commands/deliver-ticket.md +8 -0
  7. ticketwright/_kit/.claude/commands/onboard-teammate.md +8 -0
  8. ticketwright/_kit/.claude/commands/prime-domain.md +8 -0
  9. ticketwright/_kit/.claude/commands/prime-ticket.md +8 -0
  10. ticketwright/_kit/.claude/commands/prime-warehouse.md +8 -0
  11. ticketwright/_kit/.claude/commands/productize-workflow.md +8 -0
  12. ticketwright/_kit/.claude/commands/qc-review.md +8 -0
  13. ticketwright/_kit/.claude/commands/recall.md +8 -0
  14. ticketwright/_kit/.claude/commands/start-ticket.md +8 -0
  15. ticketwright/_kit/.claude/config/stack.example.asana-bq.yaml +67 -0
  16. ticketwright/_kit/.claude/config/stack.example.azure.yaml +73 -0
  17. ticketwright/_kit/.claude/config/stack.schema.md +76 -0
  18. ticketwright/_kit/.claude/config/stack.yaml +76 -0
  19. ticketwright/_kit/.claude/hooks/db_write_guard.py +145 -0
  20. ticketwright/_kit/.claude/hooks/regenerate_ticket_index.py +74 -0
  21. ticketwright/_kit/.claude/hooks/session_context.py +78 -0
  22. ticketwright/_kit/.claude/hooks/ticket_index_context.py +99 -0
  23. ticketwright/_kit/.claude/settings.json.tmpl +59 -0
  24. ticketwright/_kit/.claude/skills/productize/SKILL.md +58 -0
  25. ticketwright/_kit/.claude/skills/productize/authoring.md +36 -0
  26. ticketwright/_kit/.claude/skills/refresh/SKILL.md +40 -0
  27. ticketwright/_kit/.claude/skills/refresh/context-pack.md +34 -0
  28. ticketwright/_kit/.claude/skills/refresh/index.md +45 -0
  29. ticketwright/_kit/.claude/skills/review/SKILL.md +76 -0
  30. ticketwright/_kit/.claude/skills/setup/SKILL.md +59 -0
  31. ticketwright/_kit/.claude/skills/setup/adopt.md +43 -0
  32. ticketwright/_kit/.claude/skills/setup/scaffold.md +38 -0
  33. ticketwright/_kit/.claude/skills/setup/teammate.md +33 -0
  34. ticketwright/_kit/.claude/skills/ship/SKILL.md +53 -0
  35. ticketwright/_kit/.claude/skills/spec-and-build/SKILL.md +54 -0
  36. ticketwright/_kit/.claude/skills/ticket/SKILL.md +59 -0
  37. ticketwright/_kit/.claude/skills/ticket/priming.md +47 -0
  38. ticketwright/_kit/.claude/statusline.sh +22 -0
  39. ticketwright/_kit/adapters/README.md +103 -0
  40. ticketwright/_kit/adapters/chat/slack.md +46 -0
  41. ticketwright/_kit/adapters/chat/teams.md +38 -0
  42. ticketwright/_kit/adapters/docstore/gdrive.md +39 -0
  43. ticketwright/_kit/adapters/docstore/sharepoint.md +41 -0
  44. ticketwright/_kit/adapters/tracker/asana.md +55 -0
  45. ticketwright/_kit/adapters/tracker/azure-devops.md +68 -0
  46. ticketwright/_kit/adapters/tracker/github-issues.md +59 -0
  47. ticketwright/_kit/adapters/tracker/jira.md +71 -0
  48. ticketwright/_kit/adapters/tracker/linear.md +56 -0
  49. ticketwright/_kit/adapters/tracker/monday.md +62 -0
  50. ticketwright/_kit/adapters/vcs/azure-repos.md +58 -0
  51. ticketwright/_kit/adapters/vcs/github.md +55 -0
  52. ticketwright/_kit/adapters/vcs/gitlab.md +52 -0
  53. ticketwright/_kit/adapters/warehouse/bigquery.md +44 -0
  54. ticketwright/_kit/adapters/warehouse/databricks.md +58 -0
  55. ticketwright/_kit/adapters/warehouse/postgres.md +44 -0
  56. ticketwright/_kit/adapters/warehouse/redshift.md +49 -0
  57. ticketwright/_kit/adapters/warehouse/snowflake.md +54 -0
  58. ticketwright/_kit/adapters/warehouse/synapse.md +52 -0
  59. ticketwright/_kit/bin/build_ticket_index.py +498 -0
  60. ticketwright/_kit/bin/enrich_ticket.py +138 -0
  61. ticketwright/_kit/bin/ingest_index_records.py +149 -0
  62. ticketwright/_kit/bin/recall.py +268 -0
  63. ticketwright/_kit/bin/render.sh +54 -0
  64. ticketwright/_kit/bin/render_and_validate.sh +139 -0
  65. ticketwright/_kit/bin/selftest.sh +408 -0
  66. ticketwright/_kit/bin/split_and_export.sh +138 -0
  67. ticketwright/_kit/bin/verify_stack.sh +74 -0
  68. ticketwright/_kit/templates/AGENTS.md.tmpl +74 -0
  69. ticketwright/_kit/templates/gitignore.tmpl +35 -0
  70. ticketwright/_kit/templates/plan.md.tmpl +22 -0
  71. ticketwright/_kit/templates/productized-skill/SKILL.md.tmpl +67 -0
  72. ticketwright/_kit/templates/productized-skill/bin/drift_check.sh +30 -0
  73. ticketwright/_kit/templates/productized-skill/golden/example.json +13 -0
  74. ticketwright/_kit/templates/productized-skill/sql/qc.sql.tmpl +21 -0
  75. ticketwright/_kit/templates/productized-skill/sql/step.sql.tmpl +20 -0
  76. ticketwright/_kit/templates/productized-skill/templates/README.md.tmpl +20 -0
  77. ticketwright/_kit/templates/productized-skill/templates/tracker_comment.txt.tmpl +14 -0
  78. ticketwright/_kit/templates/roles/analyst.md +6 -0
  79. ticketwright/_kit/templates/roles/engineer.md +6 -0
  80. ticketwright/_kit/templates/roles/generalist.md +3 -0
  81. ticketwright/_kit/templates/roles/scientist.md +5 -0
  82. ticketwright/_kit/templates/spec.md.tmpl +34 -0
  83. ticketwright/_kit/templates/ticket-README.md.tmpl +29 -0
  84. ticketwright/cli.py +115 -0
  85. ticketwright-2.0.0.dist-info/METADATA +139 -0
  86. ticketwright-2.0.0.dist-info/RECORD +89 -0
  87. ticketwright-2.0.0.dist-info/WHEEL +4 -0
  88. ticketwright-2.0.0.dist-info/entry_points.txt +2 -0
  89. 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