masterly 0.2.0__tar.gz → 0.5.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 (49) hide show
  1. masterly-0.5.0/.claude/PROJECT-CLAUDE.md +138 -0
  2. masterly-0.5.0/.claude/hooks/project-claude-md.sh +299 -0
  3. masterly-0.5.0/.claude/settings.json +14 -0
  4. masterly-0.5.0/.github/dependabot.yml +30 -0
  5. masterly-0.5.0/.github/workflows/ci.yml +84 -0
  6. masterly-0.5.0/.github/workflows/release.yml +100 -0
  7. masterly-0.5.0/.github/workflows/workflow-hardening.yml +89 -0
  8. masterly-0.5.0/.github/zizmor.yml +46 -0
  9. masterly-0.5.0/PKG-INFO +592 -0
  10. masterly-0.5.0/README.md +563 -0
  11. masterly-0.5.0/RELEASING.md +107 -0
  12. masterly-0.5.0/SECURITY.md +62 -0
  13. {masterly-0.2.0 → masterly-0.5.0}/examples/README.md +98 -18
  14. {masterly-0.2.0 → masterly-0.5.0}/examples/demo_data.py +562 -46
  15. masterly-0.5.0/pyproject.toml +88 -0
  16. masterly-0.5.0/scripts/check_release_record.py +477 -0
  17. masterly-0.5.0/src/masterly/__init__.py +42 -0
  18. masterly-0.5.0/src/masterly/_client.py +337 -0
  19. {masterly-0.2.0 → masterly-0.5.0}/src/masterly/_config.py +103 -0
  20. {masterly-0.2.0 → masterly-0.5.0}/src/masterly/_extract.py +69 -12
  21. masterly-0.5.0/src/masterly/_ingest.py +878 -0
  22. masterly-0.5.0/src/masterly/_jobs.py +87 -0
  23. {masterly-0.2.0 → masterly-0.5.0}/src/masterly/_paging.py +3 -3
  24. masterly-0.5.0/src/masterly/cli/__init__.py +99 -0
  25. masterly-0.5.0/src/masterly/cli/__main__.py +9 -0
  26. masterly-0.5.0/src/masterly/cli/_engine.py +305 -0
  27. masterly-0.5.0/src/masterly/cli/_schema.py +187 -0
  28. masterly-0.5.0/tests/test_cli_migrate.py +379 -0
  29. masterly-0.5.0/tests/test_client.py +703 -0
  30. {masterly-0.2.0 → masterly-0.5.0}/tests/test_config.py +162 -1
  31. masterly-0.5.0/tests/test_demo_data.py +571 -0
  32. masterly-0.5.0/tests/test_receipts_jobs_loads.py +374 -0
  33. masterly-0.5.0/tests/test_source_ignored_fields.py +163 -0
  34. masterly-0.5.0/tests/test_source_lifecycle.py +334 -0
  35. masterly-0.5.0/tests/test_source_targets.py +551 -0
  36. {masterly-0.2.0 → masterly-0.5.0}/uv.lock +289 -201
  37. masterly-0.2.0/.github/workflows/release.yml +0 -43
  38. masterly-0.2.0/PKG-INFO +0 -175
  39. masterly-0.2.0/README.md +0 -148
  40. masterly-0.2.0/pyproject.toml +0 -65
  41. masterly-0.2.0/src/masterly/__init__.py +0 -29
  42. masterly-0.2.0/src/masterly/_client.py +0 -180
  43. masterly-0.2.0/src/masterly/_ingest.py +0 -155
  44. masterly-0.2.0/tests/test_client.py +0 -361
  45. masterly-0.2.0/tests/test_demo_data.py +0 -260
  46. {masterly-0.2.0 → masterly-0.5.0}/.gitignore +0 -0
  47. {masterly-0.2.0 → masterly-0.5.0}/LICENSE +0 -0
  48. {masterly-0.2.0 → masterly-0.5.0}/src/masterly/_precondition.py +0 -0
  49. {masterly-0.2.0 → masterly-0.5.0}/src/masterly/py.typed +0 -0
