ticketwright 3.2.0__tar.gz → 3.3.0__tar.gz

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 (84) hide show
  1. ticketwright-3.3.0/.claude/config/stack.example.multi-warehouse.yaml +104 -0
  2. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/config/stack.schema.md +54 -4
  3. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/config/stack.yaml +1 -0
  4. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/hooks/db_write_guard.py +85 -5
  5. ticketwright-3.3.0/.claude/hooks/session_context.py +179 -0
  6. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/hooks/ticket_index_context.py +2 -0
  7. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/settings.json.tmpl +1 -1
  8. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/skills/refresh/index.md +10 -2
  9. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/skills/setup/SKILL.md +11 -5
  10. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/skills/setup/adopt.md +4 -2
  11. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/skills/setup/scaffold.md +9 -2
  12. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/skills/ship/SKILL.md +14 -8
  13. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/skills/spec-and-build/SKILL.md +3 -3
  14. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/statusline.sh +13 -1
  15. {ticketwright-3.2.0 → ticketwright-3.3.0}/CHANGELOG.md +117 -0
  16. {ticketwright-3.2.0 → ticketwright-3.3.0}/PKG-INFO +49 -12
  17. {ticketwright-3.2.0 → ticketwright-3.3.0}/README.md +48 -11
  18. {ticketwright-3.2.0 → ticketwright-3.3.0}/adapters/warehouse/bigquery.md +3 -2
  19. {ticketwright-3.2.0 → ticketwright-3.3.0}/adapters/warehouse/databricks.md +3 -2
  20. {ticketwright-3.2.0 → ticketwright-3.3.0}/adapters/warehouse/postgres.md +3 -2
  21. {ticketwright-3.2.0 → ticketwright-3.3.0}/adapters/warehouse/redshift.md +3 -2
  22. {ticketwright-3.2.0 → ticketwright-3.3.0}/adapters/warehouse/snowflake.md +3 -2
  23. {ticketwright-3.2.0 → ticketwright-3.3.0}/adapters/warehouse/synapse.md +3 -2
  24. {ticketwright-3.2.0 → ticketwright-3.3.0}/bin/build_ticket_index.py +186 -10
  25. ticketwright-3.3.0/bin/bump_version.sh +99 -0
  26. {ticketwright-3.2.0 → ticketwright-3.3.0}/bin/recall.py +1 -1
  27. {ticketwright-3.2.0 → ticketwright-3.3.0}/bin/selftest.sh +354 -4
  28. ticketwright-3.3.0/bin/verify_stack.sh +146 -0
  29. {ticketwright-3.2.0 → ticketwright-3.3.0}/pyproject.toml +8 -1
  30. {ticketwright-3.2.0 → ticketwright-3.3.0}/templates/AGENTS.md.tmpl +11 -0
  31. {ticketwright-3.2.0 → ticketwright-3.3.0}/templates/gitignore.tmpl +8 -0
  32. {ticketwright-3.2.0 → ticketwright-3.3.0}/ticketwright/__init__.py +1 -1
  33. {ticketwright-3.2.0 → ticketwright-3.3.0}/ticketwright/cli.py +4 -3
  34. ticketwright-3.2.0/.claude/hooks/session_context.py +0 -76
  35. ticketwright-3.2.0/bin/verify_stack.sh +0 -83
  36. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/agents/qc-reviewer.md +0 -0
  37. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/commands/.gitkeep +0 -0
  38. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/config/stack.example.asana-bq.yaml +0 -0
  39. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/config/stack.example.azure.yaml +0 -0
  40. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/hooks/regenerate_ticket_index.py +0 -0
  41. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/skills/productize/SKILL.md +0 -0
  42. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/skills/productize/authoring.md +0 -0
  43. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/skills/refresh/SKILL.md +0 -0
  44. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/skills/refresh/context-pack.md +0 -0
  45. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/skills/review/SKILL.md +0 -0
  46. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/skills/setup/teammate.md +0 -0
  47. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/skills/ticket/SKILL.md +0 -0
  48. {ticketwright-3.2.0 → ticketwright-3.3.0}/.claude/skills/ticket/priming.md +0 -0
  49. {ticketwright-3.2.0 → ticketwright-3.3.0}/.gitignore +0 -0
  50. {ticketwright-3.2.0 → ticketwright-3.3.0}/LICENSE +0 -0
  51. {ticketwright-3.2.0 → ticketwright-3.3.0}/adapters/README.md +0 -0
  52. {ticketwright-3.2.0 → ticketwright-3.3.0}/adapters/chat/slack.md +0 -0
  53. {ticketwright-3.2.0 → ticketwright-3.3.0}/adapters/chat/teams.md +0 -0
  54. {ticketwright-3.2.0 → ticketwright-3.3.0}/adapters/docstore/gdrive.md +0 -0
  55. {ticketwright-3.2.0 → ticketwright-3.3.0}/adapters/docstore/sharepoint.md +0 -0
  56. {ticketwright-3.2.0 → ticketwright-3.3.0}/adapters/tracker/asana.md +0 -0
  57. {ticketwright-3.2.0 → ticketwright-3.3.0}/adapters/tracker/azure-devops.md +0 -0
  58. {ticketwright-3.2.0 → ticketwright-3.3.0}/adapters/tracker/github-issues.md +0 -0
  59. {ticketwright-3.2.0 → ticketwright-3.3.0}/adapters/tracker/jira.md +0 -0
  60. {ticketwright-3.2.0 → ticketwright-3.3.0}/adapters/tracker/linear.md +0 -0
  61. {ticketwright-3.2.0 → ticketwright-3.3.0}/adapters/tracker/monday.md +0 -0
  62. {ticketwright-3.2.0 → ticketwright-3.3.0}/adapters/vcs/azure-repos.md +0 -0
  63. {ticketwright-3.2.0 → ticketwright-3.3.0}/adapters/vcs/github.md +0 -0
  64. {ticketwright-3.2.0 → ticketwright-3.3.0}/adapters/vcs/gitlab.md +0 -0
  65. {ticketwright-3.2.0 → ticketwright-3.3.0}/bin/enrich_ticket.py +0 -0
  66. {ticketwright-3.2.0 → ticketwright-3.3.0}/bin/ingest_index_records.py +0 -0
  67. {ticketwright-3.2.0 → ticketwright-3.3.0}/bin/render.sh +0 -0
  68. {ticketwright-3.2.0 → ticketwright-3.3.0}/bin/render_and_validate.sh +0 -0
  69. {ticketwright-3.2.0 → ticketwright-3.3.0}/bin/split_and_export.sh +0 -0
  70. {ticketwright-3.2.0 → ticketwright-3.3.0}/templates/CLAUDE.md.tmpl +0 -0
  71. {ticketwright-3.2.0 → ticketwright-3.3.0}/templates/plan.md.tmpl +0 -0
  72. {ticketwright-3.2.0 → ticketwright-3.3.0}/templates/productized-skill/SKILL.md.tmpl +0 -0
  73. {ticketwright-3.2.0 → ticketwright-3.3.0}/templates/productized-skill/bin/drift_check.sh +0 -0
  74. {ticketwright-3.2.0 → ticketwright-3.3.0}/templates/productized-skill/golden/example.json +0 -0
  75. {ticketwright-3.2.0 → ticketwright-3.3.0}/templates/productized-skill/sql/qc.sql.tmpl +0 -0
  76. {ticketwright-3.2.0 → ticketwright-3.3.0}/templates/productized-skill/sql/step.sql.tmpl +0 -0
  77. {ticketwright-3.2.0 → ticketwright-3.3.0}/templates/productized-skill/templates/README.md.tmpl +0 -0
  78. {ticketwright-3.2.0 → ticketwright-3.3.0}/templates/productized-skill/templates/tracker_comment.txt.tmpl +0 -0
  79. {ticketwright-3.2.0 → ticketwright-3.3.0}/templates/roles/analyst.md +0 -0
  80. {ticketwright-3.2.0 → ticketwright-3.3.0}/templates/roles/engineer.md +0 -0
  81. {ticketwright-3.2.0 → ticketwright-3.3.0}/templates/roles/generalist.md +0 -0
  82. {ticketwright-3.2.0 → ticketwright-3.3.0}/templates/roles/scientist.md +0 -0
  83. {ticketwright-3.2.0 → ticketwright-3.3.0}/templates/spec.md.tmpl +0 -0
  84. {ticketwright-3.2.0 → ticketwright-3.3.0}/templates/ticket-README.md.tmpl +0 -0
