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,149 @@
1
+ #!/usr/bin/env python3
2
+ """Upsert LLM-extracted records into tickets/index_data.json (the enrichment store).
3
+
4
+ `build_ticket_index.py` renders INDEX.md but never invents summaries — the LLM-authored
5
+ fields (summary / status / best-date / tags / cross_refs) live in tickets/index_data.json.
6
+ This helper takes records produced by the refresh skill (index mode) (or a single hand-written
7
+ record at ticket close), stamps each with the live README's content hash (so the renderer can
8
+ flag stale summaries), upserts by (owner, id), and writes the store.
9
+
10
+ Usage:
11
+ ingest_index_records.py --from-json records.json # bulk upsert from {"records":[...]} or [...]
12
+ ingest_index_records.py --from-json - # read JSON from stdin
13
+
14
+ After ingesting, run: python3 bin/build_ticket_index.py
15
+ Stdlib only.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import re
22
+ import sys
23
+ from datetime import date
24
+ from pathlib import Path
25
+
26
+ from build_ticket_index import discover, repo_root, sha256_file, ref_key, load_config, ticket_url
27
+
28
+ VALID_STATUS = {"Completed", "Deployed", "In Review", "In Progress", "Blocked", "Unknown"}
29
+
30
+
31
+ def _as_list(v) -> list:
32
+ """Normalize a possibly-string / possibly-missing value into a list of strings."""
33
+ if isinstance(v, str):
34
+ return [v]
35
+ if isinstance(v, (list, tuple)):
36
+ return [x for x in v if isinstance(x, str)]
37
+ return []
38
+
39
+
40
+ # --- validators: this is the single trust boundary for LLM-authored records ---
41
+ def _valid_date(v):
42
+ """Keep an ISO YYYY-MM-DD date that actually parses; drop anything else (e.g. an invented date)."""
43
+ s = (v or "").strip() if isinstance(v, str) else ""
44
+ try:
45
+ return date.fromisoformat(s).isoformat() if s else None
46
+ except ValueError:
47
+ return None
48
+
49
+
50
+ def _clean_objects(v) -> list:
51
+ """Qualified object names only (must contain a dot) — drops bare prose like 'the loan view'."""
52
+ seen: dict[str, str] = {}
53
+ for o in _as_list(v):
54
+ o = o.strip()
55
+ if "." in o:
56
+ seen.setdefault(o.lower(), o)
57
+ return sorted(seen.values(), key=str.lower)
58
+
59
+
60
+ def _clean_tags(v, cap: int = 6) -> list:
61
+ """Coerce to kebab-case, dedup (order-preserving), cap the count."""
62
+ out = []
63
+ for t in _as_list(v):
64
+ k = re.sub(r"[^a-z0-9]+", "-", t.strip().lower()).strip("-")
65
+ if k and k not in out:
66
+ out.append(k)
67
+ return out[:cap]
68
+
69
+
70
+ def load_records(spec: str) -> list[dict]:
71
+ text = sys.stdin.read() if spec == "-" else Path(spec).read_text()
72
+ try:
73
+ data = json.loads(text)
74
+ except json.JSONDecodeError as e:
75
+ sys.exit(f"ERROR: --from-json input is not valid JSON ({e}).")
76
+ if isinstance(data, dict):
77
+ data = data.get("records", [])
78
+ if not isinstance(data, list) or not all(isinstance(r, dict) for r in data):
79
+ sys.exit('ERROR: records must be a JSON array of objects (or {"records": [...]}).')
80
+ return data
81
+
82
+
83
+ def main() -> int:
84
+ ap = argparse.ArgumentParser(description="Upsert records into tickets/index_data.json")
85
+ ap.add_argument("--from-json", required=True, help="path to records JSON, or '-' for stdin")
86
+ args = ap.parse_args()
87
+
88
+ root = repo_root()
89
+ dirs = {(t["owner"], t["id"]): t for t in discover(root)}
90
+ url_template = load_config(root)["url_template"]
91
+
92
+ store_path = root / "tickets" / "index_data.json"
93
+ store: dict[tuple[str, str], dict] = {}
94
+ if store_path.is_file():
95
+ try:
96
+ sdata = json.loads(store_path.read_text())
97
+ except json.JSONDecodeError as e:
98
+ sys.exit(f"ERROR: existing tickets/index_data.json is malformed ({e}); refusing to overwrite.")
99
+ if not isinstance(sdata, dict) or not isinstance(sdata.get("tickets"), list):
100
+ sys.exit("ERROR: existing tickets/index_data.json must be an object with a 'tickets' list.")
101
+ store = {(t["owner"], t["id"]): t for t in sdata["tickets"]
102
+ if isinstance(t, dict) and isinstance(t.get("owner"), str) and isinstance(t.get("id"), str)}
103
+
104
+ incoming = load_records(args.from_json)
105
+ upserted, skipped = 0, []
106
+ for r in incoming:
107
+ owner, tid = r.get("owner"), r.get("id")
108
+ loc = dirs.get((owner, tid))
109
+ if not loc:
110
+ skipped.append(f"{owner or '?'}/{tid or '?'}")
111
+ continue
112
+ owner, tid = str(owner), str(tid)
113
+ readme = loc["readme"]
114
+ status = r.get("status") if r.get("status") in VALID_STATUS else "Unknown"
115
+ refs = sorted({c for c in _as_list(r.get("cross_refs")) if c != tid}, key=ref_key)
116
+ rec = {
117
+ "id": tid,
118
+ "owner": owner,
119
+ "title": (r.get("title") or "").strip(),
120
+ "status": status,
121
+ "date": _valid_date(r.get("date")),
122
+ "cross_refs": refs,
123
+ "tags": _clean_tags(r.get("tags")),
124
+ "objects": _clean_objects(r.get("objects")),
125
+ "summary": (r.get("summary") or "").strip(),
126
+ "confidence": r.get("confidence") or "medium",
127
+ "readme_present": bool(readme),
128
+ "readme_hash": sha256_file(readme) if readme else None,
129
+ }
130
+ url = r.get("ticket_url") or r.get("jira_url") or ticket_url(url_template, tid)
131
+ if url:
132
+ rec["ticket_url"] = url
133
+ store[(owner, tid)] = rec
134
+ upserted += 1
135
+
136
+ tickets = sorted(store.values(),
137
+ key=lambda t: ((t.get("date") or "0000-00-00"), ref_key(t["id"]), t["owner"]),
138
+ reverse=True)
139
+ payload = json.dumps({"schema_version": 1, "tickets": tickets}, indent=2, ensure_ascii=False) + "\n"
140
+ store_path.parent.mkdir(parents=True, exist_ok=True)
141
+ store_path.write_bytes(payload.encode("utf-8"))
142
+ print(f"Upserted {upserted} record(s); store now has {len(tickets)}.", file=sys.stderr)
143
+ if skipped:
144
+ print(f"Skipped {len(skipped)} (no matching folder): {', '.join(skipped)}", file=sys.stderr)
145
+ return 0
146
+
147
+
148
+ if __name__ == "__main__":
149
+ raise SystemExit(main())
@@ -0,0 +1,268 @@
1
+ #!/usr/bin/env python3
2
+ """Prior-art recall — rank prior tickets most relevant to a new one, for reuse in the PLAN phase.
3
+
4
+ Deterministic, lexical, stdlib-only (NO embeddings/vector DB — see the kit's KISS stance). Scores
5
+ every ticket (from tickets/index_data.json, falling back to README-derived fields for un-enriched
6
+ ones) against a query built from a seed ticket and/or free text / tags / object, with a transparent
7
+ score breakdown. The `/recall` command runs this, then reads the top hits and writes a reuse brief.
8
+
9
+ Usage:
10
+ recall.py --for ENG-12 [--top 5] [--min-score N] [--json] # query = that ticket's fields
11
+ recall.py --query "genesys call metrics" [--tags a,b] [--object SCHEMA.VW] [--top 5] [--json]
12
+ recall.py --object BI.ANALYTICS.VW_LOAN # reverse lookup: tickets that touched an object
13
+ recall.py --eval [--sweep] # diagnostic: recall quality vs curated cross_refs
14
+
15
+ Scoring (transparent): object match ×4 (IDF-discounted — a ubiquitous object counts less than a rare
16
+ shared one), tag match ×3, cross-ref link +5, keyword overlap ×1 (capped). Recency is a tiebreak only.
17
+ Stdlib only.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import math
24
+ import re
25
+ import sys
26
+
27
+ from build_ticket_index import repo_root, build_rows, ticket_number
28
+
29
+ STOPWORDS = {
30
+ "the", "a", "an", "and", "or", "for", "of", "to", "in", "on", "by", "with", "from", "into",
31
+ "is", "are", "be", "as", "at", "this", "that", "it", "via", "per", "vs", "new", "add", "update",
32
+ "fix", "ticket", "data", "report", "list", "file", "files", "table", "view",
33
+ }
34
+ W_OBJECT, W_TAG, W_REF, W_KEYWORD, KEYWORD_CAP = 4, 3, 5, 1, 6
35
+ OBJ_IDF_FLOOR = 0.4 # ubiquitous object still counts, but < a rare shared one. 0.4 tuned via --eval:
36
+ # strict Pareto gain on a 139-ticket corpus (MRR .550->.571, P@1 .408->.421, P@3 .618->.671, rec@5 .462->.494)
37
+ DEFAULT_WEIGHTS = {"obj": W_OBJECT, "tag": W_TAG, "ref": W_REF, "kw": W_KEYWORD}
38
+
39
+
40
+ def tokenize(text: str) -> set[str]:
41
+ return {t for t in re.split(r"[^a-z0-9]+", (text or "").lower())
42
+ if len(t) >= 3 and t not in STOPWORDS}
43
+
44
+
45
+ def ci(values) -> set[str]:
46
+ return {str(v).strip().lower() for v in (values or []) if str(v).strip()}
47
+
48
+
49
+ def leaf(name: str) -> str:
50
+ """Trailing object name, stripped of schema/db qualification: `bi.analytics.vw_loan` -> `vw_loan`."""
51
+ return name.rsplit(".", 1)[-1]
52
+
53
+
54
+ def object_hits(q_objects: set[str], r_objects: set[str]) -> list[str]:
55
+ """Row objects that match a query object, leaf-aware so `vw_loan` finds `bi.analytics.vw_loan`."""
56
+ q_leaves = {leaf(q) for q in q_objects}
57
+ return sorted(o for o in r_objects if o in q_objects or leaf(o) in q_leaves)
58
+
59
+
60
+ def object_df(rows) -> dict:
61
+ """Leaf-granularity document frequency: how many tickets reference each object leaf."""
62
+ df: dict[str, int] = {}
63
+ for r in rows:
64
+ for lf in {leaf(o) for o in ci(r.get("objects"))}:
65
+ df[lf] = df.get(lf, 0) + 1
66
+ return df
67
+
68
+
69
+ def idf_factor(df_map: dict, n_docs: int, name: str) -> float:
70
+ """Discount in (FLOOR, 1.0]: the rarest object -> 1.0, a ubiquitous one -> ~FLOOR. Never a bonus,
71
+ so flat W_OBJECT stays the ceiling. Honors the kit's transparent-weights stance — no learned vector."""
72
+ if not df_map or n_docs <= 0:
73
+ return 1.0
74
+ denom = math.log(n_docs / (1 + min(df_map.values())))
75
+ if denom <= 0:
76
+ return 1.0
77
+ f = math.log(n_docs / (1 + df_map.get(leaf(name), 1))) / denom
78
+ return max(OBJ_IDF_FLOOR, min(1.0, f))
79
+
80
+
81
+ def score_candidate(q_tokens, q_tags, q_objects, seed_id, seed_refs, r, weights, allow_ref, df_map, n_docs):
82
+ """One candidate's score + breakdown. `allow_ref=False` disables the cross-ref signal (used by --eval,
83
+ where cross_refs are the ground-truth labels and must not leak into scoring)."""
84
+ r_tags, r_objects = ci(r.get("tags")), ci(r.get("objects"))
85
+ r_tokens = tokenize(f"{r['title']} {r['summary']}") # tags score via tag_hits only, not also as keywords
86
+ obj_hits = object_hits(q_objects, r_objects)
87
+ tag_hits = sorted(q_tags & r_tags)
88
+ kw_hits = q_tokens & r_tokens
89
+ ref_link = bool(allow_ref and seed_id and (r["id"].lower() in seed_refs or seed_id.lower() in ci(r.get("cross_refs"))))
90
+ obj_score = sum(weights["obj"] * idf_factor(df_map, n_docs, o) for o in obj_hits)
91
+ score = (obj_score + weights["tag"] * len(tag_hits)
92
+ + weights["ref"] * (1 if ref_link else 0) + weights["kw"] * min(len(kw_hits), KEYWORD_CAP))
93
+ return score, obj_hits, tag_hits, kw_hits, ref_link
94
+
95
+
96
+ def sort_key(item):
97
+ # self-contained total order: score, then date, ticket number, id, owner — all desc.
98
+ s, r, _why = item
99
+ return (s, r["date"] or "0000-00-00", ticket_number(r["id"]), r["id"], r["owner"])
100
+
101
+
102
+ def verdict_line(top) -> str:
103
+ """Advisory one-liner so PLAN can decide whether to read candidates. Scale-free (composition + gap),
104
+ never an auto-skip."""
105
+ if not top:
106
+ return "verdict: no prior art found — likely greenfield."
107
+ s0, _r0, why0 = top[0]
108
+ s1 = top[1][0] if len(top) > 1 else 0.0
109
+ strong = any(w.startswith("obj:") or w == "ref" for w in why0)
110
+ kw_only = bool(why0) and all(w.startswith("kw:") for w in why0)
111
+ if strong:
112
+ v = "strong — top hit shares an object or cross-ref"
113
+ elif s1 and s0 >= 2 * s1:
114
+ v = "clear leader — top score dominates the runner-up"
115
+ elif kw_only:
116
+ v = "weak — top hit is keyword-only; may be greenfield"
117
+ else:
118
+ v = "moderate"
119
+ return f"verdict: {v}. (You may skip reading candidates when weak/none.)"
120
+
121
+
122
+ def run_query(args, rows, df_map, n_docs) -> int:
123
+ q_tokens, q_tags, q_objects = tokenize(args.query), ci(args.tags.split(",")), ci([args.object] if args.object else [])
124
+ seed_id, seed_owner, seed_refs = None, None, set()
125
+ if args.seed:
126
+ sid, sown = args.seed.lower(), (args.owner or "").lower() # case-insensitive id/owner match
127
+ seed_matches = [r for r in rows if r["id"].lower() == sid and (not sown or r["owner"].lower() == sown)]
128
+ if not seed_matches:
129
+ sys.exit(f"recall: seed ticket {args.seed} not found (try --owner to disambiguate).")
130
+ if len(seed_matches) > 1: # same id under >1 owner and no --owner — don't silently pick one
131
+ owners = ", ".join(sorted(r["owner"] for r in seed_matches))
132
+ sys.exit(f"recall: {args.seed} exists under multiple owners ({owners}); pass --owner to pick one.")
133
+ seed = seed_matches[0]
134
+ seed_id, seed_owner = seed["id"], seed["owner"]
135
+ seed_refs = ci(seed.get("cross_refs"))
136
+ q_tokens |= tokenize(f"{seed['title']} {seed['summary']}")
137
+ q_tags |= ci(seed.get("tags"))
138
+ q_objects |= ci(seed.get("objects"))
139
+
140
+ reverse_only = bool(args.object) and not (args.seed or args.query or args.tags)
141
+
142
+ scored = []
143
+ for r in rows:
144
+ if seed_id and r["id"] == seed_id and r["owner"] == seed_owner:
145
+ continue # exclude only the chosen seed row (same id under another owner is a real candidate)
146
+ score, obj_hits, tag_hits, kw_hits, ref_link = score_candidate(
147
+ q_tokens, q_tags, q_objects, seed_id, seed_refs, r, DEFAULT_WEIGHTS, allow_ref=True,
148
+ df_map=df_map, n_docs=n_docs)
149
+ if reverse_only:
150
+ if not obj_hits:
151
+ continue
152
+ score = float(W_OBJECT * len(obj_hits)) # reverse lookup is flat count — IDF would be constant here
153
+ elif score <= 0:
154
+ continue
155
+ why = []
156
+ if obj_hits: why.append("obj:" + ",".join(f"{o}~{idf_factor(df_map, n_docs, o):.2f}" for o in obj_hits))
157
+ if tag_hits: why.append("tag:" + ",".join(tag_hits))
158
+ if ref_link: why.append("ref")
159
+ if kw_hits and not reverse_only: why.append(f"kw:{min(len(kw_hits), KEYWORD_CAP)}")
160
+ scored.append((score, r, why))
161
+
162
+ scored.sort(key=sort_key, reverse=True)
163
+ if args.min_score:
164
+ scored = [x for x in scored if x[0] >= args.min_score]
165
+ top = scored[: args.top]
166
+
167
+ if args.json:
168
+ print(json.dumps([
169
+ {"id": r["id"], "owner": r["owner"], "score": round(s, 2), "why": why, "title": r["title"],
170
+ "summary": r["summary"], "date": r["date"], "tags": r.get("tags"), "objects": r.get("objects"),
171
+ "readme": r["link"], "url": r["url"]}
172
+ for s, r, why in top], ensure_ascii=False, indent=2))
173
+ return 0
174
+
175
+ header = (f"Reverse lookup: tickets touching {args.object}" if reverse_only
176
+ else "Prior art" + (f" for {seed_id}" if seed_id else "")) + f" — top {len(top)} of {len(scored)}"
177
+ print(header)
178
+ if not top:
179
+ print(" (no matches)")
180
+ if not reverse_only:
181
+ print(verdict_line(top))
182
+ return 0
183
+ for s, r, why in top:
184
+ print(f" {r['id']:<10} score {s:>5.1f} [{' '.join(why)}] {r['date'] or '—'} {r['title'][:60]}")
185
+ print(f" {r['link']}")
186
+ if not reverse_only:
187
+ print(verdict_line(top))
188
+ print("\nRead these READMEs and write a reuse brief: what to copy (which SQL/QC artifact + path),"
189
+ " known gotchas, and what's different this time.")
190
+ return 0
191
+
192
+
193
+ def run_eval(rows, df_map, n_docs, weights, top_k=5) -> dict:
194
+ """Hold out each ticket's curated cross_refs and measure whether recall predicts them. The cross-ref
195
+ signal is DISABLED (allow_ref=False) so the labels can't leak into scoring."""
196
+ ids_present = {r["id"].lower() for r in rows}
197
+ rr_sum = p1 = p3 = rec_sum = labeled = 0
198
+ for seed in rows:
199
+ relevant = {x for x in ci(seed.get("cross_refs")) if x in ids_present and x != seed["id"].lower()}
200
+ if not relevant:
201
+ continue
202
+ labeled += 1
203
+ q_tokens = tokenize(f"{seed['title']} {seed['summary']}")
204
+ q_tags, q_objects = ci(seed.get("tags")), ci(seed.get("objects"))
205
+ ranked = []
206
+ for r in rows:
207
+ if r["id"] == seed["id"] and r["owner"] == seed["owner"]:
208
+ continue
209
+ score, *_ = score_candidate(q_tokens, q_tags, q_objects, None, set(), r, weights,
210
+ allow_ref=False, df_map=df_map, n_docs=n_docs)
211
+ ranked.append((score, r, []))
212
+ ranked.sort(key=sort_key, reverse=True)
213
+ ordered = [r["id"].lower() for _s, r, _w in ranked]
214
+ first = next((i + 1 for i, x in enumerate(ordered) if x in relevant), None)
215
+ if first:
216
+ rr_sum += 1.0 / first
217
+ top_ids = ordered[:top_k]
218
+ if top_ids and top_ids[0] in relevant:
219
+ p1 += 1
220
+ if any(x in relevant for x in top_ids[:3]):
221
+ p3 += 1
222
+ rec_sum += len({x for x in top_ids if x in relevant}) / len(relevant)
223
+ if not labeled:
224
+ return {"labeled": 0}
225
+ return {"labeled": labeled, "mrr": rr_sum / labeled, "p_at_1": p1 / labeled,
226
+ "p_at_3": p3 / labeled, "recall_at_5": rec_sum / labeled}
227
+
228
+
229
+ def main() -> int:
230
+ ap = argparse.ArgumentParser(description="Rank prior tickets relevant to a new one (prior-art recall)")
231
+ ap.add_argument("--for", dest="seed", help="seed ticket id (use its title/summary/tags/objects as the query)")
232
+ ap.add_argument("--owner", help="disambiguate the seed when an id exists under multiple owners")
233
+ ap.add_argument("--query", default="", help="free-text query")
234
+ ap.add_argument("--tags", default="", help="comma-separated tags to match")
235
+ ap.add_argument("--object", dest="object", default="", help="object name (also enables reverse lookup)")
236
+ ap.add_argument("--top", type=int, default=5)
237
+ ap.add_argument("--min-score", dest="min_score", type=float, default=0.0,
238
+ help="hide candidates scoring below this (manual filter; not a default cutoff)")
239
+ ap.add_argument("--json", action="store_true")
240
+ ap.add_argument("--eval", action="store_true",
241
+ help="diagnostic: measure recall quality against curated cross_refs (read-only, never tunes)")
242
+ ap.add_argument("--sweep", action="store_true", help="with --eval: print MRR under several weight settings")
243
+ args = ap.parse_args()
244
+
245
+ rows = build_rows(repo_root())
246
+ df_map, n_docs = object_df(rows), len(rows)
247
+
248
+ if args.eval:
249
+ base = run_eval(rows, df_map, n_docs, DEFAULT_WEIGHTS)
250
+ if not base.get("labeled"):
251
+ print("recall --eval: no tickets have cross_refs resolving in-corpus — nothing to score.")
252
+ return 0
253
+ print(f"Recall quality vs curated cross_refs (cross-ref signal held out; {base['labeled']} labeled seeds):")
254
+ print(f" MRR={base['mrr']:.3f} P@1={base['p_at_1']:.3f} P@3={base['p_at_3']:.3f} recall@5={base['recall_at_5']:.3f}")
255
+ if args.sweep:
256
+ print("Weight sensitivity (obj,tag,ref,kw) — diagnostic only, NOT auto-applied:")
257
+ for w in ({"obj": 4, "tag": 3, "ref": 5, "kw": 1}, {"obj": 4, "tag": 1, "ref": 5, "kw": 1},
258
+ {"obj": 4, "tag": 5, "ref": 5, "kw": 1}, {"obj": 4, "tag": 3, "ref": 5, "kw": 2},
259
+ {"obj": 0, "tag": 3, "ref": 5, "kw": 1}, {"obj": 4, "tag": 0, "ref": 5, "kw": 0}):
260
+ m = run_eval(rows, df_map, n_docs, w)
261
+ print(f" ({w['obj']},{w['tag']},{w['ref']},{w['kw']}) -> MRR {m['mrr']:.3f}")
262
+ return 0
263
+
264
+ return run_query(args, rows, df_map, n_docs)
265
+
266
+
267
+ if __name__ == "__main__":
268
+ raise SystemExit(main())
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env bash
2
+ # render.sh — tiny {{token}} renderer. No deps beyond coreutils + perl (preinstalled on macOS/Linux).
3
+ #
4
+ # Usage:
5
+ # bin/render.sh TEMPLATE KEY=VALUE [KEY2=VALUE2 ...] # vars on the command line
6
+ # bin/render.sh TEMPLATE --vars FILE # vars from a KEY=VALUE file
7
+ # bin/render.sh TEMPLATE ... --strict # exit 2 if any {{token}} is left unresolved
8
+ #
9
+ # Writes the rendered text to stdout. Unresolved {{tokens}} are always reported to stderr.
10
+ set -euo pipefail
11
+
12
+ if [[ $# -lt 1 ]]; then
13
+ echo "usage: $0 TEMPLATE [KEY=VALUE ... | --vars FILE] [--strict]" >&2
14
+ exit 1
15
+ fi
16
+
17
+ template="$1"; shift
18
+ [[ -f "$template" ]] || { echo "render: template not found: $template" >&2; exit 1; }
19
+
20
+ strict=0
21
+ declare -a pairs=()
22
+ while [[ $# -gt 0 ]]; do
23
+ case "$1" in
24
+ --strict) strict=1; shift ;;
25
+ --vars) shift; [[ -f "${1:-}" ]] || { echo "render: vars file not found: ${1:-}" >&2; exit 1; }
26
+ while IFS= read -r line; do
27
+ [[ "$line" =~ ^[[:space:]]*# ]] && continue
28
+ [[ "$line" == *"="* ]] && pairs+=("$line")
29
+ done < "$1"; shift ;;
30
+ *=*) pairs+=("$1"); shift ;;
31
+ *) echo "render: unrecognized arg: $1" >&2; exit 1 ;;
32
+ esac
33
+ done
34
+
35
+ content="$(cat "$template")"
36
+ # Guard the empty-array case: on bash 3.2 under `set -u`, "${pairs[@]}" with zero elements aborts.
37
+ if [[ ${#pairs[@]} -gt 0 ]]; then
38
+ for pair in "${pairs[@]}"; do
39
+ key="${pair%%=*}"
40
+ val="${pair#*=}"
41
+ # Replace every {{key}} with val. perl handles arbitrary value chars safely via env passing.
42
+ content="$(KEY="$key" VAL="$val" perl -pe 's/\{\{\Q$ENV{KEY}\E\}\}/$ENV{VAL}/g' <<< "$content")"
43
+ done
44
+ fi
45
+
46
+ printf '%s\n' "$content"
47
+
48
+ # Report anything still unresolved.
49
+ leftover="$(grep -oE '\{\{[^}]+\}\}' <<< "$content" | sort -u || true)"
50
+ if [[ -n "$leftover" ]]; then
51
+ echo "render: WARNING unresolved tokens:" >&2
52
+ echo "$leftover" >&2
53
+ if [[ "$strict" -eq 1 ]]; then exit 2; fi # non-strict: warn only, exit 0 (don't fail callers)
54
+ fi
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env bash
2
+ # render_and_validate.sh — render a SQL template AND gate the result. Wraps bin/render.sh so the
3
+ # token substitution stays single-sourced; adds the two authoring rules that bit a productized pull
4
+ # in the wild, plus a cheap structural check on the rendered SQL:
5
+ #
6
+ # 1. No {{token}} inside a SQL comment (ERROR). The renderer expands tokens everywhere — including
7
+ # inside a `--` comment. A multi-line value (e.g. a 75-row VALUES list) then spills past the
8
+ # `--`; the continuation rows are bare SQL → compile error. Describe params in prose, not tokens.
9
+ # 2. A token used as a SQL string/date literal must be QUOTED in the template (WARN; ERROR under
10
+ # --strict). `SET d = {{asof}};` renders to `= 2026-06-30`, which the warehouse reads as
11
+ # arithmetic (=1990), not a date — silent wrong results. Write `'{{asof}}'` in the template.
12
+ #
13
+ # Then it validates the rendered SQL: zero {{tokens}} remain, balanced single-quotes, balanced parens.
14
+ #
15
+ # Usage (same surface as render.sh):
16
+ # bin/render_and_validate.sh STEP.sql.tmpl KEY=VALUE [KEY2=VALUE2 ...] [--strict]
17
+ # bin/render_and_validate.sh STEP.sql.tmpl --vars FILE [--strict]
18
+ #
19
+ # Rendered SQL → stdout. Findings → stderr. Exit 1 on any ERROR (WARN also fails under --strict).
20
+ # This is for SQL step/QC templates; use plain render.sh for prose templates (README, tracker comment).
21
+ set -uo pipefail
22
+
23
+ here="$(cd "$(dirname "$0")" && pwd)"
24
+ render="$here/render.sh"
25
+
26
+ if [[ $# -lt 1 ]]; then
27
+ echo "usage: $0 TEMPLATE [KEY=VALUE ... | --vars FILE] [--strict]" >&2
28
+ exit 1
29
+ fi
30
+ template="$1"; shift
31
+ [[ -f "$template" ]] || { echo "render_and_validate: template not found: $template" >&2; exit 1; }
32
+ [[ -f "$render" ]] || { echo "render_and_validate: render.sh not found beside this script" >&2; exit 1; }
33
+
34
+ strict=0
35
+ declare -a fwd=() # everything except --strict is forwarded to render.sh verbatim
36
+ while [[ $# -gt 0 ]]; do
37
+ case "$1" in
38
+ --strict) strict=1; shift ;;
39
+ *) fwd+=("$1"); shift ;;
40
+ esac
41
+ done
42
+
43
+ TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
44
+ # Substitute via the canonical renderer (non-strict: the validator below reports leftover tokens
45
+ # with full context). Guard the empty-array case for bash 3.2 under `set -u`.
46
+ if [[ ${#fwd[@]} -gt 0 ]]; then
47
+ bash "$render" "$template" "${fwd[@]}" > "$TMP/rendered" 2>/dev/null
48
+ else
49
+ bash "$render" "$template" > "$TMP/rendered" 2>/dev/null
50
+ fi
51
+
52
+ python3 - "$template" "$TMP/rendered" "$strict" <<'PY'
53
+ import re, sys
54
+ template_path, rendered_path, strict = sys.argv[1], sys.argv[2], sys.argv[3] == "1"
55
+ errs, warns = [], []
56
+ TOKEN = re.compile(r"\{\{[^{}]+\}\}")
57
+ LITERAL = re.compile(r"(<=|>=|<>|!=|=|<|>)[ \t]*\{\{\s*[\w.-]+\s*\}\}")
58
+
59
+ def classify(text):
60
+ """Tag each char: 'c' code, 's' single-quoted string (''-escapes honored), 'm' comment
61
+ (-- to EOL and /* */ blocks). Returns (tags, ended_inside_string)."""
62
+ tags = []
63
+ in_str = in_line = in_block = False
64
+ i, n = 0, len(text)
65
+ while i < n:
66
+ c, two = text[i], text[i:i+2]
67
+ if in_line:
68
+ tags.append('m')
69
+ if c == '\n':
70
+ in_line = False
71
+ i += 1; continue
72
+ if in_block:
73
+ if two == '*/':
74
+ tags += ['m', 'm']; i += 2; in_block = False; continue
75
+ tags.append('m'); i += 1; continue
76
+ if in_str:
77
+ if c == "'":
78
+ if text[i+1:i+2] == "'": # '' is an escaped quote, stay in string
79
+ tags += ['s', 's']; i += 2; continue
80
+ tags.append('s'); in_str = False; i += 1; continue
81
+ tags.append('s'); i += 1; continue
82
+ if two == '--':
83
+ tags += ['m', 'm']; i += 2; in_line = True; continue
84
+ if two == '/*':
85
+ tags += ['m', 'm']; i += 2; in_block = True; continue
86
+ if c == "'":
87
+ tags.append('s'); in_str = True; i += 1; continue
88
+ tags.append('c'); i += 1; continue
89
+ return ''.join(tags), in_str
90
+
91
+ # ---- pre-render lint on the TEMPLATE ----
92
+ tpl = open(template_path, encoding="utf-8").read()
93
+ ttags, _ = classify(tpl)
94
+ code_only = ''.join(ch if ttags[i] == 'c' else ' ' for i, ch in enumerate(tpl))
95
+ lineno = lambda pos: tpl.count('\n', 0, pos) + 1
96
+ for m in TOKEN.finditer(tpl):
97
+ if all(ttags[j] == 'm' for j in range(m.start(), m.end())): # rule 1: token in a comment
98
+ ln = lineno(m.start())
99
+ errs.append(f"L{ln}: {m.group(0)} inside a SQL comment — describe the param in prose; the "
100
+ f"renderer expands it and a multi-line value breaks out of the `--`.")
101
+ for m in LITERAL.finditer(code_only): # rule 2: unquoted SQL literal
102
+ ln = lineno(m.start())
103
+ warns.append(f"L{ln}: '{m.group(0).strip()}' — token used as an unquoted SQL literal; quote it "
104
+ f"in the template ('{{{{token}}}}') if it is a date/string, else it reads as arithmetic.")
105
+
106
+ # ---- post-render validation on the RENDERED output ----
107
+ out = open(rendered_path, encoding="utf-8").read()
108
+ left = sorted(set(t.group(0) for t in TOKEN.finditer(out)))
109
+ if left:
110
+ errs.append("unresolved token(s) remain after render: " + " ".join(left))
111
+ otags, open_str = classify(out)
112
+ if open_str:
113
+ errs.append("unbalanced single-quote in rendered SQL (a string literal never closes) — likely an "
114
+ "unquoted token or a stray quote.")
115
+ depth = lo = 0
116
+ for i, ch in enumerate(out):
117
+ if otags[i] != 'c':
118
+ continue
119
+ if ch == '(':
120
+ depth += 1
121
+ elif ch == ')':
122
+ depth -= 1
123
+ lo = min(lo, depth)
124
+ if depth != 0 or lo < 0:
125
+ errs.append(f"unbalanced parentheses in rendered SQL (net depth {depth}) — a token may have "
126
+ "expanded into a comment or broken a clause.")
127
+
128
+ for w in warns: print(" ⚠ WARN " + w, file=sys.stderr)
129
+ for e in errs: print(" ✗ ERROR " + e, file=sys.stderr)
130
+ fail = bool(errs) or (strict and bool(warns))
131
+ if errs or warns:
132
+ print(f"render_and_validate: {len(errs)} error(s), {len(warns)} warning(s)"
133
+ + ("" if not (strict and warns and not errs) else " (warnings fail under --strict)"),
134
+ file=sys.stderr)
135
+ sys.exit(1 if fail else 0)
136
+ PY
137
+ rc=$?
138
+ cat "$TMP/rendered"
139
+ exit "$rc"