ticketwright 3.2.0__tar.gz → 3.4.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 (87) hide show
  1. {ticketwright-3.2.0 → ticketwright-3.4.0}/.claude/config/stack.example.asana-bq.yaml +1 -1
  2. {ticketwright-3.2.0 → ticketwright-3.4.0}/.claude/config/stack.example.azure.yaml +1 -1
  3. ticketwright-3.4.0/.claude/config/stack.example.multi-warehouse.yaml +104 -0
  4. ticketwright-3.4.0/.claude/config/stack.schema.md +157 -0
  5. {ticketwright-3.2.0 → ticketwright-3.4.0}/.claude/config/stack.yaml +2 -1
  6. ticketwright-3.4.0/.claude/hooks/_stack.py +139 -0
  7. ticketwright-3.4.0/.claude/hooks/db_write_guard.py +381 -0
  8. ticketwright-3.4.0/.claude/hooks/session_context.py +192 -0
  9. {ticketwright-3.2.0 → ticketwright-3.4.0}/.claude/hooks/ticket_index_context.py +2 -0
  10. {ticketwright-3.2.0 → ticketwright-3.4.0}/.claude/settings.json.tmpl +1 -1
  11. {ticketwright-3.2.0 → ticketwright-3.4.0}/.claude/skills/refresh/index.md +10 -2
  12. {ticketwright-3.2.0 → ticketwright-3.4.0}/.claude/skills/setup/SKILL.md +11 -5
  13. {ticketwright-3.2.0 → ticketwright-3.4.0}/.claude/skills/setup/adopt.md +4 -2
  14. {ticketwright-3.2.0 → ticketwright-3.4.0}/.claude/skills/setup/scaffold.md +9 -2
  15. {ticketwright-3.2.0 → ticketwright-3.4.0}/.claude/skills/ship/SKILL.md +14 -8
  16. {ticketwright-3.2.0 → ticketwright-3.4.0}/.claude/skills/spec-and-build/SKILL.md +8 -5
  17. {ticketwright-3.2.0 → ticketwright-3.4.0}/.claude/statusline.sh +13 -1
  18. {ticketwright-3.2.0 → ticketwright-3.4.0}/CHANGELOG.md +166 -0
  19. {ticketwright-3.2.0 → ticketwright-3.4.0}/PKG-INFO +60 -14
  20. {ticketwright-3.2.0 → ticketwright-3.4.0}/README.md +59 -13
  21. {ticketwright-3.2.0 → ticketwright-3.4.0}/adapters/warehouse/bigquery.md +7 -3
  22. {ticketwright-3.2.0 → ticketwright-3.4.0}/adapters/warehouse/databricks.md +7 -3
  23. {ticketwright-3.2.0 → ticketwright-3.4.0}/adapters/warehouse/postgres.md +7 -3
  24. {ticketwright-3.2.0 → ticketwright-3.4.0}/adapters/warehouse/redshift.md +7 -3
  25. {ticketwright-3.2.0 → ticketwright-3.4.0}/adapters/warehouse/snowflake.md +7 -4
  26. {ticketwright-3.2.0 → ticketwright-3.4.0}/adapters/warehouse/synapse.md +7 -3
  27. {ticketwright-3.2.0 → ticketwright-3.4.0}/bin/build_ticket_index.py +186 -10
  28. ticketwright-3.4.0/bin/bump_version.sh +99 -0
  29. {ticketwright-3.2.0 → ticketwright-3.4.0}/bin/recall.py +1 -1
  30. {ticketwright-3.2.0 → ticketwright-3.4.0}/bin/selftest.sh +471 -24
  31. ticketwright-3.4.0/bin/verify_stack.sh +146 -0
  32. {ticketwright-3.2.0 → ticketwright-3.4.0}/pyproject.toml +8 -1
  33. {ticketwright-3.2.0 → ticketwright-3.4.0}/templates/AGENTS.md.tmpl +32 -7
  34. {ticketwright-3.2.0 → ticketwright-3.4.0}/templates/gitignore.tmpl +8 -0
  35. {ticketwright-3.2.0 → ticketwright-3.4.0}/templates/productized-skill/SKILL.md.tmpl +2 -1
  36. {ticketwright-3.2.0 → ticketwright-3.4.0}/ticketwright/__init__.py +1 -1
  37. {ticketwright-3.2.0 → ticketwright-3.4.0}/ticketwright/cli.py +4 -3
  38. ticketwright-3.2.0/.claude/config/stack.schema.md +0 -77
  39. ticketwright-3.2.0/.claude/hooks/db_write_guard.py +0 -145
  40. ticketwright-3.2.0/.claude/hooks/session_context.py +0 -76
  41. ticketwright-3.2.0/bin/verify_stack.sh +0 -83
  42. {ticketwright-3.2.0 → ticketwright-3.4.0}/.claude/agents/qc-reviewer.md +0 -0
  43. {ticketwright-3.2.0 → ticketwright-3.4.0}/.claude/commands/.gitkeep +0 -0
  44. {ticketwright-3.2.0 → ticketwright-3.4.0}/.claude/hooks/regenerate_ticket_index.py +0 -0
  45. {ticketwright-3.2.0 → ticketwright-3.4.0}/.claude/skills/productize/SKILL.md +0 -0
  46. {ticketwright-3.2.0 → ticketwright-3.4.0}/.claude/skills/productize/authoring.md +0 -0
  47. {ticketwright-3.2.0 → ticketwright-3.4.0}/.claude/skills/refresh/SKILL.md +0 -0
  48. {ticketwright-3.2.0 → ticketwright-3.4.0}/.claude/skills/refresh/context-pack.md +0 -0
  49. {ticketwright-3.2.0 → ticketwright-3.4.0}/.claude/skills/review/SKILL.md +0 -0
  50. {ticketwright-3.2.0 → ticketwright-3.4.0}/.claude/skills/setup/teammate.md +0 -0
  51. {ticketwright-3.2.0 → ticketwright-3.4.0}/.claude/skills/ticket/SKILL.md +0 -0
  52. {ticketwright-3.2.0 → ticketwright-3.4.0}/.claude/skills/ticket/priming.md +0 -0
  53. {ticketwright-3.2.0 → ticketwright-3.4.0}/.gitignore +0 -0
  54. {ticketwright-3.2.0 → ticketwright-3.4.0}/LICENSE +0 -0
  55. {ticketwright-3.2.0 → ticketwright-3.4.0}/adapters/README.md +0 -0
  56. {ticketwright-3.2.0 → ticketwright-3.4.0}/adapters/chat/slack.md +0 -0
  57. {ticketwright-3.2.0 → ticketwright-3.4.0}/adapters/chat/teams.md +0 -0
  58. {ticketwright-3.2.0 → ticketwright-3.4.0}/adapters/docstore/gdrive.md +0 -0
  59. {ticketwright-3.2.0 → ticketwright-3.4.0}/adapters/docstore/sharepoint.md +0 -0
  60. {ticketwright-3.2.0 → ticketwright-3.4.0}/adapters/tracker/asana.md +0 -0
  61. {ticketwright-3.2.0 → ticketwright-3.4.0}/adapters/tracker/azure-devops.md +0 -0
  62. {ticketwright-3.2.0 → ticketwright-3.4.0}/adapters/tracker/github-issues.md +0 -0
  63. {ticketwright-3.2.0 → ticketwright-3.4.0}/adapters/tracker/jira.md +0 -0
  64. {ticketwright-3.2.0 → ticketwright-3.4.0}/adapters/tracker/linear.md +0 -0
  65. {ticketwright-3.2.0 → ticketwright-3.4.0}/adapters/tracker/monday.md +0 -0
  66. {ticketwright-3.2.0 → ticketwright-3.4.0}/adapters/vcs/azure-repos.md +0 -0
  67. {ticketwright-3.2.0 → ticketwright-3.4.0}/adapters/vcs/github.md +0 -0
  68. {ticketwright-3.2.0 → ticketwright-3.4.0}/adapters/vcs/gitlab.md +0 -0
  69. {ticketwright-3.2.0 → ticketwright-3.4.0}/bin/enrich_ticket.py +0 -0
  70. {ticketwright-3.2.0 → ticketwright-3.4.0}/bin/ingest_index_records.py +0 -0
  71. {ticketwright-3.2.0 → ticketwright-3.4.0}/bin/render.sh +0 -0
  72. {ticketwright-3.2.0 → ticketwright-3.4.0}/bin/render_and_validate.sh +0 -0
  73. {ticketwright-3.2.0 → ticketwright-3.4.0}/bin/split_and_export.sh +0 -0
  74. {ticketwright-3.2.0 → ticketwright-3.4.0}/templates/CLAUDE.md.tmpl +0 -0
  75. {ticketwright-3.2.0 → ticketwright-3.4.0}/templates/plan.md.tmpl +0 -0
  76. {ticketwright-3.2.0 → ticketwright-3.4.0}/templates/productized-skill/bin/drift_check.sh +0 -0
  77. {ticketwright-3.2.0 → ticketwright-3.4.0}/templates/productized-skill/golden/example.json +0 -0
  78. {ticketwright-3.2.0 → ticketwright-3.4.0}/templates/productized-skill/sql/qc.sql.tmpl +0 -0
  79. {ticketwright-3.2.0 → ticketwright-3.4.0}/templates/productized-skill/sql/step.sql.tmpl +0 -0
  80. {ticketwright-3.2.0 → ticketwright-3.4.0}/templates/productized-skill/templates/README.md.tmpl +0 -0
  81. {ticketwright-3.2.0 → ticketwright-3.4.0}/templates/productized-skill/templates/tracker_comment.txt.tmpl +0 -0
  82. {ticketwright-3.2.0 → ticketwright-3.4.0}/templates/roles/analyst.md +0 -0
  83. {ticketwright-3.2.0 → ticketwright-3.4.0}/templates/roles/engineer.md +0 -0
  84. {ticketwright-3.2.0 → ticketwright-3.4.0}/templates/roles/generalist.md +0 -0
  85. {ticketwright-3.2.0 → ticketwright-3.4.0}/templates/roles/scientist.md +0 -0
  86. {ticketwright-3.2.0 → ticketwright-3.4.0}/templates/spec.md.tmpl +0 -0
  87. {ticketwright-3.2.0 → ticketwright-3.4.0}/templates/ticket-README.md.tmpl +0 -0