@@ -0,0 +1,104 @@
1
+ # stack.example.multi-warehouse.yaml — a team that must reach TWO warehouses.
2
+ #
3
+ # Jira / Snowflake + Databricks / Slack / Google Drive / GitHub. Everything except the warehouse
4
+ # seam is identical to stack.yaml; this file exists to prove one claim — one warehouse seam can
5
+ # hold several named targets, and every target is independently reachable.
6
+ #
7
+ # Skills resolve which target to use per `adapters/README.md` § Multi-target seams; a `.sql` file
8
+ # names its own target in a `-- warehouse-target: <name>` header comment.
9
+ # Field docs: stack.schema.md · Validate: bash bin/verify_stack.sh .claude/config/stack.example.multi-warehouse.yaml
10
+
11
+ project:
12
+ key_prefix: ENG
13
+ key_prefixes: [ENG]
14
+ assignee_dir: alice
15
+ ticket_path: "tickets/{assignee}/{id}"
16
+ ticket_subdirs: [source_materials, final_deliverables, qc_queries, exploratory_analysis]
17
+ default_epic: ENG-100
18
+ terminal_status: Done
19
+ ticket_url_template: "https://acme.atlassian.net/browse/{id}"
20
+ word_limits: {tracker_comment: 100, chat: 100, pr: 200, ticket: 200}
21
+ graph_notes: true
22
+ graph_config: true
23
+
24
+ seams:
25
+ tracker:
26
+ tool: jira
27
+ adapter: adapters/tracker/jira.md
28
+ transport: both
29
+ site: acme.atlassian.net
30
+ cli: acli
31
+ mcp: atlassian
32
+ verify: 'acli jira workitem search --jql "project = {key_prefix}" --limit 1'
33
+
34
+ # ── MULTI-TARGET warehouse seam ───────────────────────────────────────────────────────────────
35
+ # The presence of `targets:` is the discriminator — NOT `default:`, because other seams already
36
+ # use default_channel / default_mode / default_branch and `default` must never carry type meaning.
37
+ #
38
+ # List the DEFAULT target FIRST. Readers that predate this feature (an un-relaunched session's
39
+ # statusline and SessionStart banner) display the first target they find, so first == default
40
+ # keeps them honest instead of quietly naming the wrong warehouse.
41
+ #
42
+ # Scalar keys at the SEAM level are inherited by every target, and a target's own key wins —
43
+ # handy when two targets share an account (one `cli:` up here, differing `default_warehouse:`
44
+ # per target).
45
+ warehouse:
46
+ default: prod
47
+ targets:
48
+ prod:
49
+ tool: snowflake
50
+ adapter: adapters/warehouse/snowflake.md
51
+ transport: both
52
+ cli: snow
53
+ default_warehouse: ANALYTICS_WH
54
+ pii_role: PII_READER
55
+ dev_target: ANALYTICS_DEV # canonical; a legacy `dev_db:` is still honored
56
+ verify: "snow connection test"
57
+ lake:
58
+ tool: databricks
59
+ adapter: adapters/warehouse/databricks.md
60
+ transport: cli
61
+ cli: dbsqlcli
62
+ warehouse_id: 0a1b2c3d4e5f6789
63
+ catalog: main
64
+ schema: analytics
65
+ dev_target: main_dev # was `dev_catalog:`
66
+ profile: DEFAULT
67
+ verify: "databricks --profile {profile} current-user me"
68
+
69
+ chat:
70
+ tool: slack
71
+ adapter: adapters/chat/slack.md
72
+ transport: mcp
73
+ mcp: slack
74
+ default_channel: C0XXXXXXXXX
75
+ default_mode: draft
76
+ always_include: [Alice]
77
+ verify: null
78
+
79
+ docstore:
80
+ tool: gdrive
81
+ adapter: adapters/docstore/gdrive.md
82
+ transport: cli
83
+ base_path: "/path/to/your/Shared drives/Tickets"
84
+ verify: "test -d \"{base_path}\""
85
+
86
+ vcs:
87
+ tool: github
88
+ adapter: adapters/vcs/github.md
89
+ transport: cli
90
+ default_branch: main
91
+ semantic_pr: true
92
+ worktree_root: ".claude/worktrees"
93
+ verify: "gh auth status"
94
+
95
+ policies:
96
+ hard_halt_before_external_posts: true
97
+ db_write_requires_approval: true
98
+ chat_default_draft: true
99
+ hyperlink_everything: true
100
+ skillify_everything: true
101
+ reduce_assumptions: true
102
+ commit_plan_before_implement: true
103
+ system_evolution: true
104
+ deterministic_outputs: true
@@ -34,10 +34,13 @@ policies: # behavioral rules every skill inherits (the kit's "global rules
34
34
  | `ticket_url_template` | template \| null | `https://acme.atlassian.net/browse/{id}` | How `tickets/INDEX.md` links each ticket (`{id}` token). Null/omitted → the index renders no per-ticket link. |
