diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e342430..761342e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,7 +7,7 @@ }, "metadata": { "description": "Altimate's Claude Code plugin — data tools through the altimate-code MCP server, or the model-free CLI where MCP is disallowed, plus delegation of long dbt jobs to the CLI agent.", - "version": "1.4.2" + "version": "1.5.0" }, "plugins": [ { diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..436ad7b --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,30 @@ +name: tests + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + shell: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Install zsh + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y zsh + - name: Run shell tests + run: | + set -e + for t in tests/*.sh; do + echo "::group::$t" + bash "$t" + echo "::endgroup::" + done diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ec1a13..f360b4c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,11 @@ Thanks for taking the time to contribute. This plugin lives in one repo (`Altima ``` Both must exit clean. **Validating the repo root alone is not sufficient** — the marketplace-level validator does not descend into skills, so skill-frontmatter parse errors go undetected unless you also validate the plugin path. - If you touch the CLI resolver block in a skill or command, run `./tests/resolve-cli.sh` — it checks every copy is identical and resolves correctly in bash and zsh. + Run the shell tests (CI runs them too, on Linux and macOS): + ```bash + bash tests/resolve-cli.sh # the CLI resolver block: every copy identical, resolves in bash and zsh + bash tests/workspace-rules-hook.sh # the workspace-rules hook against a stub CLI (needs python3 and git) + ``` 4. Open a PR against `AltimateAI/altimate-claude-plugin:main`. ## Repo layout @@ -24,6 +28,8 @@ Thanks for taking the time to contribute. This plugin lives in one repo (`Altima altimate-claude-plugin/ ├── .claude-plugin/ │ └── marketplace.json # marketplace descriptor (source: ./plugins/altimate-code) +├── .github/workflows/ +│ └── tests.yml # runs tests/*.sh on Linux and macOS ├── plugins/ │ └── altimate-code/ │ ├── .claude-plugin/ @@ -35,7 +41,7 @@ altimate-claude-plugin/ │ ├── agents/ │ │ └── analyst.md # read-only data analyst for the MCP tools │ ├── hooks/ -│ │ ├── hooks.json # SessionStart, Stop and PreToolUse hook config +│ │ ├── hooks.json # SessionStart, UserPromptSubmit, Stop and PreToolUse hook config │ │ ├── integrations.tsx # one-click integrations band and /integrations pane (mod) │ │ └── integrations.test.tsx # its tests (claude plugin test .) │ ├── types/ @@ -45,14 +51,21 @@ altimate-claude-plugin/ │ │ ├── data-mode.sh # MCP or CLI mode, from PATH and MCP settings │ │ ├── sync-workspace-skills.py # copies workspace skills into .claude/skills │ │ ├── refresh-skills.sh # background / on-demand workspace skill refresh +│ │ ├── workspace-rules.sh # team rules hooks (session-start, user-prompt-submit, stop) │ │ ├── warehouse-guard.sh # opt-in PreToolUse warehouse-client guard │ │ └── warehouse-guard.py # its command parser │ ├── commands/ │ │ ├── altimate.md # /altimate-code:altimate slash command -│ │ └── refresh-skills.md # /altimate-code:refresh-skills +│ │ ├── refresh-skills.md # /altimate-code:refresh-skills +│ │ └── rules.md # /altimate-code:rules slash command │ ├── README.md # plugin-specific docs │ └── CHANGELOG.md # plugin-specific changelog +├── tests/ +│ ├── resolve-cli.sh # CLI resolver block check +│ └── workspace-rules-hook.sh # workspace-rules hook check ├── README.md # repo overview +├── PRIVACY.md +├── TERMS.md ├── SECURITY.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md @@ -100,6 +113,7 @@ claude plugin install altimate-code@altimate-claude-plugin - **Hooks** — `plugins/altimate-code/hooks/hooks.json`: - SessionStart (`hooks-handlers/session-start.sh`) describes the plugin's capabilities so Claude can route to it, adds the CLI guidance when `data-mode.sh` picks the CLI mode, and copies the linked workspace's skills (`sync-workspace-skills.py`). - Stop (`refresh-skills.sh --if-stale`) refreshes those skills in the background at most hourly. + - SessionStart, UserPromptSubmit and Stop (`hooks-handlers/workspace-rules.sh`) are the team rules hooks: they call `altimate learn hook ` only in a folder altimate-code's link cache names, and never fail the session. - PreToolUse (`warehouse-guard.sh`) points direct warehouse clients at `tool run`, only with `ALTIMATE_ROUTE_GUARD=1`. - **Tests** — `tests/*.sh` and `python3 -m pytest tests/`; run them all before a PR. - **Command** — `plugins/altimate-code/commands/altimate.md` — `/altimate-code:altimate` slash command follows the same routing with agent-persona selection for delegation. diff --git a/PRIVACY.md b/PRIVACY.md index ee255e7..b35aadc 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,7 @@ # Privacy Policy — Altimate Code plugin for Claude Code **Effective date:** November 2, 2026 +**Last revised:** November 10, 2026 **Applies to:** the `altimate-code` Claude Code plugin distributed from this repository, and the data flows it initiates through the [altimate-code](https://github.com/AltimateAI/altimate-code) MCP server and CLI. This policy supplements the Altimate AI [Privacy Policy](https://www.altimate.ai/privacy) and [Terms](https://www.altimate.ai/terms), which govern Altimate services generally. Where this document is silent, the master policy applies. Questions: **info@altimate.ai**. @@ -11,6 +12,7 @@ This policy supplements the Altimate AI [Privacy Policy](https://www.altimate.ai - When the plugin is enabled, Claude Code launches a local `altimate-code mcp serve` process for direct data tools. This requires a build containing `mcp serve`; if the server cannot start or is disallowed, the same tools run through `altimate tool run`. These direct execution paths do not run a local planning model, but workspace services may use backend models as described below. - MCP tool results, including sampled column values and query results, enter the Claude Code conversation and therefore reach your configured model provider. Workspace tools can send requests to Altimate services, including backend models used by `ask_knowledge_base`. +- In a project linked to an Altimate workspace, its workspace-rules hooks start the local altimate-code CLI, which pulls the workspace's approved team rules into the session. The hooks also hand your messages and the location of the session transcript to the CLI. With learning on, the CLI keeps corrections on your machine and may send a digest of the session, which can include file contents, command output and query results, to altimate-code's own model (not Claude Code's) to word a proposed team rule. A proposal reaches your workspace only when you send or approve it. The CLI also sends usage telemetry about team rules. See [Workspace rules](#workspace-rules). - When a task **is** delegated, your task text is passed to the altimate-code CLI, which sends it to an LLM provider to plan and execute the work — the Altimate LLM Gateway by default, or a provider you configure (BYOK). - Warehouse queries, dbt commands, and file edits run **locally on your machine, under your credentials**. Your warehouse credentials are never sent to Altimate. - In a project linked to an Altimate workspace, the plugin copies the workspace's team skills into the project's `.claude/skills/` and refreshes them from Altimate in the background at session start and at most hourly. Their text loads into the Claude Code conversation like any project skill. `ALTIMATE_CLAUDE_SKILL_SYNC=0` turns this off and removes the copies at the next session start. @@ -18,7 +20,7 @@ This policy supplements the Altimate AI [Privacy Policy](https://www.altimate.ai ## What the plugin does and does not do -The plugin registers a local stdio MCP server through `.mcp.json`, provides routing skills and slash commands, and runs hooks: SessionStart prints a capability description and, with Stop, keeps the linked workspace's skills copied into the project (see below); an opt-in PreToolUse guard checks Bash commands locally. The server reads configured connections and runs data tools under your local credentials. The plugin adds no analytics of its own; CLI telemetry is described below. +The plugin registers a local stdio MCP server through `.mcp.json`, provides routing skills and slash commands, and runs hooks: SessionStart prints a capability description and, with Stop, keeps the linked workspace's skills copied into the project (see below); in a linked project, the workspace-rules hooks (SessionStart, UserPromptSubmit and Stop) start the local altimate-code CLI (see [Workspace rules](#workspace-rules)); an opt-in PreToolUse guard checks Bash commands locally. The server reads configured connections and runs data tools under your local credentials. The plugin adds no analytics of its own; CLI telemetry is described below. ## Data flows through MCP tools @@ -51,6 +53,29 @@ These service calls are separate from local CLI delegation. Configuring BYOK for Refresh uses your existing Altimate sign-in. Projects altimate-code has never worked in are skipped. Set `ALTIMATE_CLAUDE_SKILL_SYNC=0` to turn copying and refresh off and remove the copies. +## Workspace rules + +**When the hooks start the CLI.** The workspace-rules hooks start the local altimate-code CLI only when the folder the session starts in, or a folder above it in the repository, is named in altimate-code's workspace link cache (`altimate-workspace-bindings.json`, in your own state directory). Elsewhere they start nothing. No file in the repository decides this: a branch or a merge can bring files into it. With `ALTIMATE_LEARN=0` (or `false`) no hook starts the CLI. + +**What needs what.** + +- *Team rules.* In a linked project, the CLI pulls the workspace's approved team rules at session start when workspaces are on (`ALTIMATE_DISABLE_WORKSPACE` is not set), you are signed in to Altimate and `ALTIMATE_LEARN` is not `0`. This does not depend on the learning setting. +- *Recording corrections and drafting proposed rules* also need learning on. Learning is on when you turned it on in the project (`altimate learn enable`) or, if you have made no choice of your own, when the workspace owner turned on the workspace's "Learn from sessions" setting. Your own choice (`altimate learn enable` or `altimate learn disable`) overrides the workspace setting, unless your organization enforces a policy. +- In a linked folder, the per-turn hooks hand each message you send and the location of the session transcript to the local CLI whether or not learning is on. When you are signed out, workspaces are off or the project is not linked, the CLI discards them and nothing leaves your machine. When learning is off, the CLI discards them too, but after each turn it still pulls the team rules and sends proposals you already answered and rule usage counts (no message or transcript text). + +**The digest used to word a proposed rule.** After a turn with new corrections, the CLI reads the Claude Code session transcript (its last 8 MB) and builds a digest of the session: your prompts, the input and output of each tool call (up to 300 and 400 characters per call, so file contents, command output and warehouse query results can appear), and the final reply, at most 24,000 characters in total. Secrets in the digest are masked; no other data is removed. The digest is sent to altimate-code's own model: the model named by `ALTIMATE_LEARN_MODEL`, otherwise the one set as `learn.model` in altimate-code's configuration, otherwise altimate-code's default model, which is the Altimate LLM Gateway or another provider configured for altimate-code. This is not the model Claude Code uses; it is a party that does not otherwise see your Claude Code session. If altimate-code has no model it can use, nothing is sent, and the next session start reports that the check failed. The CLI makes at most one such model call per check round, and a few per turn at most. + +| Data | Where it goes | Retention | +|---|---|---| +| Your workspace's approved team rules | Pulled by the local CLI from your Altimate workspace, given to Claude Code as session context | Mirrored locally in the project's git-ignored `.altimate-code/` directory, and given only while altimate-code's own rules state holds the same rules and you are signed in as the account they were pulled for. That state, which also keeps proposals waiting to be sent and your review notices, is in your own state directory (`$XDG_STATE_HOME/altimate-code/workspace-rules/`, usually `~/.local/state/altimate-code/workspace-rules/`), outside the project. This needs an altimate-code release that keeps the rules state there; earlier releases keep it in the project's `.altimate-code/learn/workspace-rules/` folder | +| Each message you send | Handed to the local CLI, which checks it with no model call; with learning on, a message that reads as a correction is kept (secrets redacted) | Local only, in the project's git-ignored `.altimate-code/` directory | +| The session transcript, after a turn with new corrections (learning on) | Read locally by the CLI; the digest described above goes to altimate-code's own model to word one proposed rule | The transcript stays local; the model provider's handling is as for delegated tasks below | +| A proposed rule, with an excerpt of your correction if your workspace allows quotes | Your Altimate workspace's review queue, only when you send or approve it in `/altimate-code:rules` or `altimate learn proposals` | Per the Altimate Privacy Policy | +| Rule usage counts (sessions a rule was shown in, corrections after it) | Your Altimate workspace | Per the Altimate Privacy Policy | +| Rules telemetry events: `rules_sync`, `rules_delivered`, `rule_drafted`, `rule_proposal_shown`, `rule_proposal_answered`, `rule_proposal_skipped`, `rule_remember_routed`, and `rules_command` (the name of each `learn` subcommand you run, such as `learn proposals`; not sent for the hooks' own calls). Events about a session carry its id, `claude:` for a Claude Code session. No rule text, prompts or file paths | Altimate's usage telemetry, as described in [Usage telemetry](#usage-telemetry) | As for usage telemetry; turned off with the telemetry opt-out there | + +Turn off recording and drafting with `altimate learn disable` in the project, or, for members who have made no choice of their own, with the workspace's "Learn from sessions" setting. `ALTIMATE_LEARN=0` turns off all of it, including the team rules pull, and the hooks then start nothing. + The delegation path below runs only when the delegation skill or the `/altimate-code:altimate` command fires and invokes the locally installed altimate-code CLI. ## Data flows when a task is delegated @@ -103,6 +128,8 @@ The plugin and the altimate-code CLI are professional developer tools, not direc We will update this document in this repository and revise the effective date when our practices change. Material changes to Altimate-wide practices are governed by the master [Privacy Policy](https://www.altimate.ai/privacy). +**November 10, 2026:** added [Workspace rules](#workspace-rules) (the workspace-rules hooks and `/altimate-code:rules` in plugin 1.5.0), and the rules telemetry the altimate-code CLI sends. + ## Contact Altimate Inc. · 440 North Wolfe Road #150, Sunnyvale, CA 94085, USA · +1 (650) 254-6266 diff --git a/plugins/altimate-code/.claude-plugin/plugin.json b/plugins/altimate-code/.claude-plugin/plugin.json index 3934cc3..5c5b876 100644 --- a/plugins/altimate-code/.claude-plugin/plugin.json +++ b/plugins/altimate-code/.claude-plugin/plugin.json @@ -1,8 +1,8 @@ { "name": "altimate-code", "displayName": "Altimate Code", - "version": "1.4.2", - "description": "Gives Claude Code altimate-code's model-free data tools (column-level lineage, impact analysis, PII detection, data-diff, FinOps, query equivalence, dialect translation, warehouse queries) through its MCP server, or through `altimate tool run` when MCP servers are not allowed, with delegation to the altimate-code agent for long dbt jobs and access to a linked Altimate workspace's knowledge.", + "version": "1.5.0", + "description": "Gives Claude Code altimate-code's model-free data tools (column-level lineage, impact analysis, PII detection, data-diff, FinOps, query equivalence, dialect translation, warehouse queries) through its MCP server, or through `altimate tool run` when MCP servers are not allowed, with delegation to the altimate-code agent for long dbt jobs, access to a linked Altimate workspace's knowledge, and the workspace's team rules in each session.", "author": { "name": "AltimateAI", "url": "https://altimate.ai" diff --git a/plugins/altimate-code/CHANGELOG.md b/plugins/altimate-code/CHANGELOG.md index 20833fd..8e0150d 100644 --- a/plugins/altimate-code/CHANGELOG.md +++ b/plugins/altimate-code/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this plugin will be documented in this file. +## [1.5.0] — 2026-11-10 + +### Added +- Team rules from a linked Altimate workspace apply in Claude Code sessions. At session start the plugin gives Claude the workspace's approved rules for the repository, the number of proposed rules waiting for you, and the owner's reviews of your proposals since your last session. +- Corrections you make to Claude are recorded locally (UserPromptSubmit, no model call). After a turn, altimate-code checks them in the background (Stop) and keeps a proposed rule when you corrected the same topic in two sessions or corrected a team rule Claude was given. +- "Remember this as a team rule: …" said to Claude waits as a proposal (no model call). +- `/altimate-code:rules`: answer proposed rules (send for review, approve as owner, edit, save as memory, or "Not a rule"), and see the team rules and the review status of your proposals. +- The hooks run altimate-code only where the session's folder (or a folder above it in the repository) is in altimate-code's workspace link cache, which is in your own state directory: nothing in the repository turns them on. Team rules arrive in a linked project when you are signed in; recording corrections and drafting proposals also need learning on. `ALTIMATE_LEARN=0` turns all of it off and the hooks start nothing. +- Start the session in the linked folder or below it: with only a subfolder linked (a dbt project), a session started at the repository root gets no team rules. +- The hooks need an altimate-code release with `learn hook`; with an older one they do nothing. + ## [1.4.2] — 2026-11-09 ### Fixed diff --git a/plugins/altimate-code/README.md b/plugins/altimate-code/README.md index 0f1e6bf..dd8976e 100644 --- a/plugins/altimate-code/README.md +++ b/plugins/altimate-code/README.md @@ -49,7 +49,7 @@ If altimate-code is not on PATH or not configured, the plugin surfaces the failu ## How it works -1. A **SessionStart hook** adds a description of the available tools and both modes to the session, and copies the linked workspace's skills (see below). It does not start the MCP server. +1. A **SessionStart hook** adds a description of the available tools and both modes to the session, and copies the linked workspace's skills (see below). It does not start the MCP server. In a linked project, the [team rules](#team-rules) hooks add the workspace's rules too. 2. **MCP mode.** Claude calls `warehouse_list`, `warehouse_add`, `sql_execute` (`tables` returns columns and value profiles; large results spill to a local file), `sql_materialize` (session-local `scratch` DuckDB for cross-database joins), `answer_commit`, and `impact_analysis`, `dbt_lineage`, `altimate_core_column_lineage`, `schema_detect_pii`, `data_diff`, `altimate_core_equivalence`, `sql_translate`. 3. **CLI mode.** The **skill** has Claude run `altimate tool context` once (connections, schema listing, analyst method), then `altimate tool run --args ''` through Bash and reason over the structured output. `altimate tool list --json` shows every tool and its input schema. A warm process per project starts automatically. 4. Calls are read-only unless you approve `--allow-write` (CLI mode); `DROP DATABASE`, `DROP SCHEMA` and `TRUNCATE` are always blocked. Read-only is enforced by the database on Postgres, SQLite and DuckDB and by SQL classification elsewhere, so use a read-only DB user for the strongest boundary. @@ -113,6 +113,22 @@ The plugin's lineage pane follows the dbt model Claude reads or edits (by Read, `/lineage [.column]` opens the pane on a model, at any width. When the pane follows Claude's reads on its own, Claude Code draws it only in a terminal at least 144 columns wide (110 once you have opened it yourself); in a narrower one it waits, and a toast says so once. Lineage calls go to the plugin's MCP server without a permission prompt, or to `altimate tool run` when the server is not available. A manifest over 4 MiB is read through altimate-code (`dbt_lineage` with `include_graph`). Run `dbt parse` or `dbt compile` first; column lineage and reach need compiled SQL. When the shown model's `.sql` file changed after the manifest was written, the pane and the edit note say so: run `dbt compile`, then `r`. +### Team rules + +In a project linked to an Altimate workspace, the workspace's approved **team rules** apply to Claude Code itself, not only to the tasks it hands over. With learning on (`altimate learn enable` in the project, or the workspace's "Learn from sessions" if you have made no choice of your own), your corrections can become proposed rules too: + +- **At session start** the plugin gives Claude the team rules for this repository, tells it how many proposed rules are waiting for you, and reports the owner's reviews of your proposals since your last session (approved, or rejected with the reason). +- **When you correct Claude** ("no, we never use FLOAT for money") the correction is recorded locally. When you correct it on the same topic in two sessions, or correct a team rule it was given, altimate-code words a proposed rule after the turn, in the background. +- **"Remember this as a team rule: …"**, said to Claude, waits as a proposal the same way. It needs "as a rule" or "team rule" followed by ":" or "-": "remember to run the tests", a question, or "don't remember this as a team rule" is just part of the conversation. A task handed to altimate-code decides this as an altimate-code session does. +- **`/altimate-code:rules`** shows the proposals waiting for you, the team rules, and the status of the rules you proposed. For each proposal you choose: send it to the workspace owner's review (or approve it, if you are the owner), edit the wording, save it as a memory, or "Not a rule". Nothing reaches teammates until the owner approves it. + +Rule drafting uses altimate-code's own model (sign in to the Altimate LLM Gateway, or set a provider in altimate-code): if it has none it can use, the next session start says the check failed. The same commands work in a terminal: `altimate learn proposals`, `altimate learn proposals reply --send`. Proposals from Claude Code sessions are answered only there or with `/altimate-code:rules`: altimate-code's own `/rules` and its VS Code chat panel don't list them. This needs an altimate-code release with workspace rules. + +- **Start the session in the linked folder**, or a folder below it. If only a folder inside the repository is linked (a dbt project), a session started at the repository root gets no team rules. +- **A new clone or worktree** gets no team rules until altimate-code has been linked, or run, in that checkout: the link is remembered per folder. +- **The per-turn hooks never block**: they hand off to altimate-code in the background. Session start makes one bounded synchronous call (at most 15 seconds). All of them run only in a linked folder (or below it), as recorded in altimate-code's link cache in your own state directory: nothing in the repository turns them on. +- **`ALTIMATE_LEARN=0`** turns all of it off: the hooks start nothing. + ## Example prompts Once installed, try these in Claude Code (or prefix any of them with `/altimate-code:altimate`): @@ -144,6 +160,7 @@ Full details: **[PRIVACY.md](../../PRIVACY.md)** · Security policy and disclosu | Answers ignore your workspace's skills or knowledge | Run `altimate workspace status` in the project. If it isn't linked, ask Claude to link it, or run `altimate link` in a terminal. If it is linked, check that your CLI has `tool run --workspace-tools` (`altimate upgrade`). | | `Altimate account or workspace changed` | The sign-in or link changed since Claude read the workspace. Claude re-reads `workspace_context` and retries once; if it repeats, ask Claude to check `altimate workspace status`. | | Workspace engine `needs_engine` | Let Claude run `altimate workspace-client engine-install`, or run it yourself. | +| Team rules don't appear at session start | Check `altimate learn status` in the project: it needs a linked workspace and an Altimate sign-in, and `ALTIMATE_LEARN` not set to `0`. Start Claude Code in the linked folder (or below it), not at the repository root when only a subfolder is linked. Update altimate-code if `altimate learn rules` is an unknown command. | | Plugin installed but never delegates | Confirm it's enabled: `claude plugin enable altimate-code@altimate-claude-plugin`. | Still stuck? See [Support](#support). @@ -154,7 +171,8 @@ Still stuck? See [Support](#support). - **Skill** — `skills/altimate-code/SKILL.md` — MCP and `tool run` guidance, delegation for long jobs, workspace guidance - **Agent** — `agents/analyst.md` — read-only data analyst (MCP or CLI) - **Command** — `commands/altimate.md` — `/altimate-code:altimate` slash command for explicit delegation -- **Hooks** — `hooks/hooks.json` — SessionStart capability description and workspace skill copy; Stop hook hourly background skill refresh; opt-in PreToolUse `warehouse-guard.sh` (needs `ALTIMATE_ROUTE_GUARD=1`) +- **Command** — `commands/rules.md` — `/altimate-code:rules` to answer proposed team rules +- **Hooks** — `hooks/hooks.json` — SessionStart capability description and workspace skill copy; Stop hook hourly background skill refresh; the team rules hooks (`hooks-handlers/workspace-rules.sh`): SessionStart, UserPromptSubmit and Stop, which call `altimate learn hook ` and never fail the session (the per-turn hooks run in the background; session start waits at most 15 seconds); opt-in PreToolUse `warehouse-guard.sh` (needs `ALTIMATE_ROUTE_GUARD=1`) ## Support diff --git a/plugins/altimate-code/commands/rules.md b/plugins/altimate-code/commands/rules.md new file mode 100644 index 0000000..3d47a6e --- /dev/null +++ b/plugins/altimate-code/commands/rules.md @@ -0,0 +1,66 @@ +--- +description: Review the team rules proposed from your corrections in a project linked to an Altimate workspace, and see the workspace's team rules and the review status of your proposals +--- + +The user invoked `/altimate-code:rules`. In a project linked to an Altimate workspace, altimate-code proposes a team rule when the user corrects the agent on the same topic in two sessions, corrects a team rule the session was given, or asks to remember something as a team rule. Those proposals wait here for the user's answer. Nothing reaches teammates until the workspace owner approves it. + +Workflow (follow in order): + +1. **List them.** Run this, which resolves the CLI first, ignoring aliases and shell functions: + ```bash + for ALTIMATE_NAME in altimate altimate-code; do + ALTIMATE_BIN="$(unalias "$ALTIMATE_NAME" 2>/dev/null; unset -f "$ALTIMATE_NAME" 2>/dev/null; command -v "$ALTIMATE_NAME")" + [ -x "$ALTIMATE_BIN" ] && break || ALTIMATE_BIN= + done + [ -n "$ALTIMATE_BIN" ] || { echo "altimate-code CLI not found (tried altimate, altimate-code)" >&2; exit 127; } + "$ALTIMATE_BIN" learn proposals --json < /dev/null + ``` + - Exit 127 (`altimate-code CLI not found`): tell the user to install it (`npm install -g altimate-code`) and stop. + - An error saying `proposals` is an unknown command: tell the user to update altimate-code (`npm install -g altimate-code@latest`) and stop. + +2. **Show the state.** The JSON has `workspace` (`name`, `owner_name`, `is_owner`; `null` when the project is not linked), `proposals`, `team_rules` and `mine`. Show, briefly: + - the number of team rules in the workspace, and their texts when there are 10 or fewer; + - the user's own proposals in `mine` that are not yet active, with their `status` and, for a rejected one, the `reason`; + - then each entry in `proposals`, one at a time, as described in step 3. + + If `proposals` is empty, say that no proposed rules are waiting and stop. + +3. **Ask about each proposal.** For each one show its `text`, what it changes (`change`: `add`, `edit` showing `replaces.text` as the current rule, or `retire`), `why`, the user's correction (`evidence.correction.excerpt`) when present, and `applies_to` when not empty. If `weakens_verification` is set, say so plainly: the rule would weaken verification (tests, CI, hooks). Then ask the user with the AskUserQuestion tool, offering only the proposal's `actions`, labelled: + - `send` → "Send for review" (to `workspace.owner_name`'s review queue) + - `approve` → "Approve now" (the user is the workspace owner) + - `edit` → "Edit wording" + - `save_memory` → "Save as memory" (offered when the wording did not say rule or memory) + - `save_local` → "Save for this project only" (the project is not linked) + - `dismiss` → "Not a rule" (that topic is not proposed again for 30 days) + - and always "Decide later" (do nothing; it stays waiting). + +4. **Answer through the CLI.** Map the choice to one flag: `--send`, `--approve`, `--edit`, `--memory`, `--local` or `--dismiss` (`--approve`, and `--approve-anyway` below, work only for the workspace owner). For "Edit wording", ask for the new wording (one line, at most 140 characters) and pass it through a here-doc so the shell never interprets what the user typed. Run this block exactly as shown, flush left: the wording line and the closing `ALTIMATE_RULE` line start at the beginning of their lines. An indented `ALTIMATE_RULE` does not end the here-doc, and spaces before the wording become part of the rule. + +```bash +for ALTIMATE_NAME in altimate altimate-code; do + ALTIMATE_BIN="$(unalias "$ALTIMATE_NAME" 2>/dev/null; unset -f "$ALTIMATE_NAME" 2>/dev/null; command -v "$ALTIMATE_NAME")" + [ -x "$ALTIMATE_BIN" ] && break || ALTIMATE_BIN= +done +[ -n "$ALTIMATE_BIN" ] || { echo "altimate-code CLI not found (tried altimate, altimate-code)" >&2; exit 127; } +PROPOSAL_ID='' +WORDING="$(cat <<'ALTIMATE_RULE' + +ALTIMATE_RULE +)" +"$ALTIMATE_BIN" learn proposals reply "$PROPOSAL_ID" --json --edit="$WORDING" < /dev/null +``` + + Keep `--edit="$WORDING"` in one argument (a wording that starts with `-` is otherwise read as a flag). If the wording itself has a line that is exactly `ALTIMATE_RULE`, use another delimiter. For any other choice, run the same command with that flag instead of `--edit="$WORDING"` (and without the here-doc). + + The JSON answer is either `{"ok": true, "message": …, "closed": …}` or `{"ok": false, "code": …, "error": …}`. Show the `message` or `error` to the user as it is. Then: + - When `ok` is true, check `closed`. `closed: false` after an edit: the proposal is still open with the new wording; ask about it again. `closed: true`: it is answered; move on. + - When `ok` is false, branch on `code`: + - `verification_flagged`: the rule weakens verification. Ask the user to confirm explicitly. Only if they confirm, reply with `--approve-anyway`. + - `invalid_text`: the wording was refused; ask for another wording. + - `refused`: the workspace refused the content; offer to edit it. + - `busy`: another session is answering it; skip it for now. + - `not_open`: it was already answered or closed; move on. + - `not_available` with an `error` saying it "was being answered with" another action: an earlier answer stopped part-way. Offer to finish it with that action, and send it once if the user agrees. If that action is an approval of a rule that weakens verification, ask for the explicit confirmation again first: never repeat `--approve-anyway` without the user's fresh, explicit confirmation. + - any other `code` (such as `failed`): the answer did not go through; leave the proposal waiting and move on. + +5. **Finish** with one line on what was sent, approved, saved or dismissed, and how many proposals are still waiting. Do not change any project files. diff --git a/plugins/altimate-code/hooks-handlers/workspace-rules.sh b/plugins/altimate-code/hooks-handlers/workspace-rules.sh new file mode 100755 index 0000000..fe2552d --- /dev/null +++ b/plugins/altimate-code/hooks-handlers/workspace-rules.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# Workspace rules hook for the altimate-code plugin. In a project linked to an +# Altimate workspace, it brings the workspace's team rules into this session and, +# with learning from sessions on, lets corrections made here become proposed +# rules, through `altimate learn hook ` (`altimate-code` is the same CLI), +# which applies every gate. Team rules need workspaces on, a sign-in, a link and +# the learning kill switch not off (ALTIMATE_LEARN / learn.enabled). Capturing +# corrections and drafting also need learning from sessions on: your own +# `learn enable` / `learn disable` choice, else the workspace's "Learn from +# sessions" setting. +# session-start team rules, proposals waiting for the person and new +# reviews, printed as context (one CLI call, a bounded sync) +# user-prompt-submit the prompt is recorded if it reads as a correction +# (in the background, no model call) +# stop the post-turn check, at most one model call per check +# round, a few per Stop at most (in the background) +# It never fails the session: every path exits 0 and the CLI's errors are +# dropped. Every event starts the CLI only where altimate-code's workspace link +# cache (in the person's own state directory, never in the repository) names the +# host's directory or a folder above it in the repository. The per-turn events +# never block: they run in the background. Session start waits for the CLI. With +# ALTIMATE_LEARN=0 (or false) nothing is started at all. +# +# The hook input is passed through on stdin; nothing here parses it. + +EVENT="${1:-}" +HOST="claude" +INPUT="$(cat)" + +# ALTIMATE_LEARN=0 or false turns workspace rules off in the CLI, so there is nothing to start. +case "$(printf '%s' "${ALTIMATE_LEARN:-}" | tr '[:upper:]' '[:lower:]')" in + 0|false) exit 0 ;; +esac + +for ALTIMATE_NAME in altimate altimate-code; do + ALTIMATE_BIN="$(unalias "$ALTIMATE_NAME" 2>/dev/null; unset -f "$ALTIMATE_NAME" 2>/dev/null; command -v "$ALTIMATE_NAME")" + [ -x "$ALTIMATE_BIN" ] && break || ALTIMATE_BIN= +done +[ -n "$ALTIMATE_BIN" ] || exit 0 + +# $ROOT bounds the walk up the link cache. The CLI runs in the host's directory (no `cd`), because a workspace link can +# be for a folder inside the repository (a dbt project). Nothing in the repository decides whether the CLI runs: a file +# there can come from a branch or a merge. +ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" + +# linked: whether the workspace link cache has a key for the host's directory or a folder above it, up to the +# repository root. This mirrors the CLI, which looks there and never below the directory it runs in. Keys are native +# paths written as JSON strings, so each folder is converted (`cygpath -w` on Windows), JSON-escaped, then escaped for +# the regular expression. It is matched with both quotes and the colon after them: `/x/app` never matches `/x/app-old`, +# and a folder that appears only as a value (a row's `projectPath`) is not a key. The physical path is tried first, then +# the path as the host reached it (through a symlink, or `/tmp` for `/private/tmp`): an older CLI wrote that form, and +# rewrites it only when it reads the cache itself. +linked() { + local cache="${XDG_STATE_HOME:-$HOME/.local/state}/altimate-code/altimate-workspace-bindings.json" + local top + [ -f "$cache" ] || return 1 + top="$(cd "$ROOT" 2>/dev/null && pwd -P)" + [ -n "$top" ] || return 1 + linked_from "$cache" "$top" "$(pwd -P)" && return 0 + [ -n "${PWD:-}" ] && [ "$PWD" != "$(pwd -P)" ] && linked_from "$cache" "$top" "$PWD" +} + +# linked_from CACHE TOP DIR: the walk up from DIR, ending at the folder whose physical path is TOP. +linked_from() { + local cache="$1" top="$2" dir="$3" key + [ -n "$dir" ] || return 1 + while :; do + key="$dir" + command -v cygpath >/dev/null 2>&1 && key="$(cygpath -w "$dir" 2>/dev/null)" + # An empty key would match any empty string in the cache. + [ -n "$key" ] || return 1 + key="$(printf '%s' "$key" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/[][\.*^$+?(){}|]/\\&/g')" + grep -qE "\"$key\"[[:space:]]*:" "$cache" 2>/dev/null && return 0 + case "$dir" in "$top"|/) return 1 ;; esac + [ "$(cd "$dir" 2>/dev/null && pwd -P)" = "$top" ] && return 1 + dir="$(dirname "$dir")" + done +} + +case "$EVENT" in + session-start) + # Only a directory the link cache names can have team rules: elsewhere the CLI is not started at all. + linked || exit 0 + OUTPUT="$(printf '%s' "$INPUT" | "$ALTIMATE_BIN" learn hook session-start --host "$HOST" 2>/dev/null)" + # Only the hook's JSON: an altimate-code without `learn hook` prints its help instead. + case "$OUTPUT" in + '{'*) printf '%s\n' "$OUTPUT" ;; + esac + ;; + user-prompt-submit|stop) + linked || exit 0 + ( printf '%s' "$INPUT" | nohup "$ALTIMATE_BIN" learn hook "$EVENT" --host "$HOST" & ) >/dev/null 2>&1 /dev/null || { echo "FAIL: python3 is needed to check hooks.json"; exit 1; } +HOST="$(sed -n 's/^HOST="\(.*\)"$/\1/p' "$HOOK")" +FAILURES=0 +fail() { echo "FAIL: $*"; FAILURES=$((FAILURES + 1)); } + +# hooks.json: each event runs the script with its argument and timeout. +HOOKS_ERRORS="$(python3 - "$PLUGIN/hooks/hooks.json" "${HOOK#"$PLUGIN"/}" <<'PY' +import json, sys +path, script = sys.argv[1], sys.argv[2] +with open(path) as f: + hooks = json.load(f)["hooks"] +expected = {"SessionStart": ("session-start", 15), "UserPromptSubmit": ("user-prompt-submit", 5), "Stop": ("stop", 5)} +for event, (arg, timeout) in expected.items(): + found = [h for group in hooks.get(event, []) for h in group.get("hooks", []) + if h.get("type") == "command" and h.get("command", "").startswith("bash ") + and h.get("command", "").endswith('/%s" %s' % (script, arg))] + if len(found) != 1: + print("%s does not run %s %s exactly once" % (event, script, arg)) + elif found[0].get("timeout") != timeout: + print("%s runs %s %s with timeout %r, expected %d" % (event, script, arg, found[0].get("timeout"), timeout)) +PY +)" || fail "could not parse hooks.json" +[ -z "$HOOKS_ERRORS" ] || fail "hooks.json: $HOOKS_ERRORS" + +WORK="$(mktemp -d)" && [ -d "$WORK" ] || { echo "FAIL: could not create a temp dir"; exit 1; } +WORK="$(cd "$WORK" && pwd -P)" +PIDS="$WORK/pids" +# Stop the slow stub's background calls before their directories are removed. +trap '[ -f "$PIDS" ] && kill $(cat "$PIDS") 2>/dev/null; rm -rf "$WORK"' EXIT +BIN="$WORK/bin" +LOG="$WORK/calls" +STARTED="$WORK/started" +CACHE="$WORK/xdg/altimate-code/altimate-workspace-bindings.json" +mkdir -p "$BIN" "$WORK/project/dbt/models" "$WORK/project/models" "$WORK/project-old" "$WORK/bare" "$WORK/xdg/altimate-code" +git -C "$WORK/project" init -q +# The stub notes that it started before anything else, then records its arguments and stdin; STUB_MODE picks how it +# answers. +cat > "$BIN/altimate-code" <> "$STARTED" +INPUT="\$(cat)" +printf '%s|%s\n' "\$*" "\$INPUT" >> "$LOG" +case "\${STUB_MODE:-ok}" in + ok) [ "\$3" = "session-start" ] && printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"rules"}}\n' ;; + help) echo "altimate-code learn: unknown command" ;; + fail) echo "boom" >&2; exit 1 ;; + slow) echo \$\$ >> "$PIDS"; exec sleep 4 ;; +esac +exit 0 +STUB +chmod +x "$BIN/altimate-code" + +# hook [STUB_MODE] [VAR=value ...] → prints "exit|stdout" +hook() { + local dir="$1" event="$2" path="$3" mode="${4:-ok}" out rc + shift 3; [ $# -gt 0 ] && shift + out="$(cd "$dir" && printf '{"session_id":"s1","prompt":"no, that is wrong"}' \ + | env -u ALTIMATE_LEARN PATH="$path" STUB_MODE="$mode" XDG_STATE_HOME="$WORK/xdg" "$@" bash "$HOOK" "$event" 2>&1)" + rc=$? + printf '%s|%s' "$rc" "$out" +} +# link ...: the link cache holds exactly these folders (written as the CLI writes them: JSON strings). +link() { + python3 -c 'import json, sys; print(json.dumps({"bindings": {d: {"datamateId": 1} for d in sys.argv[1:]}}))' "$@" > "$CACHE" +} +SYS="/usr/bin:/bin" +JSON='0|{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"rules"}}' +# Wait for a background call to land in the log (at most 5s). +await_log() { for _ in $(seq 50); do grep -q "$1" "$LOG" 2>/dev/null && return 0; sleep 0.1; done; return 1; } +# Session start runs the CLI in the foreground, so whether it started is known once the hook returns. +started() { [ -s "$STARTED" ]; } +reset() { : > "$LOG"; : > "$STARTED"; } + +reset +# No workspace link: the CLI is not started at session start. +[ "$(hook "$WORK/project" session-start "$BIN:$SYS")" = "0|" ] || fail "session start printed output in a project with no link" +! started || fail "session start ran the CLI in a project with no link: $(cat "$LOG")" +# A workspace rules state file in the repository (a branch or a merge can bring one) does not make it linked. +mkdir -p "$WORK/project/.altimate-code/learn/workspace-rules" +echo '{"version":1,"scope":{"datamateId":1}}' > "$WORK/project/.altimate-code/learn/workspace-rules/rules.json" +reset +[ "$(hook "$WORK/project" session-start "$BIN:$SYS")" = "0|" ] || fail "a rules state file in the repository printed output" +! started || fail "a rules state file in the repository ran the CLI at session start" + +# A link for a folder inside the repository (a dbt project), started below it: the CLI runs. +link "$WORK/project/dbt" +[ "$(hook "$WORK/project/dbt/models" session-start "$BIN:$SYS")" = "$JSON" ] \ + || fail "session start does not print the CLI's hook JSON below a linked folder" +grep -qx "learn hook session-start --host $HOST|{\"session_id\":\"s1\",\"prompt\":\"no, that is wrong\"}" "$LOG" \ + || fail "session start does not pass the hook input and host through: $(cat "$LOG")" +reset +[ "$(hook "$WORK/project/dbt" session-start "$BIN:$SYS")" = "$JSON" ] || fail "session start in the linked folder did not run the CLI" +# The same link, started at the repository root: the CLI would not find it, so it is not started. +reset +[ "$(hook "$WORK/project" session-start "$BIN:$SYS")" = "0|" ] || fail "session start at the root printed output with only a child folder linked" +! started || fail "session start at the repository root ran the CLI with only a child folder linked" +# A link for the repository, started in a subfolder: the CLI runs. +link "$WORK/project" +reset +[ "$(hook "$WORK/project/dbt/models" session-start "$BIN:$SYS")" = "$JSON" ] || fail "session start below a linked repository did not run the CLI" +# A link for a sibling folder sharing the repository's name as a prefix: not a link for this repository. +link "$WORK/project-old" "$WORK/projectX/dbt" +reset +[ "$(hook "$WORK/project" session-start "$BIN:$SYS")" = "0|" ] || fail "a sibling-prefix link printed output" +! started || fail "session start ran the CLI for a link to $WORK/project-old" +# A folder named only as a value (another row's `projectPath`), not as a key: not linked. +python3 -c 'import json, sys; print(json.dumps({"bindings": {sys.argv[1] + "/dbt": {"datamateId": 1, "projectPath": sys.argv[1]}}}, indent=2))' "$WORK/project" > "$CACHE" +reset +[ "$(hook "$WORK/project" session-start "$BIN:$SYS")" = "0|" ] || fail "a folder named only as a value printed output" +! started || fail "session start ran the CLI for a folder named only as a projectPath value" +# Pretty-printed keys still match, and a dot in a folder name is not a regular-expression wildcard. +mkdir -p "$WORK/dot.repo" && git -C "$WORK/dot.repo" init -q +link "$WORK/dotXrepo" +reset +[ "$(hook "$WORK/dot.repo" session-start "$BIN:$SYS")" = "0|" ] && ! started || fail "a dot in a folder name matched another character" +python3 -c 'import json, sys; print(json.dumps({"bindings": {sys.argv[1]: {"datamateId": 1}}}, indent=2))' "$WORK/dot.repo" > "$CACHE" +reset +[ "$(hook "$WORK/dot.repo" session-start "$BIN:$SYS")" = "$JSON" ] || fail "a pretty-printed link cache key was not found" +# Folder names with a quote or a backslash are matched as their JSON-escaped keys. +ODD="$WORK/odd\"name\\repo" +mkdir -p "$ODD/sub" && git -C "$ODD" init -q +link "$ODD" +reset +[ "$(hook "$ODD/sub" session-start "$BIN:$SYS")" = "$JSON" ] || fail "a linked folder whose name has a quote or backslash was not found" +# On Windows (Git Bash, MSYS, Cygwin) the CLI keys the cache by native paths; a stub `cygpath -w` stands in for it. +mkdir -p "$WORK/winbin" +printf '#!/bin/sh\nprintf "C:%%s" "$2" | tr / "\\\\\\\\"\n' > "$WORK/winbin/cygpath" +chmod +x "$WORK/winbin/cygpath" +link "C:$(printf '%s' "$WORK/project/dbt" | tr / '\\')" +reset +[ "$(hook "$WORK/project/dbt/models" session-start "$WORK/winbin:$BIN:$SYS")" = "$JSON" ] \ + || fail "a link keyed by a native Windows path was not found: $(cat "$CACHE")" +reset +[ "$(hook "$WORK/project" session-start "$WORK/winbin:$BIN:$SYS")" = "0|" ] && ! started \ + || fail "session start at the repository root ran the CLI with only a child folder linked (Windows paths)" + +# A `cygpath` that prints nothing gives no key, which must not match an empty string in the cache. +printf '#!/bin/sh\nexit 1\n' > "$WORK/winbin/cygpath" +python3 -c 'import json; print(json.dumps({"bindings": {"": {"datamateId": 1}}}))' > "$CACHE" +reset +[ "$(hook "$WORK/project" session-start "$WORK/winbin:$BIN:$SYS")" = "0|" ] && ! started \ + || fail "an empty key from cygpath matched an empty string in the link cache" +# A folder reached through a symlink, linked by that path (as an older CLI wrote it): found by the path the host used. +ln -s "$WORK/project" "$WORK/via-link" +link "$WORK/via-link/dbt" +reset +[ "$(hook "$WORK/via-link/dbt/models" session-start "$BIN:$SYS")" = "$JSON" ] \ + || fail "a link keyed by the symlinked path was not found" +# The walk by that path still ends at the repository root. +link "$WORK" +reset +[ "$(hook "$WORK/via-link/dbt" session-start "$BIN:$SYS")" = "0|" ] && ! started \ + || fail "the walk by the symlinked path went above the repository root" + +link "$WORK/project" +[ "$(hook "$WORK/project" session-start "$BIN:$SYS" help)" = "0|" ] || fail "a CLI without \`learn hook\` leaks its output" +[ "$(hook "$WORK/project" session-start "$BIN:$SYS" fail)" = "0|" ] || fail "a failing CLI fails the hook or leaks its error" +[ "$(hook "$WORK/project" session-start "$SYS")" = "0|" ] || fail "a missing CLI fails the hook" +# ALTIMATE_LEARN=0 (or false, any case) turns everything off: the CLI is not started. +for off in 0 false FALSE; do + reset + [ "$(hook "$WORK/project" session-start "$BIN:$SYS" ok ALTIMATE_LEARN="$off")" = "0|" ] \ + || fail "session start with ALTIMATE_LEARN=$off printed output" + ! started || fail "session start with ALTIMATE_LEARN=$off ran the CLI" +done + +# Per-turn events with no link (the repository's planted rules state file still there), and with ALTIMATE_LEARN=0 where +# the repository is linked, start nothing. They would run the CLI in the background, so a positive call made afterwards +# has to land first (proving background calls get through) and the log is then watched for a bounded time. +link +reset +for event in user-prompt-submit stop; do + [ "$(hook "$WORK/project" "$event" "$BIN:$SYS")" = "0|" ] || fail "$event without a link did not exit quietly" +done +link "$WORK/project" +for event in user-prompt-submit stop; do + [ "$(hook "$WORK/project" "$event" "$BIN:$SYS" ok ALTIMATE_LEARN=0)" = "0|" ] || fail "$event with ALTIMATE_LEARN=0 did not exit quietly" +done +[ "$(hook "$WORK/project" stop "$BIN:$SYS")" = "0|" ] || fail "stop in a linked repository did not exit quietly" +await_log "learn hook stop --host $HOST" || fail "stop in a linked repository did not run the CLI" +for _ in $(seq 20); do [ "$(grep -c . "$STARTED")" -gt 1 ] && break; sleep 0.1; done +[ "$(cat "$STARTED")" = "learn hook stop --host $HOST" ] \ + || fail "per-turn events ran the CLI without a link or with ALTIMATE_LEARN=0: $(cat "$STARTED")" + +reset +for event in user-prompt-submit stop; do + start=$(date +%s) + [ "$(hook "$WORK/project/models" "$event" "$BIN:$SYS" slow)" = "0|" ] || fail "$event did not exit quietly" + [ $(( $(date +%s) - start )) -lt 3 ] || fail "$event waited for the CLI instead of running it in the background" + await_log "learn hook $event --host $HOST|{\"session_id\":\"s1\"" || fail "$event did not run the CLI with the hook input" +done + +[ "$(hook "$WORK/bare" user-prompt-submit "$BIN:$SYS")" = "0|" ] || fail "outside a git repository the hook failed" + +[ "$FAILURES" -eq 0 ] && echo "ok" || { echo "$FAILURES failure(s)"; exit 1; }