@@ -59,7 +59,7 @@ seams:
59
59
 
60
60
  policies:
61
61
  hard_halt_before_external_posts: true
62
- db_write_requires_approval: true
62
+ db_write_requires_approval: high_risk # off | high_risk | all — see stack.schema.md
63
63
  chat_default_draft: true
64
64
  hyperlink_everything: true
65
65
  skillify_everything: true
@@ -64,7 +64,7 @@ seams:
64
64
 
65
65
  policies:
66
66
  hard_halt_before_external_posts: true
67
- db_write_requires_approval: true
67
+ db_write_requires_approval: high_risk # off | high_risk | all — see stack.schema.md
68
68
  chat_default_draft: true
69
69
  hyperlink_everything: true
70
70
  skillify_everything: true
@@ -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
@@ -0,0 +1,157 @@
1
+ # `stack.yaml` — the tool registry (schema + policies)
2
+
3
+ `stack.yaml` is the **single source of truth** for which concrete tool fills each abstract "seam"
4
+ and for the project facts the skills need. Skills never hardcode `acli`, `snow`, `slack`, channel
5
+ IDs, epics, or paths — those live **here** and in the per-tool adapters. Swapping Jira→Asana or
6
+ Snowflake→BigQuery means editing this file and pointing at a different adapter; **no skill changes.**
7
+
8
+ The `setup` skill writes this file by interviewing you and detecting installed tooling.
9
+ `bin/verify_stack.sh` reads it to smoke-test every seam. Every skill reads it at preflight.
10
+
11
+ ---
12
+
13
+ ## Top-level shape
14
+
15
+ ```yaml
16
+ project: # facts about this workspace, tool-independent
17
+ seams: # one entry per abstract seam → concrete tool + adapter + verify
18
+ policies: # behavioral rules every skill inherits (the kit's "global rules")
19
+ ```
20
+
21
+ ---
22
+
23
+ ## `project`
24
+
25
+ | Field | Type | Example | Meaning |
26
+ |---|---|---|---|
27
+ | `key_prefix` | string | `ENG` | Ticket-ID prefix. Branch names = `{key_prefix}-NNNN`. |
28
+ | `key_prefixes` | list | `[ENG]` | Prefixes the ticket index recognizes in folder names. Optional; defaults to `[key_prefix]`. Use when one repo holds tickets from several trackers (e.g. `[ENG, OPS]`). |
29
+ | `assignee_dir` | string | `alice` | Default owner subdir under `tickets/`. |
30
+ | `ticket_path` | template | `tickets/{assignee}/{id}` | Where a ticket folder lives. `{assignee}` `{id}` tokens. |
31
+ | `ticket_subdirs` | list | `[source_materials, final_deliverables, qc_queries, exploratory_analysis]` | Scaffolded per ticket. |
32
+ | `default_epic` | string \| null | `ENG-100` | Parent epic for newly created tickets (null if tracker has no epics). |
33
+ | `terminal_status` | string | `Done` | The "done" workflow state (not always "Done"). |
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
+ | `word_limits` | map | `{tracker_comment: 100, chat: 100, pr: 200, ticket: 200}` | Hard caps the comms skills enforce. |
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`. |
38
+
39
+ ## `seams`
40
+
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:
44
+
45
+ | Field | Type | Meaning |
46
+ |---|---|---|
47
+ | `tool` | string | The concrete tool, e.g. `jira` / `asana` / `monday` / `linear`. |
48
+ | `adapter` | path | The playbook that maps the verb contract → this tool's commands. |
49
+ | `verify` | string \| null | A **read-only** smoke-test command. `{token}` interpolation from this seam's own keys + `project`. `null` = skip (skills warn). Non-zero exit ⇒ seam "unreachable". |
50
+ | `transport` | enum | `cli` \| `mcp` \| `both` — how the adapter talks to the tool. Drives the verify fallback. |
51
+ | *(extra keys)* | any | Tool-specific config the adapter reads (site, warehouse, role, channel, base_path, …). |
52
+
53
+ The `warehouse` seam may also be `null`/omitted for non-data repos — `review`, `spec-and-build`,
54
+ and `refresh context` degrade gracefully (skip warehouse steps) when it is.
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
+
101
+ ## `policies` (the 9 kit policies — see kit README "AI-layer" section)
102
+
103
+ | Policy | Type | Default | Enforced by |
104
+ |---|---|---|---|
105
+ | `hard_halt_before_external_posts` | bool | `true` | `ship`, every productized skill — pause for human go before any tracker/chat/docstore write. |
106
+ | `db_write_requires_approval` | enum | `high_risk` | the `db_write_guard` hook (Claude Code) + any skill issuing a non-SELECT. See below. |
107
+ | `chat_default_draft` | `true` | `chat.draft` not `chat.send` unless the user says "send it". |
108
+ | `hyperlink_everything` | `true` | comms skills wrap every ticket-ID / file / PR in a smart link. |
109
+ | `skillify_everything` | `true` | recurring work → a `/productize` skill the agent can invoke, not a one-off. |
110
+ | `reduce_assumptions` | `true` | ask before building; still document every assumption in the ticket README. |
111
+ | `commit_plan_before_implement` | `true` | `spec-and-build` commits the spec/plan artifact before `build` (blame-free retry). |
112
+ | `system_evolution` | `true` | `ship` retro: a failure fixes the AI layer (rule/context/command/adapter), not just the ticket. |
113
+ | `deterministic_outputs` | `true` | data exports use explicit `ORDER BY`; productized skills ship golden-replay diffs. |
114
+
115
+ All are booleans except `db_write_requires_approval`.
116
+
117
+ ### `db_write_requires_approval` — the one enum
118
+
119
+ | Value | Behavior | Also accepts |
120
+ |---|---|---|
121
+ | `off` | Never ask. | `false`, `none`, `null` |
122
+ | `high_risk` | **Default.** Ask only for irreversible, access-changing, or unrecognized SQL. | `true`, `destructive` |
123
+ | `all` | Ask for any mutation at all. | `strict`, `always` |
124
+
125
+ Reads (`SELECT`/`SHOW`/`DESCRIBE`/`EXPLAIN`/`WITH … SELECT`/`LIST`/`USE`) pass in every value;
126
+ `all` means every *write*, not every command.
127
+
128
+ Classification is **default-deny**. Only a narrow allowlist is treated as additive — plain
129
+ `CREATE` (no `OR REPLACE`), `INSERT INTO` (no `OVERWRITE`), `ALTER … ADD`, and `COMMENT ON`.
130
+ Everything else that mutates is high-risk, *including anything the scanner does not recognize*.
131
+ Enumerating dangerous forms instead would leave holes: `ALTER TABLE … MODIFY COLUMN` can change
132
+ a type and truncate data while matching no plausible "destructive verb" list.
133
+
134
+ A missing, malformed, or unrecognized value resolves to `all`, never to something weaker —
135
+ unparseable config must not quietly widen what runs unprompted. Note the asymmetry: an explicit
136
+ legacy `true` *does* relax to `high_risk`, because it is a value someone chose rather than a
137
+ value the parser failed on.
138
+
139
+ Under `bypassPermissions` the hook stays silent and emits a `systemMessage` instead of asking —
140
+ the operator has already opted out of prompting, so a prompt there is incoherent. Every other
141
+ permission mode gets a normal `ask`. For agents other than Claude Code this policy is
142
+ **guidance, not enforcement**: they read it in `AGENTS.md` and are trusted to honor it, since
143
+ hooks are the only mechanically enforced layer.
144
+
145
+ `always_include` (under `seams.chat`) — names always added to a chat message (e.g. `[Alice]`); the
146
+ "never solo-DM a stakeholder" rule.
147
+
148
+ ---
149
+
150
+ ## Worked example
151
+
152
+ A worked example lives at [`stack.yaml`](stack.yaml) (Jira/Snowflake/Slack/Drive/GitHub). Three more
153
+ prove the abstraction holds with zero skill edits: `stack.example.asana-bq.yaml`
154
+ (Asana/BigQuery/Teams/SharePoint/GitLab), `stack.example.azure.yaml`
155
+ (Azure DevOps/Synapse/Teams/SharePoint/Azure Repos), and `stack.example.multi-warehouse.yaml`
156
+ (Jira/**Snowflake + Databricks**/Slack/Drive/GitHub — the named-targets shape above). To validate any
157
+ 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:
@@ -67,7 +68,7 @@ seams:
67
68
 
68
69
  policies:
69
70
  hard_halt_before_external_posts: true
70
- db_write_requires_approval: true
71
+ db_write_requires_approval: high_risk # off | high_risk | all — see stack.schema.md
71
72
  chat_default_draft: true
72
73
  hyperlink_everything: true
73
74
  skillify_everything: true
@@ -0,0 +1,139 @@
1
+ """Shared stack.yaml resolution + reading for the hooks. Stdlib only, no yaml dep.
2
+
3
+ The four hooks each grew their own root-resolution and their own mini-YAML scanner,
4
+ three mutually inconsistent ways. This module is the seam they should converge on;
5
+ `db_write_guard` uses it today, the others can migrate without behavior change.
6
+
7
+ Two properties matter more than convenience here:
8
+
9
+ 1. **No kit-relative fallback.** Resolution stops at the project. A hook that falls
10
+ back to the kit's own shipped `stack.yaml` silently activates outside a
11
+ configured repo and reads the worked example's policy instead of the user's —
12
+ which is exactly how a "repo-gated" guard ends up firing everywhere.
13
+ 2. **Block-scoped lookups.** `_block_lines` walks indentation rather than pattern-matching
14
+ across the whole document, so a key is read from the block that actually owns it.
15
+
16
+ Seam blocks are *not* handled here — `db_write_guard.seam_block` already does that, with
17
+ harder-won handling for inferred indent, anchors, flow mappings, and block scalars. This
18
+ module covers the top-level `policies:` mapping only; duplicating a second seam parser
19
+ would just create two things to keep correct.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import re
25
+ from pathlib import Path
26
+
27
+ STACK_REL = ".claude/config/stack.yaml"
28
+
29
+
30
+ def _iter_up(start: Path):
31
+ """Yield `start` and its ancestors, stopping at (and including) the repo root.
32
+
33
+ Bounded by `.git` so a subdirectory invocation still finds the project's config
34
+ without escaping into a parent checkout that happens to have one.
35
+ """
36
+ try:
37
+ cur = start.resolve()
38
+ except OSError:
39
+ return
40
+ while True:
41
+ yield cur
42
+ if (cur / ".git").exists() or cur.parent == cur:
43
+ return
44
+ cur = cur.parent
45
+
46
+
47
+ def find_stack(cwd: str | None) -> Path | None:
48
+ """Locate the *project's* stack.yaml, or None. None means "not a ticketwright repo"."""
49
+ project_dir = os.environ.get("CLAUDE_PROJECT_DIR")
50
+ if project_dir:
51
+ candidate = Path(project_dir) / STACK_REL
52
+ if candidate.is_file():
53
+ return candidate
54
+ if cwd:
55
+ for directory in _iter_up(Path(cwd)):
56
+ candidate = directory / STACK_REL
57
+ if candidate.is_file():
58
+ return candidate
59
+ return None
60
+
61
+
62
+ def read_text(stack: Path | None) -> str:
63
+ if stack is None:
64
+ return ""
65
+ try:
66
+ return stack.read_text(errors="replace")
67
+ except OSError:
68
+ return ""
69
+
70
+
71
+ def _block_lines(text: str, key: str) -> list[str]:
72
+ """Return the lines nested under `key:`, by indentation.
73
+
74
+ Stops at the first non-blank, non-comment line indented at or below the key's own
75
+ level — i.e. the next sibling. Blank and comment lines inside the block are kept
76
+ so they never terminate it early.
77
+ """
78
+ base: int | None = None
79
+ header = re.compile(rf"^(\s*){re.escape(key)}\s*:\s*(?:#.*)?$")
80
+ out: list[str] = []
81
+ for line in text.splitlines():
82
+ if base is None:
83
+ match = header.match(line)
84
+ if match:
85
+ base = len(match.group(1))
86
+ continue
87
+ if not line.strip() or line.lstrip().startswith("#"):
88
+ out.append(line)
89
+ continue
90
+ if len(line) - len(line.lstrip()) <= base:
91
+ break
92
+ out.append(line)
93
+ return out
94
+
95
+
96
+ def _unquote(value: str) -> str:
97
+ value = value.strip()
98
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
99
+ return value[1:-1]
100
+ # Only strip a trailing comment from an unquoted scalar; a quoted one may contain '#'.
101
+ return value.split("#", 1)[0].strip()
102
+
103
+
104
+ def _scalar(lines: list[str], key: str) -> str | None:
105
+ pattern = re.compile(rf"^\s*{re.escape(key)}\s*:\s*(\S.*)$")
106
+ for line in lines:
107
+ match = pattern.match(line)
108
+ if match:
109
+ return _unquote(match.group(1))
110
+ return None
111
+
112
+
113
+ # ---- the db_write_requires_approval policy -------------------------------------
114
+
115
+ MODE_OFF = "off"
116
+ MODE_HIGH_RISK = "high_risk"
117
+ MODE_ALL = "all"
118
+
119
+ POLICY_KEY = "db_write_requires_approval"
120
+
121
+ # Legacy `true` relaxes to high_risk (v3.4 behavior change). A missing, malformed, or
122
+ # unrecognized value does NOT — unparseable config must never widen what runs
123
+ # unprompted, so it falls back to `all`.
124
+ _ALIASES = {
125
+ "off": MODE_OFF, "false": MODE_OFF, "no": MODE_OFF, "none": MODE_OFF, "null": MODE_OFF,
126
+ "high_risk": MODE_HIGH_RISK, "high-risk": MODE_HIGH_RISK,
127
+ "destructive": MODE_HIGH_RISK, "true": MODE_HIGH_RISK, "yes": MODE_HIGH_RISK,
128
+ "all": MODE_ALL, "strict": MODE_ALL, "always": MODE_ALL,
129
+ }
130
+
131
+
132
+ def db_write_mode(stack: Path | None) -> str:
133
+ text = read_text(stack)
134
+ if not text:
135
+ return MODE_ALL
136
+ raw = _scalar(_block_lines(text, "policies"), POLICY_KEY)
137
+ if raw is None:
138
+ return MODE_ALL
139
+ return _ALIASES.get(raw.strip().lower(), MODE_ALL)