35
35
  | `word_limits` | map | `{tracker_comment: 100, chat: 100, pr: 200, ticket: 200}` | Hard caps the comms skills enforce. |
36
36
  | `graph_notes` | bool | `true` | Generate the Obsidian graph layer (`tickets/graph/` + `tickets/objects/`). On by default; set `false` to disable. |
37
+ | `graph_config` | bool | `true` | Also write/merge `.obsidian/graph.json` (tickets↔objects filter + color groups) so the Graph view opens ready-to-read. Create/merge-only — never clobbers manual tweaks. On by default; set `false` to keep the nodes but not manage the Obsidian config. Ignored when `graph_notes` is `false`. |
37
38
 
38
39
  ## `seams`
39
40
 
40
- Exactly these five keys: `tracker`, `warehouse`, `chat`, `docstore`, `vcs`. Each:
41
+ Exactly these five keys: `tracker`, `warehouse`, `chat`, `docstore`, `vcs`. Each is **either** a
42
+ single mapping (below) **or** a *multi-target* mapping — see "Multiple warehouses". Fields of a
43
+ single mapping:
41
44
 
42
45
  | Field | Type | Meaning |
43
46
  |---|---|---|
@@ -50,6 +53,51 @@ Exactly these five keys: `tracker`, `warehouse`, `chat`, `docstore`, `vcs`. Each
50
53
  The `warehouse` seam may also be `null`/omitted for non-data repos — `review`, `spec-and-build`,
51
54
  and `refresh context` degrade gracefully (skip warehouse steps) when it is.
52
55
 
56
+ ### Multiple warehouses (named targets)
57
+
58
+ A repo that must reach more than one warehouse — prod Snowflake plus a Databricks lakehouse, or two
59
+ Snowflake accounts — declares **named targets** instead of one flat mapping:
60
+
61
+ ```yaml
62
+ warehouse:
63
+ default: prod # REQUIRED when `targets:` is present; must name a key below
64
+ cli: snow # seam-level scalars are inherited by every target
65
+ targets:
66
+ prod: { tool: snowflake, adapter: adapters/warehouse/snowflake.md, verify: "snow connection test" }
67
+ lake: { tool: databricks, adapter: adapters/warehouse/databricks.md, verify: "…" }
68
+ ```
69
+
70
+ | Field | Type | Meaning |
71
+ |---|---|---|
72
+ | `targets` | map | Named targets. **Its presence is the discriminator** for a multi-target seam. |
73
+ | `default` | string | Which target skills use when nothing else selects one. Required with `targets`. |
74
+
75
+ Rules:
76
+
77
+ - **Inheritance.** A target inherits any key it doesn't define itself, including `tool` / `adapter` /
78
+ `verify` — so two targets on one account can share all three and differ only in, say,
79
+ `default_warehouse`. A target's own key wins. Inheritance is keyed on *absence*: an explicit
80
+ `verify: null` on a target means "skip", it does not fall back to the seam's command.
81
+ - **List the default first.** Readers that predate this feature (an un-relaunched session's statusline
82
+ and SessionStart banner) show the first target they find, so first == default keeps them honest.
83
+ `bin/verify_stack.sh` warns when the default isn't first, and fails when `default` is missing or
84
+ names an unknown target.
85
+ - **Which target is active** is resolved per `adapters/README.md` § Multi-target seams. A `.sql` file
86
+ names its own target in a `-- warehouse-target: <name>` header comment; that never goes in a CSV,
87
+ whose header must stay on row 1 with no preamble.
88
+ - v1 implements multi-target for **`warehouse`** only. `verify_stack.sh` handles the shape for any
89
+ seam, but no skill resolves targets for the other four, so don't rely on it there yet.
90
+
91
+ **Known limitation:** `tickets/OBJECTS.md` and the graph layer fold object names case-insensitively
92
+ and are warehouse-blind, so `ANALYTICS.CUSTOMERS` on one target and `analytics.customers` on another
93
+ collapse into one node. Usually that's the useful reading (a genuine cross-system relationship), but
94
+ it is not a per-target index.
95
+
96
+ **Dev target.** The dev environment is `seams.warehouse[.targets.<name>].dev_target`. When that key
97
+ is absent it falls back to the key named by the warehouse adapter's `dev_key:` frontmatter (`dev_db`
98
+ for Snowflake, `dev_dataset` for BigQuery, `dev_catalog` for Databricks, `dev_schema` for
99
+ Postgres/Redshift/Synapse) — so configs written before `dev_target` existed keep working untouched.
100
+
53
101
  ## `policies` (the 9 kit policies — see kit README "AI-layer" section)