@@ -0,0 +1,138 @@
1
+ # Agent guidance for Masterly (project-wide)
2
+
3
+ This file is the **shared context for every Claude session in any Masterly repo**. Claude Code loads it automatically when working inside `/Users/clarsson/projects/masterly/` or any subdirectory.
4
+
5
+ Per-repo `CLAUDE.md` files (in each repo root) add repo-specific guidance on top of this one. Architectural source of truth is `masterly-framework/docs/`.
6
+
7
+ ## What Masterly is
8
+
9
+ Masterly is a modern Master Data Management (MDM) SaaS for organizations replacing Excel or no-tool today. Target customers run Databricks, Snowflake, or Microsoft Fabric. Masterly is an **active processing pipeline** (REST/Stream/MCP ingest → Identity Resolution → Golden Resolution → Data Quality → REST/Stream/MCP extract), not just a catalog or metadata layer.
10
+
11
+ For the full picture: [`masterly-framework/docs/product/overview.md`](masterly-framework/docs/product/overview.md).
12
+
13
+ ## Hard architectural constraints
14
+
15
+ These are non-negotiable. Every design must satisfy all of them:
16
+
17
+ 1. **Two-axis deployment factoring** — compute tier (`shared` | `dedicated` | `self-hosted`) × data tier (`masterly-db` | `byo-db`) yields 5 valid combinations. Self-hosted is the initial GTM. The legacy term "BYOC" is retired; use "BYO-DB" on the relevant compute tier. See [`masterly-framework/docs/architecture/deployment-models.md`](masterly-framework/docs/architecture/deployment-models.md) and [ADR 0002](masterly-framework/docs/adr/0002-deployment-topology.md).
18
+ 2. **Region pinning per Environment** — EU and US at minimum. No cross-region data leakage in logs, telemetry, or backups.
19
+ 3. **DBU instrumentation on every tier; billing only on usage tiers** — every meaningful operation emits a DBU event to the Environment's local `dbu_events` ledger, on all five combinations. `billing_mode` never gates emission ([ADR 0034](masterly-framework/docs/adr/0034-dbu-event-taxonomy-weights-and-usage-reporting.md)). What is gated is *export and invoicing*: only `billing_mode == "usage"` (Shared MT, Dedicated) ships aggregates to Masterly's metering store, via the telemetry receiver. Self-hosted is fixed-price — local usage visibility, no billing pipeline.
20
+ 4. **Stateless services** — no in-memory session state. Postgres for durable data-plane state, Cosmos NoSQL for control-plane state, Redis for cache/ephemeral.
21
+ 5. **Local-LLM support** — self-hosted and BYO-DB customers will not ship master data to OpenAI/Anthropic. The AI Router/Gateway abstracts the LLM.
22
+ 6. **Configuration is managed, not ad-hoc** — customer config (data models, rules, mappings) is versioned, JSON-Schema-validated, and promotable across Environments. Two modes per Environment ([ADR 0026](masterly-framework/docs/adr/0026-config-management-modes.md)): **Managed (Click-Ops, default)** — stored and versioned in the product — or **GitOps** — config-as-code in the customer's repo. GitOps is optional.
23
+ 7. **Data never lives in configuration** — master data, golden records, and IR decisions stay in Postgres, never in Git or a config snapshot.
24
+
25
+ ## Canonical hierarchy and terminology
26
+
27
+ ```
28
+ Organization (paying account, = Stytch Organization)
29
+ └── Environment (e.g. prd-eu, prd-us, non-prd-eu — region pinning lives here)
30
+ └── Workspace
31
+ └── Domain
32
+ └── Data Model
33
+ └── Data Product
34
+ ```
35
+
36
+ **Use "Environment," not "Tenant."** Earlier brand materials used "Tenant" for what is now called Environment; that term is deprecated.
37
+
38
+ **Unit of data isolation = Environment.** The data plane is provisioned per-Environment (DB-per-Environment), not per-Organization.
39
+
40
+ **Deployment grouping = Install** ([ADR 0039](masterly-framework/docs/adr/0039-installs-as-environment-deployment-grouping.md)). An **Install** is one deployment of the stack (one compute unit + control plane, one `terraform apply`) that hosts a set of an Org's Environments: `Organization 1—N Install 1—N Environment`. It is a **deployment-axis** concept, **not** a level in the logical hierarchy above — navigation stays Organization → Environment → Workspace, and the user's Environment switcher is Org-scoped and Install-invisible. It is what lets a customer separate *infrastructure* (e.g. prod vs non-prod in different subscriptions, on different versions). First-class on the `self-hosted` and `dedicated` tiers; degenerate on `shared`.
41
+
42
+ ## Repo map
43
+
44
+ All repos live under `github.com/masterly-data/`. Local clones expected under `/Users/clarsson/projects/masterly/`.
45
+
46
+ | Repo | Purpose | Layer(s) |
47
+ |---|---|---|
48
+ | `masterly-framework` | **Architectural source of truth** — ADRs, architecture docs, design system, this CLAUDE.md, conventions | All — shared |
49
+ | `masterly-platform-iac` | Terraform for Masterly's platform infrastructure | Layers 1 (shared) + 2 (regional CP) |
50
+ | `masterly-shared-iac` | Terraform for Shared MT data plane | Layer 3 (placeholder until first Shared MT customer) |
51
+ | `masterly-dedicated-iac` | Terraform for per-customer Dedicated cells | Layer 4 (placeholder until first Dedicated customer) |
52
+ | `terraform-azurerm-masterly` | **Public** (Apache 2.0) Terraform module customers and partners consume at a semver tag. No credential to fetch — the gate is images + licence ([ADR 0067 amendment 2026-09-01](masterly-framework/docs/adr/0067-customer-artifact-distribution.md)). | Layer 5 — the shipped module |
53
+ | `masterly-demo-iac` | Private: **Masterly's own demo install only** (`deployments/demo-eu`) plus the workflows that plan/apply/operate it. Renamed from `masterly-self-hosted-iac` on 2026-09-07, when the module copy at its root was deleted (MAS-42) — **the module lives in `terraform-azurerm-masterly` and nowhere else.** Not what customers consume. | Layer 5 — internal |
54
+ | `masterly-platform-backend` | FastAPI control-plane services: `license-issuer`, future `tenant-router` + `billing-aggregator`. Masterly-operated; never ships to customers. | Layer 2 (regional CP) |
55
+ | `masterly-application-backend` | FastAPI product application: `api`, `workers`, future `ai-router`, plus business modules (IR, golden, DQ). One codebase across all deployment models. | All — application code (data plane) |
56
+ | `masterly-application-frontend` | Next.js + Stytch B2B user sessions. Ships with the application backend across all deployment models. | All — application code |
57
+ | `masterly-python-sdk` | **Public** (Apache-2.0) **Python client** (`pip install masterly`, v0.2.0 on PyPI) — extract (products/changes/golden) + ingest + configuration (workspaces/domains/data-models/sources) over `/v1` (ADR 0068), plus `examples/demo_data.py` for demo master data. Development history through v0.2.0 lives in the private `masterly-python-sdk-archive`. | Customer-side tooling |
58
+ | `masterly-web` | **Public web** — marketing site (`masterlydata.com`) + customer docs (`/docs`). Astro + Starlight, static, on Azure Static Web Apps. Masterly-operated; ships to no customer; shares only design tokens with the frontend. See [ADR 0030](masterly-framework/docs/adr/0030-public-web-marketing-and-docs.md). | Public web (marketing + docs) |
59
+ | `masterly-demo01-cac` | GitOps config-as-code for the demo install: Data Models, IR rules, `modules.yaml`. (Named `demo-config` in older docs and in ADRs 0012/0014 — those record history; this is the live name.) | Layer 5 — demo install |
60
+
61
+ The demo install (subscription `msly-demo-01-eu`) lives in `masterly-demo-iac/deployments/demo-eu/` and consumes the **published** module at a pinned version (`source = "masterly-data/masterly/azurerm"`), exactly as a customer does. It used to consume it by local path, which dogfooded every module change against the live demo for free; that no longer happens, so **plan a live install against the module branch before tagging a module release** (decided 2026-06-11; changed 2026-08-31 when the module was published).
62
+
63
+ ## Tech stack
64
+
65
+ - **Backend:** Python + FastAPI, stateless containerized microservices, sync + async patterns
66
+ - **Frontend:** TypeScript + Next.js
67
+ - **Data plane:** PostgreSQL + Redis (per-Environment)
68
+ - **Control plane:** Cosmos DB NoSQL with multi-region writes (see [ADR 0016](masterly-framework/docs/adr/0016-cosmos-db-for-layer-2.md))
69
+ - **Hosting:** Azure Container Apps
70
+ - **Auth:** Stytch B2B (multi-org, multi-connection per org)
71
+ - **IaC:** Terraform throughout, OIDC to Azure (see [ADR 0015](masterly-framework/docs/adr/0015-terraform-as-iac.md), [ADR 0017](masterly-framework/docs/adr/0017-github-actions-terraform-cd.md))
72
+ - **CD:** GitHub Actions with OIDC federation, plan-on-PR + apply-on-merge with Environment approval
73
+
74
+ API-first. The REST API contract is the primary interface. MCP, Stream, REST, and GUI are equally first-class channels.
75
+
76
+ ## Design system
77
+
78
+ The Masterly design system lives in [`masterly-framework/design-system/`](masterly-framework/design-system/). When working on any UI surface (Next.js frontend, marketing pages, decks, prototypes):
79
+
80
+ - **Tokens:** [`masterly-framework/design-system/colors_and_type.css`](masterly-framework/design-system/colors_and_type.css) is the single source of truth. Never hard-code colors, type sizes, or spacing.
81
+ - **Brand fundamentals:** [`masterly-framework/design-system/README.md`](masterly-framework/design-system/README.md) — dark-first, warm-gradient, sharp-typographic. The signature `.` after "Masterly." is non-negotiable.
82
+ - **Component reference:** [`masterly-framework/design-system/ui_kits/product/`](masterly-framework/design-system/ui_kits/product/) shows the product surface conventions.
83
+ - **No emoji** in Masterly materials. Use Phosphor / Lucide / Heroicons outline sets.
84
+ - **Magenta is sparingly used** — 60% dark, 30% gradient, 10% magenta accent.
85
+ - **Sentence case** for all UI labels, buttons, and headings except the wordmark "Masterly.".
86
+
87
+ ## Decision-making protocol
88
+
89
+ | Decision type | Where it lands |
90
+ |---|---|
91
+ | **New cross-cutting architectural decision** (affects multiple repos, hard to reverse) | New ADR in [`masterly-framework/docs/adr/`](masterly-framework/docs/adr/) using [`template.md`](masterly-framework/docs/adr/template.md). Mark Proposed; the user accepts it — and an acceptance is not complete until a card has carried that status into the ADR and its index row on `origin/main`, opened in the same pass the acceptance is recorded ([lifecycle](masterly-framework/docs/operating-model/lifecycle.md)). |
92
+ | **Open question** that doesn't yet have a decision | New or updated file in [`masterly-framework/docs/architecture/open-questions/`](masterly-framework/docs/architecture/open-questions/) |
93
+ | **Cross-cutting convention** (naming, error handling, file layout patterns) | New or updated file in [`masterly-framework/docs/conventions/`](masterly-framework/docs/conventions/) |
94
+ | **Architecture detail** (how a subsystem actually works) | New or updated file in [`masterly-framework/docs/architecture/`](masterly-framework/docs/architecture/) |
95
+ | **Repo-local convention** (file structure inside one repo, build patterns specific to that repo) | That repo's `CLAUDE.md` or `README.md` — not the framework |
96
+ | **Operational runbook** | That repo's `RUNBOOK.md` or similar — not the framework |
97
+
98
+ **Rule of thumb:** if a decision affects how a second engineer would build a feature in a *different* repo, it belongs in `masterly-framework`. If it affects how to wire one feature together in *this* repo, it stays local.
99
+
100
+ ## Naming conventions (cross-repo)
101
+
102
+ | Surface | Pattern | Example |
103
+ |---|---|---|
104
+ | Domain (primary) | `masterlydata.com` | |
105
+ | Subdomains | `<purpose>.masterlydata.com` | `cp.masterlydata.com`, `app.masterlydata.com`, `api.masterlydata.com` |
106
+ | Azure subscriptions | `msly-<layer>-<env>-<geo>[-<slug>]` | `msly-shared-prod`, `msly-cp-prod-eu`, `msly-demo-eu` |
107
+ | Azure resource groups (Masterly subs) | `rg-msly-<layer>-<purpose>-<cell>` | `rg-msly-shared-security` (shared = no cell), `rg-msly-cp-aca-eu01` |
108
+ | Azure resource groups (customer self-hosted) | `rg-masterly-<install>-<purpose>` ([ADR 0039](masterly-framework/docs/adr/0039-installs-as-environment-deployment-grouping.md)) | `rg-masterly-prod-aca`, `rg-masterly-nonprod-data` |
109
+ | ACA Environments | `aca-<layer>-<cell>[-<slug>]` | `aca-cp-eu01`, `aca-demo-eu01` |
110
+ | Container Apps | `ca-<app-name>` | `ca-license-issuer`, `ca-frontend`, `ca-api`, `ca-workers` |
111
+ | Cosmos DB (control plane) | `masterly-cp-cosmos-<cell>` | `masterly-cp-cosmos-eu01` (serverless, single-region per cell in v1 — ADR 0016) |
112
+ | Container Registry | `masterly.azurecr.io` (single registry, geo-replicated) | |
113
+
114
+ **Geo vs cell vs region** (per [ADR 0022](masterly-framework/docs/adr/0022-cell-based-resource-naming.md)):
115
+ - **Geo** (`eu`, `us`) — the data-residency boundary. Lives in the **subscription / Environment** name (`msly-cp-prod-eu`, Environment `prd-eu`).
116
+ - **Cell** — a numbered regional instance within a geo: `<geo><NN>` (`eu01`, `eu02`, `us01`), carried on **resources**. Dedicated (Layer 4) cells use a customer slug instead (`msly-cell-<customer>-<region>`). Shared (Layer 1) resources are cross-cell and carry **no** cell suffix.
117
+ - **Physical Azure region** (e.g. Sweden Central — [ADR 0021](masterly-framework/docs/adr/0021-eu-control-plane-in-sweden-central.md)) is **never baked into a name** — it is the resource's native `location`, so a cell can move regions without a rename.
118
+
119
+ Required Azure tags on every RG, now genuinely enforced by Azure Policy (`msly-required-rg-tags`, `Deny`, on both production subscriptions — see `masterly-platform-iac/bootstrap/policy/README.md`): **seven** — `cost-center`, `data-residency`, `deployment-model`, `owner`, `lifecycle`, `environment`, `region`. (This line listed only five until 2026-09-07; `platform-iac-resources.md` §1 is the source of truth.) **Azure reserves tag-name prefixes `azure`, `microsoft`, `windows`** — never start a tag name with them (use e.g. `cloud-region`, not `azure-region`).
120
+
121
+ ## Working style
122
+
123
+ - **Foundation-first.** Get pluggable data storage, tenant isolation, deployment topology, and DBU instrumentation right *before* shipping features. Lesson from the PoC.
124
+ - **Be explicit.** Solo founder + agents today; team later. Write as if a smart engineer (or agent) is picking this up cold next month.
125
+ - **Push back on the user when their request conflicts with a hard constraint above.** Don't silently relax constraints.
126
+ - **No code or infra changes without an ADR for cross-cutting decisions.** Repo-local changes don't need ADRs.
127
+ - **Multi-repo work:** when a change spans repos, decide if the *decision* is shared (ADR in framework) or *implementation only* (per-repo PR). Don't duplicate the decision across repos.
128
+ - **Customer docs ship with the product.** A customer-facing change (capability, API, MCP tool, config, deployment, or behavior) isn't done until the public docs in `masterly-web` reflect it — see the [customer-docs convention](masterly-framework/docs/conventions/customer-docs.md). Internal-only changes need none; say which in the PR.
129
+
130
+ ## Reading order for a new session
131
+
132
+ If you're a Claude session just opening in any Masterly repo, read in this order:
133
+
134
+ 1. **This file** (project-wide guidance) — already auto-loaded
135
+ 2. **The local repo's `CLAUDE.md`** (per-repo specifics) — already auto-loaded
136
+ 3. **`masterly-framework/docs/architecture/build-order-and-phases.md`** — current roadmap and where this repo fits in
137
+ 4. **ADRs relevant to the work at hand** — see references in the local `CLAUDE.md`
138
+ 5. **The architecture doc(s) for the specific subsystem** — see `masterly-framework/docs/architecture/`
@@ -0,0 +1,299 @@
1
+ #!/bin/bash
2
+ # Install the project-wide CLAUDE.md where a Claude Code cloud session loads it (MAS-881).
3
+ #
4
+ # One source (the MAS-546 shape): the canonical copy of this hook is masterly-framework
5
+ # repo-templates/project-claude-md/project-claude-md.sh. Every Masterly repository holds a
6
+ # VERBATIM copy at .claude/hooks/project-claude-md.sh, registered as a synchronous SessionStart
7
+ # hook by .claude/settings.json from the same template, with the vendored fallback beside it at
8
+ # .claude/PROJECT-CLAUDE.md. Change it in masterly-framework and re-copy; never edit a copy in
9
+ # place.
10
+ #
11
+ # Why this exists. Every Masterly repository's CLAUDE.md opens by saying that the project-wide
12
+ # context is loaded from ../CLAUDE.md, one directory above the clone. On a developer's machine
13
+ # that path is a symlink installed once by hand and verified (masterly-framework,
14
+ # docs/conventions/project-wide-claude-md.md). A Claude Code cloud container is provisioned
15
+ # fresh, with nobody at a shell to run `ln -s`, so the path was simply absent -- and every cloud
16
+ # session ran without the hard architectural constraints, silently, because a session missing
17
+ # this file has no way to notice. This hook is the cloud analogue of that one-time install.
18
+ #
19
+ # What it does, in order:
20
+ # 1. Nothing unless CLAUDE_CODE_REMOTE is "true". It never touches a developer's machine,
21
+ # where the link is installed and verified by hand, and it prints nothing there.
22
+ # 2. Derives every path; none is typed. The clones directory is the parent of
23
+ # CLAUDE_PROJECT_DIR, the repository root Claude Code hands a hook, and the target is
24
+ # <clones>/CLAUDE.md -- an ancestor of the working directory, which is what Claude Code
25
+ # loads. If CLAUDE_PROJECT_DIR is not set, nothing is derived and nothing is installed.
26
+ # 3. Chooses the source, preferring the one that cannot drift:
27
+ # a. <clones>/masterly-framework/PROJECT-CLAUDE.md -- the tracked original, when the
28
+ # framework repository is cloned alongside;
29
+ # b. otherwise <repo>/.claude/PROJECT-CLAUDE.md -- the vendored copy shipped beside
30
+ # this hook: byte-identical to the tracked file when it was copied, and behind it
31
+ # from the tracked file's next change until it is re-copied. masterly-framework's
32
+ # CI proves the template equals the tracked file; nothing in a consuming repository's
33
+ # CI can see whether its copy is current.
34
+ # 4. Links the target to the source by a relative path and reports one line on stdout,
35
+ # which the SessionStart hook contract adds to the session's context -- so the session
36
+ # can see which source it is reading, and a person can compare it with /context.
37
+ #
38
+ # What it never does: destroy content. The target is a place a person or a platform may have
39
+ # put a file, so:
40
+ # * anything at the target that is not a symlink is left exactly as it is. A regular file
41
+ # whose bytes equal the chosen source is reported as an installed plain copy; anything else
42
+ # is reported as foreign, loudly, because sessions in that container are then loading a
43
+ # document this repository does not govern -- and it is still not replaced, because
44
+ # replacing it is the clobber this hook exists not to do;
45
+ # * a symlink that already resolves to the chosen source is left as it is;
46
+ # * a symlink that resolves anywhere else, or nowhere, is re-pointed. A symlink carries no
47
+ # content, so re-pointing it loses nothing; and in a remote container this hook is the only
48
+ # thing that makes one, so a link to a lesser source is this hook's own earlier choice in a
49
+ # container whose clone set has changed since (the container is cached between sessions).
50
+ #
51
+ # Synchronous, never async. Printing {"async": true} would let the session start before the
52
+ # link exists, which is exactly the race this hook exists to close. The selftest asserts that
53
+ # nothing this hook prints can be read as that marker.
54
+ #
55
+ # --selftest builds every case above in a temporary directory and asserts each outcome, so a
56
+ # hook that has quietly stopped installing fails CI instead of passing for the wrong reason.
57
+ # It exercises this file itself, with CLAUDE_CODE_REMOTE and CLAUDE_PROJECT_DIR set per case.
58
+
59
+ set -euo pipefail
60
+
61
+ TRACKED_NAME="PROJECT-CLAUDE.md"
62
+ INSTALLED_NAME="CLAUDE.md"
63
+ FRAMEWORK_DIR="masterly-framework"
64
+ VENDORED_REL=".claude/$TRACKED_NAME"
65
+ HOOK_REL=".claude/hooks/project-claude-md.sh"
66
+ TAG="project-wide CLAUDE.md"
67
+
68
+ # The physical path of an existing file or directory. `cd && pwd -P` rather than realpath or
69
+ # readlink -f, which are not on every platform the selftest runs on.
70
+ physical() {
71
+ if [ -d "$1" ]; then
72
+ (cd "$1" && pwd -P)
73
+ else
74
+ printf '%s/%s\n' "$(cd "$(dirname "$1")" && pwd -P)" "$(basename "$1")"
75
+ fi
76
+ }
77
+
78
+ # Where a symlink chain ends, as a physical path. Fails when it ends nowhere.
79
+ resolve() {
80
+ local p=$1 t hops=0
81
+ while [ -L "$p" ]; do
82
+ t=$(readlink "$p")
83
+ case $t in
84
+ /*) p=$t ;;
85
+ *) p="$(dirname "$p")/$t" ;;
86
+ esac
87
+ hops=$((hops + 1))
88
+ [ "$hops" -le 40 ] || return 1
89
+ done
90
+ [ -e "$p" ] || return 1
91
+ physical "$p"
92
+ }
93
+
94
+ install() {
95
+ # 1. Remote only. Silent otherwise: a developer's session gets no line of noise.
96
+ [ "${CLAUDE_CODE_REMOTE:-}" = "true" ] || return 0
97
+
98
+ # 2. Derived paths, or nothing.
99
+ if [ -z "${CLAUDE_PROJECT_DIR:-}" ] || [ ! -d "$CLAUDE_PROJECT_DIR" ]; then
100
+ echo "$TAG: CLAUDE_PROJECT_DIR is not set to a directory, so no path can be derived; nothing installed."
101
+ return 0
102
+ fi
103
+ local project clones repo target link how source
104
+ project=$(physical "$CLAUDE_PROJECT_DIR")
105
+ clones=$(dirname "$project")
106
+ repo=$(basename "$project")
107
+ target="$clones/$INSTALLED_NAME"
108
+
109
+ # 3. The source, in preference order.
110
+ if [ -f "$clones/$FRAMEWORK_DIR/$TRACKED_NAME" ]; then
111
+ link="$FRAMEWORK_DIR/$TRACKED_NAME"
112
+ how="the tracked original in the $FRAMEWORK_DIR clone alongside; it cannot drift"
113
+ elif [ -f "$project/$VENDORED_REL" ]; then
114
+ link="$repo/$VENDORED_REL"
115
+ how="the vendored copy shipped in $repo; byte-identical to $FRAMEWORK_DIR/$TRACKED_NAME when it was copied, possibly behind it since"
116
+ else
117
+ echo "$TAG: no source. Neither $clones/$FRAMEWORK_DIR/$TRACKED_NAME nor $project/$VENDORED_REL exists, so nothing was installed at $target and this session has no project-wide context."
118
+ return 0
119
+ fi
120
+ source="$clones/$link"
121
+
122
+ # 4. The target, without destroying anything.
123
+ if [ -L "$target" ]; then
124
+ local current previous
125
+ if current=$(resolve "$target") && [ "$current" = "$(physical "$source")" ]; then
126
+ echo "$TAG: $target -> $link is already in place ($how). Installed by $repo/$HOOK_REL."
127
+ return 0
128
+ fi
129
+ previous=$(readlink "$target")
130
+ rm -f "$target"
131
+ ln -s "$link" "$target"
132
+ echo "$TAG: $target -> $link, re-pointed from $previous ($how). Installed by $repo/$HOOK_REL."
133
+ return 0
134
+ fi
135
+ if [ -e "$target" ]; then
136
+ if [ -f "$target" ] && cmp -s "$target" "$source"; then
137
+ echo "$TAG: $target is a plain file identical to $link and was left as it is ($how). Checked by $repo/$HOOK_REL."
138
+ return 0
139
+ fi
140
+ local msg
141
+ msg="$TAG: WARNING. $target exists, is not a symlink, and is not the tracked file. It was NOT replaced: sessions in this container load it instead of $link. Reported by $repo/$HOOK_REL."
142
+ echo "$msg"
143
+ echo "$msg" >&2
144
+ return 0
145
+ fi
146
+ ln -s "$link" "$target"
147
+ echo "$TAG: $target -> $link ($how). Installed by $repo/$HOOK_REL."
148
+ }
149
+
150
+ # --- selftest -------------------------------------------------------------------------------
151
+
152
+ selftest() {
153
+ local self failures=0 total=0
154
+ self=$(physical "$0")
155
+ # Not local: the EXIT trap runs after this function has returned.
156
+ SELFTEST_TMP=$(mktemp -d)
157
+ trap 'rm -rf "$SELFTEST_TMP"' EXIT
158
+ local tmp=$SELFTEST_TMP
159
+
160
+ # A clones directory with a repository in it, holding the vendored copy, and optionally
161
+ # the framework clone beside it. Prints nothing; the caller knows the layout.
162
+ fixture() { # <clones> <with-framework: yes|no> <with-vendored: yes|no>
163
+ mkdir -p "$1/some-repo/.claude/hooks"
164
+ [ "$3" = "yes" ] && printf 'vendored copy\n' >"$1/some-repo/$VENDORED_REL"
165
+ if [ "$2" = "yes" ]; then
166
+ mkdir -p "$1/$FRAMEWORK_DIR"
167
+ printf 'tracked original\n' >"$1/$FRAMEWORK_DIR/$TRACKED_NAME"
168
+ fi
169
+ return 0
170
+ }
171
+
172
+ # Run the hook as Claude Code would: this file, with the two variables set per case. The
173
+ # environment running the selftest may itself be a cloud container with CLAUDE_CODE_REMOTE
174
+ # exported, so the "unset" case unsets it rather than inheriting it -- the first run of this
175
+ # selftest was in exactly such a container, and the case failed for that reason.
176
+ run() { # <remote value | unset> <project dir>
177
+ if [ "$1" = "unset" ]; then
178
+ env -u CLAUDE_CODE_REMOTE CLAUDE_PROJECT_DIR="$2" bash "$self" </dev/null 2>/dev/null
179
+ else
180
+ CLAUDE_CODE_REMOTE=$1 CLAUDE_PROJECT_DIR=$2 bash "$self" </dev/null 2>/dev/null
181
+ fi
182
+ }
183
+
184
+ check() { # <name> <exit status of the predicate>
185
+ total=$((total + 1))
186
+ if [ "$2" -eq 0 ]; then
187
+ echo "ok: $1"
188
+ else
189
+ echo "selftest FAIL: $1"
190
+ failures=$((failures + 1))
191
+ fi
192
+ }
193
+
194
+ # The predicates, named so each case reads as the claim it makes.
195
+ links_to() { [ -L "$1" ] && [ "$(readlink "$1")" = "$2" ]; } # <path> <link text>
196
+ reads() { [ "$(cat "$1")" = "$2" ]; } # <path> <content>
197
+ plain_file() { [ ! -L "$1" ] && [ -f "$1" ]; } # <path>
198
+ absent() { [ ! -e "$1" ] && [ ! -L "$1" ]; } # <path>
199
+ says() { case $1 in *"$2"*) return 0 ;; esac; return 1; } # <output> <fragment>
200
+ silent() { [ -z "$1" ]; } # <output>
201
+ not_json() { case $1 in "{"*) return 1 ;; esac; return 0; } # <output>
202
+
203
+ local c out t
204
+
205
+ # Remote, framework alongside: the link points at the tracked original.
206
+ c="$tmp/sibling"; t="$c/$INSTALLED_NAME"
207
+ fixture "$c" yes yes
208
+ out=$(run true "$c/some-repo")
209
+ check "remote with the framework alongside links to the tracked original" \
210
+ "$(links_to "$t" "$FRAMEWORK_DIR/$TRACKED_NAME" && reads "$t" "tracked original"; echo $?)"
211
+ check " ...and says so on stdout" "$(says "$out" "-> $FRAMEWORK_DIR/$TRACKED_NAME"; echo $?)"
212
+ check " ...and prints nothing that reads as an async marker" "$(not_json "$out"; echo $?)"
213
+
214
+ # Running again changes nothing and says it is already in place.
215
+ out=$(run true "$c/some-repo")
216
+ check "a second run leaves a correct link alone" \
217
+ "$(links_to "$t" "$FRAMEWORK_DIR/$TRACKED_NAME" && says "$out" "already in place"; echo $?)"
218
+
219
+ # Remote, no framework: the link points at the vendored copy inside the repository.
220
+ c="$tmp/vendored"; t="$c/$INSTALLED_NAME"
221
+ fixture "$c" no yes
222
+ out=$(run true "$c/some-repo")
223
+ check "remote without the framework links to the vendored copy" \
224
+ "$(links_to "$t" "some-repo/$VENDORED_REL" && reads "$t" "vendored copy"; echo $?)"
225
+ check " ...and says the copy may be behind" "$(says "$out" "possibly behind"; echo $?)"
226
+
227
+ # The framework appears later (a cached container whose clone set changed): re-pointed.
228
+ mkdir -p "$c/$FRAMEWORK_DIR"
229
+ printf 'tracked original\n' >"$c/$FRAMEWORK_DIR/$TRACKED_NAME"
230
+ out=$(run true "$c/some-repo")
231
+ check "a link to the vendored copy is re-pointed once the framework is alongside" \
232
+ "$(links_to "$t" "$FRAMEWORK_DIR/$TRACKED_NAME" && says "$out" "re-pointed from some-repo/$VENDORED_REL"; echo $?)"
233
+
234
+ # A dangling link is re-pointed.
235
+ c="$tmp/dangling"; t="$c/$INSTALLED_NAME"
236
+ fixture "$c" yes yes
237
+ ln -s "no-such-file.md" "$t"
238
+ run true "$c/some-repo" >/dev/null
239
+ check "a dangling link is re-pointed" "$(links_to "$t" "$FRAMEWORK_DIR/$TRACKED_NAME"; echo $?)"
240
+
241
+ # Not remote: nothing happens and nothing is printed, whether unset or false.
242
+ c="$tmp/local-unset"; t="$c/$INSTALLED_NAME"
243
+ fixture "$c" yes yes
244
+ out=$(run unset "$c/some-repo")
245
+ check "not remote (variable unset) installs nothing and prints nothing" \
246
+ "$(absent "$t" && silent "$out"; echo $?)"
247
+ c="$tmp/local-false"; t="$c/$INSTALLED_NAME"
248
+ fixture "$c" yes yes
249
+ out=$(run false "$c/some-repo")
250
+ check "not remote (variable false) installs nothing and prints nothing" \
251
+ "$(absent "$t" && silent "$out"; echo $?)"
252
+
253
+ # A regular file at the target is never replaced -- whatever it holds.
254
+ c="$tmp/foreign"; t="$c/$INSTALLED_NAME"
255
+ fixture "$c" yes yes
256
+ printf 'somebody else put this here\n' >"$t"
257
+ out=$(run true "$c/some-repo")
258
+ check "a foreign regular file at the target is not replaced" \
259
+ "$(plain_file "$t" && reads "$t" "somebody else put this here"; echo $?)"
260
+ check " ...and is reported as not replaced" "$(says "$out" "NOT replaced"; echo $?)"
261
+ c="$tmp/plain-copy"; t="$c/$INSTALLED_NAME"
262
+ fixture "$c" yes yes
263
+ printf 'tracked original\n' >"$t"
264
+ out=$(run true "$c/some-repo")
265
+ check "an identical plain copy at the target is left as a plain file" \
266
+ "$(plain_file "$t" && says "$out" "plain file identical"; echo $?)"
267
+
268
+ # No source at all: nothing is created, and the session is told it has no context.
269
+ c="$tmp/no-source"; t="$c/$INSTALLED_NAME"
270
+ fixture "$c" no no
271
+ out=$(run true "$c/some-repo")
272
+ check "no source anywhere installs nothing and says so" \
273
+ "$(absent "$t" && says "$out" "no source"; echo $?)"
274
+
275
+ # No project directory: nothing is derived.
276
+ c="$tmp/no-project"; t="$c/$INSTALLED_NAME"
277
+ fixture "$c" yes yes
278
+ out=$(CLAUDE_CODE_REMOTE=true bash "$self" </dev/null 2>/dev/null)
279
+ check "an unset CLAUDE_PROJECT_DIR derives nothing" \
280
+ "$(absent "$t" && says "$out" "CLAUDE_PROJECT_DIR is not set"; echo $?)"
281
+
282
+ # Synchronous by construction: no line of code in this file carries the async marker. The
283
+ # pattern is a key followed by a colon, which this line does not contain.
284
+ check "the hook never emits an async marker" \
285
+ "$(! grep -v '^ *#' "$self" | grep -qE '"async"[[:space:]]*:'; echo $?)"
286
+
287
+ if [ "$failures" -ne 0 ]; then
288
+ echo
289
+ echo "FAIL: $failures of $total selftest case(s) failed."
290
+ return 1
291
+ fi
292
+ echo "OK: $total selftest cases pass."
293
+ }
294
+
295
+ if [ "${1:-}" = "--selftest" ]; then
296
+ selftest
297
+ else
298
+ install
299
+ fi
@@ -0,0 +1,14 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/project-claude-md.sh"
9
+ }
10
+ ]
11
+ }
12
+ ]
13
+ }
14
+ }
@@ -0,0 +1,30 @@
1
+ # Dependabot keeps the pinned action digests and the dependency manifests current.
2
+ # Third-party actions in .github/workflows are pinned by commit SHA (a tag can be
3
+ # moved; a SHA cannot), which makes them invisible to a human reading for staleness —
4
+ # so the github-actions ecosystem below is what actually keeps them from rotting.
5
+ #
6
+ # Grouping (MAS-126): a wall of PRs each week gets skimmed, and a skimmed wall is how
7
+ # a real one goes through unread. Actions group at every level, because actions/* ride
8
+ # major tags deliberately and third-party ones are pinned by SHA, so the diff is a
9
+ # digest either way and the version that changed is in the title. Code dependencies
10
+ # group minor and patch only, so a major always arrives as its own readable PR.
11
+ version: 2
12
+ updates:
13
+ - package-ecosystem: "uv"
14
+ directory: "/"
15
+ schedule:
16
+ interval: "weekly"
17
+ open-pull-requests-limit: 10
18
+ groups:
19
+ uv-minor-patch:
20
+ patterns: ["*"]
21
+ update-types: ["minor", "patch"]
22
+ - package-ecosystem: "github-actions"
23
+ directory: "/"
24
+ schedule:
25
+ interval: "weekly"
26
+ open-pull-requests-limit: 10
27
+ groups:
28
+ actions:
29
+ patterns: ["*"]
30
+ update-types: ["major", "minor", "patch"]
@@ -0,0 +1,84 @@
1
+ # Gates every pull request and every push to main. The package ships to PyPI from
2
+ # release.yml, which builds whatever is on the tag — so this is the only place the
3
+ # code is actually checked before it can get there.
4
+ #
5
+ # The floor is `requires-python` in pyproject: a client library is compiled by its
6
+ # consumers' interpreters, not ours, so the oldest supported one has to run the suite.
7
+ name: ci
8
+
9
+ on:
10
+ pull_request:
11
+ push:
12
+ branches: [main]
13
+
14
+ # A re-push supersedes the run before it, so cancel the superseded PR run rather than pay
15
+ # for a result nobody will read. Guarded to pull_request: a push to main — a deploy on some
16
+ # repos, a tag build on others — must never be cancelled by whatever lands next. MAS-174.
17
+ concurrency:
18
+ group: ${{ github.workflow }}-${{ github.ref }}
19
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
20
+
21
+ # Least privilege: the workflow grants nothing, and each job states the one scope it needs --
22
+ # reading the repository for actions/checkout. Nothing here writes through the token, and
23
+ # publishing lives in release.yml. Without a block the token's scopes are whatever the
24
+ # repository setting says, which is not something a reader of this file can see.
25
+ permissions: {}
26
+
27
+ jobs:
28
+ check:
29
+ runs-on: ubuntu-latest
30
+ permissions:
31
+ contents: read
32
+ strategy:
33
+ fail-fast: false
34
+ matrix:
35
+ # The ends of the supported range. The versions between them share a bytecode
36
+ # generation with one of these two; widening the matrix buys little for the minutes.
37
+ python: ["3.10", "3.13"]
38
+ steps:
39
+ # persist-credentials: false — actions/checkout otherwise leaves the job's
40
+ # GITHUB_TOKEN in .git/config for the rest of the run, where any later step or
41
+ # third-party action can read it. Nothing in this repo pushes from CI, so the
42
+ # token has no job to do after the checkout. See masterly-framework
43
+ # docs/conventions/github-repo-settings.md, "Checkout credentials".
44
+ - uses: actions/checkout@v7
45
+ with:
46
+ persist-credentials: false
47
+ - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10, pinned by digest
48
+ with:
49
+ python-version: ${{ matrix.python }}
50
+ - run: uv sync
51
+ - run: uv run ruff check
52
+ # Strict, and on the package rather than the tests: a public client's types are
53
+ # part of its contract.
54
+ - run: uv run mypy src
55
+ - run: uv run pytest -q
56
+
57
+ # Every version on PyPI should be a tag here, and two are not: 0.1.1 and 0.2.0 were published
58
+ # from the private repository this one replaced, and the archive-and-recreate did not carry
59
+ # their tags across (MAS-503). RELEASING.md records what they were built from; this job keeps
60
+ # that record honest, and stops the package version from being set to one of those spent
61
+ # numbers. Its own job because it is standard-library only — no uv, no matrix, no network.
62
+ release-record:
63
+ runs-on: ubuntu-latest
64
+ permissions:
65
+ contents: read
66
+ steps:
67
+ # persist-credentials: false — actions/checkout otherwise leaves the job's
68
+ # GITHUB_TOKEN in .git/config for the rest of the run, where any later step or
69
+ # third-party action can read it. Nothing in this repo pushes from CI, so the
70
+ # token has no job to do after the checkout. See masterly-framework
71
+ # docs/conventions/github-repo-settings.md, "Checkout credentials".
72
+ #
73
+ # fetch-depth: 0 — the check asks whether a commit is in this repository's history, and
74
+ # in a shallow clone the answer is "no" for everything. It refuses to run shallow rather
75
+ # than answer vacuously, so the full history is not optional here.
76
+ - uses: actions/checkout@v7
77
+ with:
78
+ persist-credentials: false
79
+ fetch-depth: 0
80
+ # The detector before the thing it detects: synthetic records prove the check still
81
+ # rejects a missing, malformed or stale one, so a check that has quietly stopped
82
+ # checking fails here rather than passing the repository for the wrong reason.
83
+ - run: python3 scripts/check_release_record.py --selftest
84
+ - run: python3 scripts/check_release_record.py