54
102
 
55
103
  | Policy | Default | Enforced by |
@@ -71,7 +119,9 @@ and `refresh context` degrade gracefully (skip warehouse steps) when it is.
71
119
 
72
120
  ## Worked example
73
121
 
74
- A worked example lives at [`stack.yaml`](stack.yaml) (Jira/Snowflake/Slack/Drive/GitHub). Two more
122
+ A worked example lives at [`stack.yaml`](stack.yaml) (Jira/Snowflake/Slack/Drive/GitHub). Three more
75
123
  prove the abstraction holds with zero skill edits: `stack.example.asana-bq.yaml`
76
- (Asana/BigQuery/Teams/SharePoint/GitLab) and `stack.example.azure.yaml`
77
- (Azure DevOps/Synapse/Teams/SharePoint/Azure Repos). To validate any config: `bash bin/verify_stack.sh`.
124
+ (Asana/BigQuery/Teams/SharePoint/GitLab), `stack.example.azure.yaml`
125
+ (Azure DevOps/Synapse/Teams/SharePoint/Azure Repos), and `stack.example.multi-warehouse.yaml`
126
+ (Jira/**Snowflake + Databricks**/Slack/Drive/GitHub — the named-targets shape above). To validate any
127
+ config: `bash bin/verify_stack.sh`.
@@ -18,6 +18,7 @@ project:
18
18
  ticket_url_template: "https://acme.atlassian.net/browse/{id}" # how INDEX.md links a ticket ({id} token); omit/null = no link
19
19
  word_limits: {tracker_comment: 100, chat: 100, pr: 200, ticket: 200}
20
20
  graph_notes: true # Obsidian graph layer (tickets/graph + tickets/objects); false to disable
21
+ graph_config: true # also write .obsidian/graph.json (tickets↔objects filter + colors); create/merge-only, never clobbers manual tweaks
21
22
 
22
23
  seams:
23
24
  tracker:
@@ -51,15 +51,95 @@ def find_stack_yaml(cwd: str) -> Path | None:
51
51
  return None
52
52
 
53
53
 
54
+ _COMMENT = re.compile(r"^\s*#")
55
+ # `note: |`, `- note: |-`, `"note": >2`, … — a key whose value is a literal/folded block scalar.
56
+ _BLOCK_SCALAR = re.compile(
57
+ r"""^\s*(?:-\s+)?(?:"[^"]*"|'[^']*'|[A-Za-z0-9_.-]+):\s*[|>][-+0-9]*\s*(?:#.*)?$"""
58
+ )
59
+
60
+
61
+ def seam_block(text: str, seam: str) -> str:
62
+ """The lines under `<seam>:` inside the top-level `seams:` mapping.
63
+
64
+ Three properties matter, all of them about never *narrowing* what gets gated:
65
+
66
+ * The seam-key indent is **inferred** from the file, not assumed to be two spaces — a
67
+ four-space-indented stack.yaml is valid YAML that `yq` reads fine.
68
+ * Comment-only lines carry no indentation and never terminate the scan, so a comment
69
+ between `seams:` and the first seam can't hide it.
70
+ * A config with no `seams:` anchor (malformed or partial) is scanned whole rather than
71
+ skipped. Gating too much only costs a confirmation prompt; gating too little is a
72
+ destructive statement running unreviewed.
73
+
74
+ Block-scalar bodies are skipped so prose can't be read as configuration. No yaml dep — this
75
+ hook has to stay a standalone stdlib script.
76
+ """
77
+ # A mapping key may carry a YAML anchor/alias/tag before its nested block (`warehouse: &wh`),
78
+ # which yq resolves fine — so the key patterns tolerate one.
79
+ prop = r"(?:[&*!][^\s#]*\s*)?"
80
+ lines = text.splitlines()
81
+ start = next((i for i, ln in enumerate(lines)
82
+ if re.match(rf"^seams:\s*{prop}(?:#.*)?$", ln)), None)
83
+ if start is None:
84
+ body, min_indent = lines, 0 # no anchor: a bare `warehouse:` may sit at column 0
85
+ else:
86
+ body, min_indent = lines[start + 1:], 1
87
+
88
+ indent = depth = skip_deeper_than = None
89
+ out = []
90
+ for ln in body:
91
+ if not ln.strip() or _COMMENT.match(ln):
92
+ if depth is not None and ln.strip() == "":
93
+ out.append(ln)
94
+ continue # comments define neither indent nor an end
95
+ cur = len(ln) - len(ln.lstrip())
96
+ if cur < min_indent:
97
+ break # dedented out of `seams:`
98
+ if skip_deeper_than is not None:
99
+ if cur > skip_deeper_than:
100
+ continue # still inside a block scalar
101
+ skip_deeper_than = None
102
+ if depth is None:
103
+ if indent is None:
104
+ indent = cur # the first real child sets the seam-key column
105
+ if cur == indent:
106
+ km = re.match(rf"^\s*{re.escape(seam)}:\s*(.*)$", ln)
107
+ if km:
108
+ rest = re.sub(r"^[&*!][^\s#]*\s*", "", km.group(1).strip())
109
+ rest = re.sub(r"\s*#.*$", "", rest).strip()
110
+ if rest.startswith("{"):
111
+ return rest # inline flow mapping — the seam is all on this line
112
+ if rest == "":
113
+ depth = cur # normal nested block
114
+ # any other inline scalar (`warehouse: null`) opens nothing
115
+ continue
116
+ if cur <= depth:
117
+ break # next seam at the same level
118
+ if _BLOCK_SCALAR.match(ln):
119
+ skip_deeper_than = cur
120
+ continue
121
+ out.append(ln)
122
+ return "\n".join(out)
123
+
124
+
54
125
  def warehouse_clis(stack: Path | None) -> list[str]:
126
+ """Defaults + every `cli:` declared inside the warehouse seam.
127
+
128
+ Scoped to the seam block for two reasons. A multi-target seam declares one `cli:` per target,
129
+ so all of them must be gated. And the previous `DOTALL` scan was unanchored: on a warehouse
130
+ seam with no `cli:` of its own (only Snowflake requires one) that was listed *before* the
131
+ tracker, it captured the tracker's CLI — making `<tracker-cli> ... create ...` trip the
132
+ destructive-statement check and prompt for approval on a plain ticket edit.
133
+ """
55
134
  clis = list(DEFAULT_WAREHOUSE_CLIS)
56
135
  if stack:
57
136
  try:
58
- text = stack.read_text(errors="replace")
59
- # tiny scan: a `cli: <name>` line under the warehouse seam (no yaml dep)
60
- m = re.search(r"warehouse:.*?(?:\n\s+cli:\s*([A-Za-z0-9_-]+))", text, re.DOTALL)
61
- if m and m.group(1) not in clis:
62
- clis.insert(0, m.group(1))
137
+ blk = seam_block(stack.read_text(errors="replace"), "warehouse")
138
+ # Unanchored within the block (which is already scoped to the seam) so a flow mapping
139
+ # — `prod: {tool: trino, cli: trino}` — is read too, not just one-key-per-line style.
140
+ for c in re.findall(r"(?:^|[\s{,])cli:\s*([A-Za-z0-9_.-]+)", blk, re.MULTILINE):
141
+ if c not in clis:
142
+ clis.insert(0, c)
63
143
  except OSError:
64
144
  pass
65
145
  return clis
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env python3
2
+ """SessionStart hook — prime every session with the configured stack + AI-layer index.
3
+
4
+ Prints a compact summary (becomes session additionalContext) so the agent always knows
5
+ which tools are wired, which skills/commands exist, and the lifecycle — without anyone
6
+ having to load all of AGENTS.md. This is the always-on, *tiny* slice of context; the
7
+ `/prime-*` commands load the rest on demand.
8
+
9
+ Wire it in settings.json:
10
+ "hooks": { "SessionStart": [ { "hooks": [
11
+ { "type": "command", "command": "python3 .claude/hooks/session_context.py" } ] } ] }
12
+
13
+ Stdlib only. Fails open (prints nothing) if the kit isn't configured yet.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import re
19
+ import sys
20
+ from pathlib import Path
21
+
22
+
23
+ def project_root() -> Path:
24
+ if os.environ.get("CLAUDE_PROJECT_DIR"):
25
+ return Path(os.environ["CLAUDE_PROJECT_DIR"])
26
+ # hook lives at <root>/.claude/hooks/
27
+ return Path(__file__).resolve().parent.parent.parent
28
+
29
+
30
+ _COMMENT = re.compile(r"^\s*#")
31
+ _BLOCK_SCALAR = re.compile(
32
+ r"""^\s*(?:-\s+)?(?:"[^"]*"|'[^']*'|[A-Za-z0-9_.-]+):\s*[|>][-+0-9]*\s*(?:#.*)?$"""
33
+ )
34
+ _PROP = r"(?:[&*!][^\s#]*\s*)?" # an optional YAML anchor/alias/tag after a mapping key
35
+
36
+
37
+ def seam_block(text: str, seam: str) -> str:
38
+ """The lines under `<seam>:` inside the top-level `seams:` mapping.
39
+
40
+ The seam-key indent is inferred from the file rather than assumed, comment-only lines carry no
41
+ indentation, a key may hold a YAML anchor, and block-scalar bodies are skipped. Deliberately
42
+ duplicated from db_write_guard.py — hooks are copied individually on a vendored install, so each
43
+ has to stand alone as a stdlib-only script.
44
+ """
45
+ lines = text.splitlines()
46
+ start = next((i for i, ln in enumerate(lines)
47
+ if re.match(rf"^seams:\s*{_PROP}(?:#.*)?$", ln)), None)
48
+ if start is None:
49
+ return ""
50
+
51
+ indent = depth = skip_deeper_than = None
52
+ out = []
53
+ for ln in lines[start + 1:]:
54
+ if not ln.strip() or _COMMENT.match(ln):
55
+ continue
56
+ cur = len(ln) - len(ln.lstrip())
57
+ if cur == 0:
58
+ break
59
+ if skip_deeper_than is not None:
60
+ if cur > skip_deeper_than:
61
+ continue
62
+ skip_deeper_than = None
63
+ if depth is None:
64
+ if indent is None:
65
+ indent = cur
66
+ if cur == indent:
67
+ km = re.match(rf"^\s*{re.escape(seam)}:\s*(.*)$", ln)
68
+ if km:
69
+ rest = re.sub(r"^[&*!][^\s#]*\s*", "", km.group(1).strip())
70
+ rest = re.sub(r"\s*#.*$", "", rest).strip()
71
+ if rest.startswith("{"):
72
+ return rest # inline flow mapping — the seam is all on this line
73
+ if rest == "":
74
+ depth = cur
75
+ continue
76
+ if cur <= depth:
77
+ break
78
+ if _BLOCK_SCALAR.match(ln):
79
+ skip_deeper_than = cur
80
+ continue
81
+ out.append(ln)
82
+ return "\n".join(out)
83
+
84
+
85
+ def seam_tools(text: str, seam: str) -> list[str]:
86
+ """Every `tool:` declared in a seam — one for a single mapping, one per target otherwise.
87
+
88
+ A multi-target seam returns the default's tool FIRST, so the banner never implies the wrong
89
+ warehouse is the active one.
90
+ """
91
+ blk = seam_block(text, seam)
92
+ if not blk:
93
+ return []
94
+
95
+ # A wholly inline seam (`warehouse: {default: prod, targets: {…}}`) has no lines to walk, so
96
+ # read it positionally: tools in document order, default's tool hoisted to the front.
97
+ if blk.lstrip().startswith("{"):
98
+ tools = re.findall(r"\btool:\s*([A-Za-z0-9_.-]+)", blk)
99
+ dm = re.search(r"\bdefault:\s*([A-Za-z0-9_.-]+)", blk)
100
+ if dm:
101
+ # `<name>: {… tool: X …}` — find the tool belonging to the default target.
102
+ tm = re.search(rf"\b{re.escape(dm.group(1))}:\s*\{{[^}}]*\btool:\s*([A-Za-z0-9_.-]+)", blk)
103
+ if tm and tm.group(1) in tools:
104
+ tools = [tm.group(1)] + [t for t in tools if t != tm.group(1)]
105
+ return tools
106
+
107
+ pairs, cur_target, target_indent = [], None, None
108
+ for ln in blk.splitlines():
109
+ m = re.match(r"^(\s*)([A-Za-z0-9_.-]+):\s*(.*)$", ln)
110
+ if not m:
111
+ continue
112
+ ind, key = len(m.group(1)), m.group(2)
113
+ val = re.sub(r"\s*#.*$", "", m.group(3)).strip()
114
+ if key == "tool" and val:
115
+ pairs.append((cur_target, val))
116
+ continue
117
+ if key == "targets":
118
+ continue
119
+ if val == "" or val.startswith("{"):
120
+ # the header of one target — bare `name:`, or `name: {…}` in flow style
121
+ if target_indent is None or ind <= target_indent:
122
+ target_indent, cur_target = ind, key
123
+ tm = re.search(r"\btool:\s*([A-Za-z0-9_.-]+)", val)
124
+ if tm:
125
+ pairs.append((key, tm.group(1))) # flow style: its tool is on this same line
126
+ dm = re.search(r"^\s+default:\s*([A-Za-z0-9_.-]+)", blk, re.MULTILINE)
127
+ if dm:
128
+ d = dm.group(1)
129
+ pairs.sort(key=lambda p: 0 if p[0] == d else 1) # stable: others keep file order
130
+ return [t for _, t in pairs]
131
+
132
+
133
+ def scan_stack(stack: Path) -> dict:
134
+ text = stack.read_text(errors="replace")
135
+ out = {}
136
+ m = re.search(r"^\s*key_prefix:\s*([A-Za-z0-9_-]+)", text, re.MULTILINE)
137
+ out["key_prefix"] = m.group(1) if m else "?"
138
+ for seam in ("tracker", "warehouse", "chat", "docstore", "vcs"):
139
+ tools = seam_tools(text, seam)
140
+ # A multi-target seam renders as "a+b" (default first) so no target is hidden.
141
+ out[seam] = "+".join(tools) if tools else "—"
142
+ return out
143
+
144
+
145
+ def main() -> int:
146
+ root = project_root()
147
+ stack = root / ".claude/config/stack.yaml"
148
+ if not stack.is_file():
149
+ return 0 # not configured — say nothing
150
+
151
+ try:
152
+ s = scan_stack(stack)
153
+ except OSError:
154
+ return 0
155
+
156
+ skills = sorted(p.parent.name for p in (root / ".claude/skills").glob("*/SKILL.md")) \
157
+ if (root / ".claude/skills").is_dir() else []
158
+ commands = sorted(
159
+ p.stem for p in (root / ".claude/commands").glob("*.md")
160
+ ) if (root / ".claude/commands").is_dir() else []
161
+
162
+ lines = [
163
+ "## Ticketwright — session context",
164
+ f"Stack ({s['key_prefix']}-tickets): tracker={s['tracker']} · warehouse={s['warehouse']} · "
165
+ f"chat={s['chat']} · docstore={s['docstore']} · vcs={s['vcs']}.",
166
+ "Lifecycle: /ticket (opens + auto-primes context) → /spec-and-build → /review → /ship.",
167
+ ]
168
+ if skills:
169
+ lines.append("Skills: " + ", ".join(skills) + ".")
170
+ if commands:
171
+ lines.append("Commands: " + ", ".join(commands) + ".")
172
+ lines.append("Policies enforced: DB writes & external posts require approval (db_write_guard hook + "
173
+ "skill hard-halts); chat defaults to draft; outputs deterministic. See AGENTS.md.")
174
+ print("\n".join(lines))
175
+ return 0
176
+
177
+
178
+ if __name__ == "__main__":
179
+ sys.exit(main())
@@ -95,6 +95,8 @@ def main() -> int:
95
95
  lines.append(f"- {t.get('owner')}/{t.get('id')} ({d}) — {title}")
96
96
  if total > len(tickets):
97
97
  lines.append(f"({total - len(tickets)} newer ticket(s) on disk not yet enriched — run the index workflow.)")
98
+ elif len(tickets) > total:
99
+ lines.append(f"({len(tickets) - total} record(s) have no folder on disk — run /refresh index --prune.)")
98
100
  print("\n".join(lines))
99
101
  return 0
100
102
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "_README": "Rendered to .claude/settings.json by /setup. The hooks block below is for a VENDORED (cp -r) install, where nothing else wires the kit's hooks. On a PLUGIN install (CLAUDE_PLUGIN_ROOT set), .claude-plugin/plugin.json already wires these same hooks from the plugin dir, so /setup OMITS this hooks block to avoid double-firing (double db-write prompts, double index regen) AND ADDS an 'extraKnownMarketplaces' (ticketwright github source, autoUpdate:true) + 'enabledPlugins' (ticketwright@ticketwright:true) block so the REPO opts every user into the plugin at PROJECT scope (a plugin cannot set its own install scope — the repo does; teammates are prompted to install), committed with the repo, and self-updates on formal releases only (version-string change; never un-released main commits). Do NOT add those two keys on a vendored install — there is no marketplace to enable from. permissions + statusLine are written in both modes (setup appends stack-specific CLI allows); on a plugin install setup copies statusline.sh into .claude/ so the relative command resolves. The db_write_guard hook turns 'db_write_requires_approval' into a mechanical ask before any destructive warehouse statement.",
2
+ "_README": "Rendered to .claude/settings.json by /setup. The hooks block below is for a VENDORED (cp -r) install, where nothing else wires the kit's hooks. On a PLUGIN install (CLAUDE_PLUGIN_ROOT set), .claude-plugin/plugin.json already wires these same hooks from the plugin dir, so /setup OMITS this hooks block to avoid double-firing (double db-write prompts, double index regen) AND ADDS an 'extraKnownMarketplaces' (ticketwright https url source, autoUpdate:true) + 'enabledPlugins' (ticketwright@ticketwright:true) block so the REPO opts every user into the plugin at PROJECT scope (a plugin cannot set its own install scope — the repo does; teammates are prompted to install), committed with the repo, and self-updates on formal releases only (version-string change; never un-released main commits). Do NOT add those two keys on a vendored install — there is no marketplace to enable from. permissions + statusLine are written in both modes (setup appends stack-specific CLI allows); on a plugin install setup copies statusline.sh into .claude/ so the relative command resolves. The db_write_guard hook turns 'db_write_requires_approval' into a mechanical ask before any destructive warehouse statement.",
3
3
  "hooks": {
4
4
  "PreToolUse": [
5
5
  {
@@ -22,8 +22,16 @@ Two layers, kept separate so the catalog is reproducible and CI/pre-commit-safe:
22
22
  marked `▱`).
23
23
 
24
24
  ## Phase 2 — Enrich (the model half)
25
- 4. Decide scope: the given ticket id(s); `--all` for the whole backlog; or default to the
26
- un-enriched/stale set from `--stats`.
25
+ 4. Decide scope (enrichment is the model (re)writing curated summaries — **never rewrite curated
26
+ summaries silently**):
27
+ - a **specific ticket id (or ids)** — enrich just those;
28
+ - **default** (no flag): only the **un-enriched + stale** set from `--stats` — the safe day-to-day
29
+ scope;
30
+ - **`--all`**: cover every ticket **but skip those already enriched and fresh** — the bootstrap
31
+ scope for a new or newly-adopted backlog (render everything, then enrich only what's missing or
32
+ changed);
33
+ - **`--force`** (a.k.a. `--reenrich-all`): genuinely re-enrich **everything**, rewriting even
34
+ hand-curated, non-stale summaries. Rare, and destructive to curation — only on an explicit ask.
27
35
  5. For each target ticket, read its `README.md` and write ONE record:
28
36
  `{id, owner, title, status, date, summary (<=180 chars, lead with what was delivered + key
29
37
  numbers), tags (1-4 kebab-case), cross_refs (other ticket ids), objects (qualified data objects
@@ -53,11 +53,17 @@ include the key commented with a `# TODO` and keep going — `verify` will point
53
53
  `private/` subfolder), the AI-layer index, and the seeded ticket index.
54
54
 
55
55
  ### Phase 4 — Verify & hand off
56
- 6. Run `!bash "${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR}/bin/selftest.sh"` (kit integrity — a failure here is fatal) and
57
- `!bash "${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR}/bin/verify_stack.sh"` (per-seam reachability — an unreachable seam is
58
- **not** fatal at setup time; print its adapter's auth notes as the fix).
59
- 7. **Report:** the chosen stack, files written, any `# TODO` keys or stub adapters, and the next
60
- step — `/ticket <id>` to start work, or `/setup --teammate` for a new person.
56
+ 6. **Two distinct checks — keep them labeled as such in the report:**
57
+ - `!bash "${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR}/bin/selftest.sh"` — **kit integrity**. It
58
+ validates the plugin's *own bundled example* stacks, **not** your repo's config. A failure here
59
+ is fatal.
60
+ - `!bash "${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR}/bin/verify_stack.sh" .claude/config/stack.yaml`
61
+ — **your repo's stack** reachability (pass the repo stack path explicitly so it's unambiguous
62
+ which config was checked). An unreachable seam is **not** fatal at setup time; print its
63
+ adapter's auth notes as the fix.
64
+ 7. **Report:** name which check is which (selftest = kit integrity; verify_stack = *your* seams), then
65
+ the chosen stack, files written, any `# TODO` keys or stub adapters, and the next step —
66
+ `/ticket <id>` to start work, or `/setup --teammate` for a new person.
61
67
  8. **Offer to commit the scaffold.** What setup just wrote (`.claude/config/stack.yaml`, `AGENTS.md`,
62
68
  `CLAUDE.md`, `.claude/settings.json`, `.gitignore`, `documentation/AI_LAYER_INDEX.md`, the seeded `tickets/`
63
69
  index — plus, on a vendored install, the kit itself) is untracked; if it isn't committed, a later
@@ -39,5 +39,7 @@ Include: what was auto-configured, what needs a human decision, and the suggeste
39
39
  real ticket through `/ticket → /review → /ship` before deleting anything custom.
40
40
 
41
41
  ## 5 · Verify & report
42
- Same as Phase 4 of the default mode (selftest + verify_stack). The report leads with the
43
- MIGRATION.md path and the trial-ticket suggestion — adoption is incremental by design.
42
+ Same as Phase 4 of the default mode: `selftest.sh` (**kit integrity** — the plugin's own example
43
+ stacks) then `verify_stack.sh .claude/config/stack.yaml` (**your repo's** seams). Label the two in
44
+ the report so the kit's example stack is never mistaken for the repo's config. The report leads with
45
+ the MIGRATION.md path and the trial-ticket suggestion — adoption is incremental by design.
@@ -33,7 +33,7 @@ itself:
33
33
  {
34
34
  "extraKnownMarketplaces": {
35
35
  "ticketwright": {
36
- "source": { "source": "github", "repo": "kyle-chalmers/ticketwright" },
36
+ "source": { "source": "url", "url": "https://github.com/kyle-chalmers/ticketwright.git" },
37
37
  "autoUpdate": true
38
38
  }
39
39
  },
@@ -43,6 +43,10 @@ itself:
43
43
  }
44
44
  ```
45
45
 
46
+ The source is an explicit `https://…git` URL, not the `owner/repo` shorthand — the shorthand can
47
+ resolve to SSH and fail for users without GitHub SSH keys, whereas the URL clones over HTTPS via the
48
+ git credential helper. A fork edits just this URL.
49
+
46
50
  `autoUpdate` re-installs **only when the plugin's version string changes** — i.e. only on a formal
47
51
  release (the release commit bumps `plugin.json`/`marketplace.json`/`__init__.py` in lockstep and tags
48
52
  `v*`). Between releases, ordinary commits to the default branch leave the version untouched, so
@@ -77,4 +81,7 @@ SessionStart surfacing, curated summaries at ship time). An existing backlog get
77
81
  `/refresh index --all`.
78
82
 
79
83
  That renderer also writes the Obsidian graph layer (`tickets/graph/` + `tickets/objects/`) when
80
- `project.graph_notes` is on (the default), committed alongside `INDEX.md`/`OBJECTS.md`.
84
+ `project.graph_notes` is on (the default), committed alongside `INDEX.md`/`OBJECTS.md`. It then seeds
85
+ `.obsidian/graph.json` (unless `project.graph_config: false`) with the tickets↔objects filter + color
86
+ groups so the Graph view opens ready-to-read — create/merge-only, so it never clobbers a user's manual
87
+ graph tweaks.
@@ -40,14 +40,20 @@ authorization, execute in order:
40
40
  7. **chat.draft** to `seams.chat.default_channel` (policy `chat_default_draft` — the human clicks
41
41
  send unless they said "send it", in which case `chat.send`). Smart links for ticket id(s),
42
42
  files, PR.
43
- 8. **vcs.commit** — stage this ticket's paths (deliverable files included: they're committed by
44
- default so results live with the ticket and show in the PR) **plus `tickets/INDEX.md` +
45
- `tickets/OBJECTS.md` + `tickets/index_data.json`** (all three, or `--check` flags drift in CI;
46
- semantic message + Co-Authored-By). **Before staging, list the `final_deliverables/` files that
47
- will be committed and confirm none carry PII/customer data that shouldn't be in git** — if any do,
48
- have the user rename them `*.private.csv` (etc.) or move them under a `private/` subfolder (both
49
- gitignored) first. Then **vcs.open_pr** (semantic title; body = Business Impact / Deliverables /
50
- Technical Notes / QC).
43
+ 8. **vcs.commit** — **first, isolate repo-setup / AI-layer files.** If any are dirty
44
+ (`.claude/settings.json`, `.claude/config/stack.yaml`, `.claude/statusline.sh`, `AGENTS.md`/
45
+ `CLAUDE.md`, `documentation/AI_LAYER_INDEX.md`, `.gitignore`) they belong to the repo's plugin
46
+ setup, not this ticket — give them a separate `chore(plugins): …` commit on this ticket's branch
47
+ (a distinct commit that rides the ticket's one PR — not a second PR, never folded into the ticket
48
+ commit); `.claude/settings.local.json` +
49
+ `.claude/worktrees/` are gitignored, leave them. Then stage this ticket's paths (deliverable files
50
+ included: they're committed by default so results live with the ticket and show in the PR) **plus
51
+ `tickets/INDEX.md` + `tickets/OBJECTS.md` + `tickets/index_data.json`** (all three, or `--check`
52
+ flags drift in CI; semantic message + Co-Authored-By). **Before staging, list the
53
+ `final_deliverables/` files that will be committed and confirm none carry PII/customer data that
54
+ shouldn't be in git** — if any do, have the user rename them `*.private.csv` (etc.) or move them
55
+ under a `private/` subfolder (both gitignored) first. Then **vcs.open_pr** (semantic title; body =
56
+ Business Impact / Deliverables / Technical Notes / QC).
51
57
  9. **transition** the ticket toward `project.terminal_status` if appropriate.
52
58
 
53
59
  ## Phase C — System-evolution retro (always, even on success)
@@ -30,8 +30,8 @@ context-engineering core idea: AI fails from missing context, not weak models.
30
30
  3. **Write the spec** from `${CLAUDE_PLUGIN_ROOT:-$CLAUDE_PROJECT_DIR}/templates/spec.md.tmpl` into the ticket's folder
31
31
  (`specs/<id>-<slug>.md` or `final_deliverables/`): operation type (new/alter), data grain,
32
32
  sources + join/cast rules, transformation logic, **validation gates** (the exact QC the build must
33
- pass), downstream impact, dev-env target (`seams.warehouse.dev_db`), and a **confidence score
34
- (1–10)**.
33
+ pass), downstream impact, the **dev target** (`seams.warehouse.dev_target`, else the key the
34
+ warehouse adapter names in its `dev_key:` frontmatter), and a **confidence score (1–10)**.
35
35
  4. **Reduce assumptions:** before finalizing, list open questions and **ask the user** (don't guess).
36
36
  5. **Commit the spec** via vcs `commit` (`docs: <id> spec for <thing>`) — policy
37
37
  `commit_plan_before_implement` enables blame-free retry if the build later reveals a gap.
@@ -41,7 +41,7 @@ context-engineering core idea: AI fails from missing context, not weak models.
41
41
  6. **Load** the committed spec (path arg or newest in the ticket's `specs/`). Treat it as the source
42
42
  of truth, but **validate each step independently** — don't blindly follow; the spec can be wrong.
43
43
  7. **Implement in small build-and-check sub-loops:** one object/step at a time. Develop against
44
- `seams.warehouse.dev_db` first; parameterize values at the top **via a CTE params row
44
+ the warehouse's **dev target** first; parameterize values at the top **via a CTE params row
45
45
  (`WITH params AS (SELECT … AS anchor) … CROSS JOIN params`), not a session `DECLARE`/`SET`** —
46
46
  CTE params stay portable and keep CSV exports clean; explicit `ORDER BY` on any export
47
47
  (deterministic outputs).