From d95762ebc0ea82faa17f61ce6de37e29dfaaa2b5 Mon Sep 17 00:00:00 2001 From: Sarav Date: Fri, 30 Oct 2026 10:50:43 +0530 Subject: [PATCH 1/8] feat: team rules from a linked Altimate workspace in Claude Code sessions - Workspace-rules hooks (SessionStart, UserPromptSubmit, Stop) call `altimate-code learn hook `. - Session start gives the agent the workspace's approved team rules, the number of proposals waiting, and new reviews. - Corrections are recorded with no model call. - The post-turn check runs in the background. - The hooks always exit 0, run the per-turn events only where workspace rules state exists, and print only the CLI's hook JSON. - `/altimate-code:rules` answers stored proposals through `learn proposals reply`. - Tests: a stub-CLI test for the hook script, and the resolver test covers the new command and `learn` calls. - README, CHANGELOG and PRIVACY (what the hooks read and send), version 1.2.0. Co-Authored-By: Claude Opus 5.5 --- .claude-plugin/marketplace.json | 2 +- PRIVACY.md | 21 +++++- .../altimate-code/.claude-plugin/plugin.json | 2 +- plugins/altimate-code/CHANGELOG.md | 8 ++ plugins/altimate-code/README.md | 17 ++++- plugins/altimate-code/commands/rules.md | 61 +++++++++++++++ .../hooks-handlers/workspace-rules.sh | 43 +++++++++++ plugins/altimate-code/hooks/hooks.json | 29 ++++++- tests/resolve-cli.sh | 3 +- tests/workspace-rules-hook.sh | 75 +++++++++++++++++++ 10 files changed, 252 insertions(+), 9 deletions(-) create mode 100644 plugins/altimate-code/commands/rules.md create mode 100755 plugins/altimate-code/hooks-handlers/workspace-rules.sh create mode 100755 tests/workspace-rules-hook.sh diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 8a3dc75..d146057 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,7 +7,7 @@ }, "metadata": { "description": "Altimate's Claude Code plugin — delegates dbt and warehouse work to the altimate-code CLI agent.", - "version": "1.1.1" + "version": "1.2.0" }, "plugins": [ { diff --git a/PRIVACY.md b/PRIVACY.md index 47d28c0..8ff5124 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -9,16 +9,31 @@ This policy supplements the Altimate AI [Privacy Policy](https://www.altimate.ai ## The short version -- The plugin itself collects **no data**. It is a routing layer (a skill, a slash command, and a static session hook) that tells Claude Code when to delegate a task to the altimate-code CLI installed on your machine. +- The plugin itself collects **no data**. It is a routing layer (skills, slash commands, and session hooks) that tells Claude Code when to delegate a task to the altimate-code CLI installed on your machine. +- In a project linked to an Altimate workspace with learning on, its workspace-rules hooks hand your messages and the session transcript to the local altimate-code CLI, which keeps corrections on your machine and may send a digest to your LLM provider to word a proposed team rule. A proposal reaches your workspace only when you send or approve it. 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. - Installing the plugin is an explicit act — `/plugin marketplace add` does not install it, and `/plugin install` never auto-runs. Nothing is delegated until the plugin is installed and you send a task that matches the skill. ## What the plugin does and does not do -The plugin contains no executable service of its own. Its only runtime behavior is a SessionStart hook that prints a fixed, static text block describing the skill's capabilities. It does not read your files, does not phone home, and does not collect analytics. +The plugin contains no executable service of its own. At session start, a hook prints a fixed, static text block describing the skill's capabilities. The workspace-rules hooks (see below) call the locally installed altimate-code CLI. The plugin does not phone home and does not collect analytics. -Data leaves Claude Code's process only when the delegation skill or the `/altimate-code:altimate` command fires and invokes the locally installed altimate-code CLI. +Data leaves Claude Code's process only when the delegation skill, the `/altimate-code:altimate`, `/altimate-code:review` or `/altimate-code:rules` command, or a workspace-rules hook invokes the locally installed altimate-code CLI. + +## Workspace rules + +These hooks act only in a project linked to an Altimate workspace, with learning on, workspaces on and you signed in to Altimate. Otherwise the session-start hook reads only local altimate-code state and sends nothing, and the per-turn hooks do not run the CLI at all in a project without workspace rules state. + +| 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 | +| Each message you send | The local CLI checks it, with no model call; 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 | Read locally by the CLI; a bounded, redacted digest goes to your configured LLM provider to word one proposed rule | Local only; the provider's handling is as for delegated tasks above | +| 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` | 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 | + +Turn this off with `altimate-code learn disable` in the project, `ALTIMATE_LEARN=0`, or the workspace's "Learn from sessions" setting. ## Data flows when a task is delegated diff --git a/plugins/altimate-code/.claude-plugin/plugin.json b/plugins/altimate-code/.claude-plugin/plugin.json index d74bcee..52df7ac 100644 --- a/plugins/altimate-code/.claude-plugin/plugin.json +++ b/plugins/altimate-code/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "altimate-code", "displayName": "Altimate Code", - "version": "1.1.1", + "version": "1.2.0", "description": "Delegates dbt and warehouse work to altimate-code, a specialized CLI agent with 100+ purpose-built data tools (SQL analysis, column-level lineage, dbt build/test/run, warehouse profiling, FinOps, connectivity to Snowflake, BigQuery, Redshift, Databricks, Postgres, MySQL, DuckDB), and to the skills, knowledge and memory of a linked Altimate workspace.", "author": { "name": "AltimateAI", diff --git a/plugins/altimate-code/CHANGELOG.md b/plugins/altimate-code/CHANGELOG.md index 55a43e6..09a16b1 100644 --- a/plugins/altimate-code/CHANGELOG.md +++ b/plugins/altimate-code/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this plugin will be documented in this file. +## [1.2.0] — 2026-10-30 + +### 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. +- `/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 only in a linked project with learning on. They need an altimate-code release with `learn hook`; with an older one they do nothing. + ## [1.1.1] — 2026-10-20 ### Fixed diff --git a/plugins/altimate-code/README.md b/plugins/altimate-code/README.md index 17af579..fe7392a 100644 --- a/plugins/altimate-code/README.md +++ b/plugins/altimate-code/README.md @@ -38,7 +38,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 static description of altimate-code's capabilities to the session, so Claude Code knows when a task fits. +1. A **SessionStart hook** adds a static description of altimate-code's capabilities to the session, so Claude Code knows when a task fits. In a linked project, the [team rules](#team-rules) hooks add the workspace's rules too. 2. When a data-engineering task fires the **skill** (or you invoke **`/altimate-code:altimate `** explicitly), Claude Code runs `altimate run ""` locally with its `builder` agent. 3. altimate-code plans and executes the task using your configured LLM provider. Warehouse queries, dbt commands, and file edits run **locally under your credentials**. 4. The result is written to a local output file and surfaced back in your Claude Code conversation. @@ -54,6 +54,17 @@ An [Altimate workspace](https://help.altimate.ai/workspaces/) holds your team's What altimate-code learns during these tasks is saved to the workspace's memory and shared with your team in the CLI, the IDE and Studio. Skills added to the workspace reach the next task. +### Team rules + +With learning on (the workspace's "Learn from sessions", or `altimate learn enable` in the project), the workspace's approved **team rules** apply to Claude Code itself, not only to the tasks it hands over: + +- **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: …"** in a task handed to altimate-code is proposed the same way. +- **`/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. + +The same commands work in a terminal: `altimate learn proposals`, `altimate learn proposals reply --send`. This needs an altimate-code release with workspace rules. + ## Example prompts Once installed, try these in Claude Code (or prefix any of them with `/altimate-code:altimate`): @@ -80,6 +91,7 @@ Full details: **[PRIVACY.md](../../PRIVACY.md)** · Security policy and disclosu | `UNKNOWN_USER` / `Database does not exist` mid-run | The CLI connects but your warehouse credentials don't match this project — reconfigure warehouse auth in the `altimate` TUI (`altimate-code` on npm installs older than v0.7.1) or `profiles.yml`. | | `Not signed in to Altimate` | Run `altimate auth login -p altimate-backend --method "Altimate LLM Gateway"` (`altimate-code` on npm installs older than v0.7.1). | | 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. | +| Team rules don't appear at session start | Check `altimate learn status` in the project: it needs learning on, a linked workspace and an Altimate sign-in. 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). @@ -88,7 +100,8 @@ Still stuck? See [Support](#support). - **Skill** — `skills/altimate-code/SKILL.md` — delegation logic and workspace guidance - **Command** — `commands/altimate.md` — `/altimate-code:altimate` slash command for explicit delegation -- **Hook** — `hooks/hooks.json` — SessionStart capability description (static text only) +- **Command** — `commands/rules.md` — `/altimate-code:rules` to answer proposed team rules +- **Hooks** — `hooks/hooks.json` — SessionStart capability description (static text), and the team rules hooks (`hooks-handlers/workspace-rules.sh`): SessionStart, UserPromptSubmit and Stop, which call `altimate learn hook ` and never block or fail the session ## Support diff --git a/plugins/altimate-code/commands/rules.md b/plugins/altimate-code/commands/rules.md new file mode 100644 index 0000000..dfbd914 --- /dev/null +++ b/plugins/altimate-code/commands/rules.md @@ -0,0 +1,61 @@ +--- +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`. For "Edit wording", ask for the new wording (one line, at most 140 characters). Pass it through a here-doc so the shell never interprets what the user typed: + ```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), and start the closing `ALTIMATE_RULE` line at the beginning of its line. For any other choice, run the same command with that flag instead of `--edit="$WORDING"` (and without the here-doc). + + The JSON answer is `{"ok": true, "message": …, "closed": …}` or `{"ok": false, "code": …, "error": …}`. Show the `message` or `error` to the user as it is. Then: + - `closed: false` after an edit: the proposal is still open with the new wording; ask about it again. + - `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. + +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..4ad5337 --- /dev/null +++ b/plugins/altimate-code/hooks-handlers/workspace-rules.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Workspace rules hook for the altimate-code plugin. In a project linked to an +# Altimate workspace with learning on, it brings the workspace's team rules into +# this session and lets corrections made here become proposed rules, through +# `altimate-code learn hook ` (which applies every gate: learning and +# workspaces on, signed in, linked). +# 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 +# (in the background) +# It never fails or blocks the session: every path exits 0 and the CLI's errors +# are dropped. The per-turn events run only where workspace rules state exists +# (session start is what creates it), so other projects pay nothing. +# +# The hook input is passed through on stdin; nothing here parses it. + +EVENT="${1:-}" +HOST="claude" +INPUT="$(cat)" + +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 + +case "$EVENT" in + session-start) + 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) + ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" + [ -f "$ROOT/.altimate-code/learn/workspace-rules/rules.json" ] || exit 0 + ( printf '%s' "$INPUT" | nohup "$ALTIMATE_BIN" learn hook "$EVENT" --host "$HOST" & ) >/dev/null 2>&1 "$BIN/altimate-code" <> "$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) sleep 4 ;; +esac +exit 0 +STUB +chmod +x "$BIN/altimate-code" + +# hook [STUB_MODE] → prints "exit|stdout" +hook() { + local out rc + out="$(cd "$1" && printf '{"session_id":"s1","prompt":"no, that is wrong"}' | env PATH="$3" STUB_MODE="${4:-ok}" bash "$HOOK" "$2" 2>&1)" + rc=$? + printf '%s|%s' "$rc" "$out" +} +SYS="/usr/bin:/bin" +# 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; } + +: > "$LOG" +[ "$(hook "$WORK/project" session-start "$BIN:$SYS")" = '0|{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"rules"}}' ] \ + || fail "session start does not print the CLI's hook JSON" +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")" + +[ "$(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" + +: > "$LOG" +for event in user-prompt-submit stop; do + [ "$(hook "$WORK/project" "$event" "$BIN:$SYS")" = "0|" ] || fail "$event without rules state did not exit quietly" +done +sleep 0.5 +[ ! -s "$LOG" ] || fail "per-turn events ran the CLI without workspace rules state: $(cat "$LOG")" + +mkdir -p "$WORK/project/.altimate-code/learn/workspace-rules" +echo '{}' > "$WORK/project/.altimate-code/learn/workspace-rules/rules.json" +mkdir -p "$WORK/project/models" +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; } From 4899cf8507fa63b0cf8f2dfca013d14f10f8fde5 Mon Sep 17 00:00:00 2001 From: Sarav Date: Fri, 30 Oct 2026 12:05:19 +0530 Subject: [PATCH 2/8] =?UTF-8?q?docs:=20team=20rules=20=E2=80=94=20direct?= =?UTF-8?q?=20"remember",=20drafting=20model,=20finishing=20an=20interrupt?= =?UTF-8?q?ed=20answer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README and CHANGELOG: "Remember this as a team rule: …" said to the agent waits as a proposal, and rule drafting needs altimate-code's own model. - `/altimate-code:rules`: handles an answer that stopped part-way (`not_available` … "was being answered with"). - Codex: the sandbox has no network, so a proposal sent from `/altimate-code:rules` is queued until the next turn. Co-Authored-By: Claude Opus 5.5 --- plugins/altimate-code/CHANGELOG.md | 1 + plugins/altimate-code/README.md | 4 ++-- plugins/altimate-code/commands/rules.md | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/altimate-code/CHANGELOG.md b/plugins/altimate-code/CHANGELOG.md index 09a16b1..7ecce5c 100644 --- a/plugins/altimate-code/CHANGELOG.md +++ b/plugins/altimate-code/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this plugin will be documented in this file. ### 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 only in a linked project with learning on. They need an altimate-code release with `learn hook`; with an older one they do nothing. diff --git a/plugins/altimate-code/README.md b/plugins/altimate-code/README.md index fe7392a..7896bd5 100644 --- a/plugins/altimate-code/README.md +++ b/plugins/altimate-code/README.md @@ -60,10 +60,10 @@ With learning on (the workspace's "Learn from sessions", or `altimate learn enab - **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: …"** in a task handed to altimate-code is proposed the same way. +- **"Remember this as a team rule: …"**, said to Claude or in a task handed to altimate-code, waits as a proposal the same way. - **`/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. -The same commands work in a terminal: `altimate learn proposals`, `altimate learn proposals reply --send`. This needs an altimate-code release with workspace rules. +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`. This needs an altimate-code release with workspace rules. ## Example prompts diff --git a/plugins/altimate-code/commands/rules.md b/plugins/altimate-code/commands/rules.md index dfbd914..879ab8c 100644 --- a/plugins/altimate-code/commands/rules.md +++ b/plugins/altimate-code/commands/rules.md @@ -57,5 +57,6 @@ Workflow (follow in order): - `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` saying it "was being answered with" another action: an earlier answer stopped part-way; offer to finish it with that action. 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. From 6e87e8f401e95a4d73491ba9e5972067e92de062 Mon Sep 17 00:00:00 2001 From: Sarav Date: Fri, 30 Oct 2026 12:54:43 +0530 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20team=20rules=20hook=20=E2=80=94=20st?= =?UTF-8?q?art=20nothing=20outside=20linked=20repositories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Session start runs the CLI only where workspace rules state exists, or where the workspace link cache names the repository or a folder inside it. Elsewhere the hook costs nothing. - The per-turn hooks share the same state check. The CLI still runs from the host's directory: a link can be for a folder inside the repository. - README: direct "remember" needs "team rule" wording. `/altimate-code:rules`: a here-doc delimiter note. - Hook test covers the pre-filter and a link for a folder inside the repository. Co-Authored-By: Claude Opus 5.5 --- plugins/altimate-code/README.md | 2 +- plugins/altimate-code/commands/rules.md | 2 +- .../hooks-handlers/workspace-rules.sh | 15 +++++++++++++-- tests/workspace-rules-hook.sh | 8 +++++++- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/plugins/altimate-code/README.md b/plugins/altimate-code/README.md index 7896bd5..4866764 100644 --- a/plugins/altimate-code/README.md +++ b/plugins/altimate-code/README.md @@ -60,7 +60,7 @@ With learning on (the workspace's "Learn from sessions", or `altimate learn enab - **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 or in a task handed to altimate-code, waits as a proposal the same way. +- **"Remember this as a team rule: …"**, said to Claude or in a task handed to altimate-code, waits as a proposal the same way. It needs the words "team rule" (or "as a rule"): "remember to run the tests" is just part of the conversation. - **`/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`. This needs an altimate-code release with workspace rules. diff --git a/plugins/altimate-code/commands/rules.md b/plugins/altimate-code/commands/rules.md index 879ab8c..8bff0da 100644 --- a/plugins/altimate-code/commands/rules.md +++ b/plugins/altimate-code/commands/rules.md @@ -48,7 +48,7 @@ Workflow (follow in order): )" "$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), and start the closing `ALTIMATE_RULE` line at the beginning of its line. For any other choice, run the same command with that flag instead of `--edit="$WORDING"` (and without the here-doc). + Keep `--edit="$WORDING"` in one argument (a wording that starts with `-` is otherwise read as a flag), and start the closing `ALTIMATE_RULE` line at the beginning of its line. 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 `{"ok": true, "message": …, "closed": …}` or `{"ok": false, "code": …, "error": …}`. Show the `message` or `error` to the user as it is. Then: - `closed: false` after an edit: the proposal is still open with the new wording; ask about it again. diff --git a/plugins/altimate-code/hooks-handlers/workspace-rules.sh b/plugins/altimate-code/hooks-handlers/workspace-rules.sh index 4ad5337..7a27d5c 100755 --- a/plugins/altimate-code/hooks-handlers/workspace-rules.sh +++ b/plugins/altimate-code/hooks-handlers/workspace-rules.sh @@ -26,8 +26,20 @@ for ALTIMATE_NAME in altimate altimate-code; do done [ -n "$ALTIMATE_BIN" ] || exit 0 +# Run from the host's directory, not the repository root: a workspace link can be for a folder inside the repository +# (a dbt project), and the CLI finds the root itself. +ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +STATE="$ROOT/.altimate-code/learn/workspace-rules/rules.json" + case "$EVENT" in session-start) + # Only a repository with workspace rules state, or one the workspace link cache names (the repository or a folder + # inside it), can have team rules: elsewhere the CLI is not started at all. + if [ ! -f "$STATE" ]; then + CACHE="${XDG_STATE_HOME:-$HOME/.local/state}/altimate-code/altimate-workspace-bindings.json" + REAL="$(cd "$ROOT" 2>/dev/null && pwd -P)" + [ -n "$REAL" ] && grep -qF "\"$REAL" "$CACHE" 2>/dev/null || exit 0 + fi 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 @@ -35,8 +47,7 @@ case "$EVENT" in esac ;; user-prompt-submit|stop) - ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" - [ -f "$ROOT/.altimate-code/learn/workspace-rules/rules.json" ] || exit 0 + [ -f "$STATE" ] || exit 0 ( printf '%s' "$INPUT" | nohup "$ALTIMATE_BIN" learn hook "$EVENT" --host "$HOST" & ) >/dev/null 2>&1 [STUB_MODE] → prints "exit|stdout" hook() { local out rc - out="$(cd "$1" && printf '{"session_id":"s1","prompt":"no, that is wrong"}' | env PATH="$3" STUB_MODE="${4:-ok}" bash "$HOOK" "$2" 2>&1)" + out="$(cd "$1" && printf '{"session_id":"s1","prompt":"no, that is wrong"}' | env PATH="$3" STUB_MODE="${4:-ok}" XDG_STATE_HOME="$WORK/xdg" bash "$HOOK" "$2" 2>&1)" rc=$? printf '%s|%s' "$rc" "$out" } @@ -44,6 +44,12 @@ SYS="/usr/bin:/bin" await_log() { for _ in $(seq 50); do grep -q "$1" "$LOG" 2>/dev/null && return 0; sleep 0.1; done; return 1; } : > "$LOG" +# Neither workspace rules state nor a 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" +[ ! -s "$LOG" ] || fail "session start ran the CLI in a project with no link: $(cat "$LOG")" +# A link for a folder inside the repository (a dbt project), started from the repository root: the CLI runs. +mkdir -p "$WORK/xdg/altimate-code" "$WORK/project/dbt" +printf '{"bindings":{"%s/dbt":{"datamateId":1}}}' "$(cd "$WORK/project" && pwd -P)" > "$WORK/xdg/altimate-code/altimate-workspace-bindings.json" [ "$(hook "$WORK/project" session-start "$BIN:$SYS")" = '0|{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"rules"}}' ] \ || fail "session start does not print the CLI's hook JSON" grep -qx "learn hook session-start --host $HOST|{\"session_id\":\"s1\",\"prompt\":\"no, that is wrong\"}" "$LOG" \ From a6a01db5bc6779892d1960ff60c66bb2e6bd44d7 Mon Sep 17 00:00:00 2001 From: Sarav Date: Wed, 4 Nov 2026 14:24:04 +0530 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20team=20rules=20hook=20=E2=80=94=20re?= =?UTF-8?q?view=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `hooks-handlers/workspace-rules.sh`: at session start, walk from the host's directory up to the repository root and match each folder as an exact JSON key of the link cache (escaped, native form via `cygpath -w` on Windows), as the CLI does. A link for a subfolder no longer starts the CLI from the repository root, and `/x/app` no longer matches `/x/app-old`. - Skip every event with `ALTIMATE_LEARN=0` (or `false`). - Correct the comments on `$ROOT`, blocking and model calls per `Stop`. - `tests/workspace-rules-hook.sh`: parse `hooks.json` and check each event's argument and timeout; cover a start below a linked subfolder, a root start with only a subfolder linked, a sibling-prefix link, quote/backslash paths, Windows native keys and `ALTIMATE_LEARN`; replace the fixed sleep with a sentinel; stop the slow stub on exit. - `commands/rules.md`: show the edit here-doc flush left, branch on `code` when `ok` is false and on `closed` when it is true, and never repeat `--approve-anyway` without fresh confirmation. - `PRIVACY.md`: state what is gated by what, who can turn capture on, the digest's contents and caps, that it goes to altimate-code's own model rather than Claude Code's, and the rules telemetry the CLI sends; add a revision date. - `README.md`, `CHANGELOG.md`: `altimate learn …` throughout, bounded session start, start in the linked folder, new clones and worktrees, where proposals are answered, and the corrected gating. Co-Authored-By: Claude Opus 5.5 --- PRIVACY.md | 26 +++- plugins/altimate-code/CHANGELOG.md | 4 +- plugins/altimate-code/README.md | 13 +- plugins/altimate-code/commands/rules.md | 52 +++---- .../hooks-handlers/workspace-rules.sh | 59 +++++--- tests/workspace-rules-hook.sh | 136 ++++++++++++++---- 6 files changed, 213 insertions(+), 77 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 8ff5124..81fffb6 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,7 @@ # Privacy Policy — Altimate Code plugin for Claude Code **Effective date:** July 9, 2026 +**Last revised:** November 4, 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) 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**. @@ -10,30 +11,39 @@ This policy supplements the Altimate AI [Privacy Policy](https://www.altimate.ai ## The short version - The plugin itself collects **no data**. It is a routing layer (skills, slash commands, and session hooks) that tells Claude Code when to delegate a task to the altimate-code CLI installed on your machine. -- In a project linked to an Altimate workspace with learning on, its workspace-rules hooks hand your messages and the session transcript to the local altimate-code CLI, which keeps corrections on your machine and may send a digest to your LLM provider to word a proposed team rule. A proposal reaches your workspace only when you send or approve it. See [Workspace rules](#workspace-rules). +- 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. - Installing the plugin is an explicit act — `/plugin marketplace add` does not install it, and `/plugin install` never auto-runs. Nothing is delegated until the plugin is installed and you send a task that matches the skill. ## What the plugin does and does not do -The plugin contains no executable service of its own. At session start, a hook prints a fixed, static text block describing the skill's capabilities. The workspace-rules hooks (see below) call the locally installed altimate-code CLI. The plugin does not phone home and does not collect analytics. +The plugin contains no executable service of its own. At session start, a hook prints a fixed, static text block describing the skill's capabilities. The workspace-rules hooks (see below) call the locally installed altimate-code CLI. The plugin itself does not phone home and does not collect analytics; the altimate-code CLI it starts sends usage telemetry (see [Usage telemetry](#usage-telemetry)). Data leaves Claude Code's process only when the delegation skill, the `/altimate-code:altimate`, `/altimate-code:review` or `/altimate-code:rules` command, or a workspace-rules hook invokes the locally installed altimate-code CLI. ## Workspace rules -These hooks act only in a project linked to an Altimate workspace, with learning on, workspaces on and you signed in to Altimate. Otherwise the session-start hook reads only local altimate-code state and sends nothing, and the per-turn hooks do not run the CLI at all in a project without workspace rules state. +**When the hooks start the CLI.** At session start, the workspace-rules hook starts the local altimate-code CLI only in a repository that already has workspace rules state (`.altimate-code/learn/workspace-rules/rules.json` at the repository root), or when the folder the session starts in, or a folder above it in the repository, is named in altimate-code's local workspace link cache (`altimate-workspace-bindings.json`). Elsewhere it starts nothing. The hooks that run on each message and after each turn start the CLI only where workspace rules state exists. 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. +- Where workspace rules state exists, 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 learning is off, you are signed out or the project is not linked, the CLI discards them and nothing leaves your machine. + +**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 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 | -| Each message you send | The local CLI checks it, with no model call; 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 | Read locally by the CLI; a bounded, redacted digest goes to your configured LLM provider to word one proposed rule | Local only; the provider's handling is as for delegated tasks above | -| 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` | Per the Altimate Privacy Policy | +| 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` (with the Claude Code session id, as `claude:`), `rule_proposal_answered`, `rule_proposal_skipped`, `rule_remember_routed`. 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 this off with `altimate-code learn disable` in the project, `ALTIMATE_LEARN=0`, or the workspace's "Learn from sessions" setting. +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. ## Data flows when a task is delegated @@ -85,6 +95,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 4, 2026:** added [Workspace rules](#workspace-rules) (the workspace-rules hooks and `/altimate-code:rules` in plugin 1.2.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/CHANGELOG.md b/plugins/altimate-code/CHANGELOG.md index 7ecce5c..086c32f 100644 --- a/plugins/altimate-code/CHANGELOG.md +++ b/plugins/altimate-code/CHANGELOG.md @@ -9,7 +9,9 @@ All notable changes to this plugin will be documented in this file. - 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 only in a linked project with learning on. They need an altimate-code release with `learn hook`; with an older one they do nothing. +- Session start runs altimate-code only in a repository with workspace rules state, or where the session's folder (or a folder above it in the repository) is in altimate-code's workspace link cache; the per-turn hooks run it only where workspace rules state exists. 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.1.1] — 2026-10-20 diff --git a/plugins/altimate-code/README.md b/plugins/altimate-code/README.md index 4866764..50d8120 100644 --- a/plugins/altimate-code/README.md +++ b/plugins/altimate-code/README.md @@ -56,14 +56,19 @@ What altimate-code learns during these tasks is saved to the workspace's memory ### Team rules -With learning on (the workspace's "Learn from sessions", or `altimate learn enable` in the project), the workspace's approved **team rules** apply to Claude Code itself, not only to the tasks it hands over: +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 or in a task handed to altimate-code, waits as a proposal the same way. It needs the words "team rule" (or "as a rule"): "remember to run the tests" is just part of the conversation. - **`/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`. This needs an altimate-code release with workspace rules. +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), and only in a repository with team rules state or a linked folder. +- **`ALTIMATE_LEARN=0`** turns all of it off: the hooks start nothing. ## Example prompts @@ -91,7 +96,7 @@ Full details: **[PRIVACY.md](../../PRIVACY.md)** · Security policy and disclosu | `UNKNOWN_USER` / `Database does not exist` mid-run | The CLI connects but your warehouse credentials don't match this project — reconfigure warehouse auth in the `altimate` TUI (`altimate-code` on npm installs older than v0.7.1) or `profiles.yml`. | | `Not signed in to Altimate` | Run `altimate auth login -p altimate-backend --method "Altimate LLM Gateway"` (`altimate-code` on npm installs older than v0.7.1). | | 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. | -| Team rules don't appear at session start | Check `altimate learn status` in the project: it needs learning on, a linked workspace and an Altimate sign-in. Update altimate-code if `altimate learn rules` is an unknown command. | +| 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). @@ -101,7 +106,7 @@ Still stuck? See [Support](#support). - **Skill** — `skills/altimate-code/SKILL.md` — delegation logic and workspace guidance - **Command** — `commands/altimate.md` — `/altimate-code:altimate` slash command for explicit delegation - **Command** — `commands/rules.md` — `/altimate-code:rules` to answer proposed team rules -- **Hooks** — `hooks/hooks.json` — SessionStart capability description (static text), and the team rules hooks (`hooks-handlers/workspace-rules.sh`): SessionStart, UserPromptSubmit and Stop, which call `altimate learn hook ` and never block or fail the session +- **Hooks** — `hooks/hooks.json` — SessionStart capability description (static text), and 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) ## Support diff --git a/plugins/altimate-code/commands/rules.md b/plugins/altimate-code/commands/rules.md index 8bff0da..3d47a6e 100644 --- a/plugins/altimate-code/commands/rules.md +++ b/plugins/altimate-code/commands/rules.md @@ -34,29 +34,33 @@ Workflow (follow in order): - `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`. For "Edit wording", ask for the new wording (one line, at most 140 characters). Pass it through a here-doc so the shell never interprets what the user typed: - ```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), and start the closing `ALTIMATE_RULE` line at the beginning of its line. 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 `{"ok": true, "message": …, "closed": …}` or `{"ok": false, "code": …, "error": …}`. Show the `message` or `error` to the user as it is. Then: - - `closed: false` after an edit: the proposal is still open with the new wording; ask about it again. - - `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` saying it "was being answered with" another action: an earlier answer stopped part-way; offer to finish it with that action. +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 index 7a27d5c..01749f8 100755 --- a/plugins/altimate-code/hooks-handlers/workspace-rules.sh +++ b/plugins/altimate-code/hooks-handlers/workspace-rules.sh @@ -1,18 +1,21 @@ #!/usr/bin/env bash # Workspace rules hook for the altimate-code plugin. In a project linked to an -# Altimate workspace with learning on, it brings the workspace's team rules into -# this session and lets corrections made here become proposed rules, through -# `altimate-code learn hook ` (which applies every gate: learning and +# Altimate workspace, it brings the workspace's team rules into this session and, +# with learning on, lets corrections made here become proposed rules, through +# `altimate learn hook ` (which applies every gate: learning and # workspaces on, signed in, linked). # 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 -# (in the background) -# It never fails or blocks the session: every path exits 0 and the CLI's errors -# are dropped. The per-turn events run only where workspace rules state exists -# (session start is what creates it), so other projects pay nothing. +# 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. The per-turn events never block: they run in the background, and only +# where workspace rules state exists. Session start waits for the CLI, and starts +# it only where that state exists or the workspace link cache names the host's +# directory or a folder above it in the repository. With ALTIMATE_LEARN=0 (or +# false) nothing is started at all. # # The hook input is passed through on stdin; nothing here parses it. @@ -20,26 +23,48 @@ 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 -# Run from the host's directory, not the repository root: a workspace link can be for a folder inside the repository -# (a dbt project), and the CLI finds the root itself. +# $ROOT only locates the state file, which the CLI keeps at the repository root. 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). ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" STATE="$ROOT/.altimate-code/learn/workspace-rules/rules.json" +# 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) and escaped, and matched with +# both quotes, so `/x/app` never matches `/x/app-old`. +linked() { + local cache="${XDG_STATE_HOME:-$HOME/.local/state}/altimate-code/altimate-workspace-bindings.json" + local top dir key + [ -f "$cache" ] || return 1 + top="$(cd "$ROOT" 2>/dev/null && pwd -P)" + dir="$(pwd -P)" + [ -n "$top" ] && [ -n "$dir" ] || return 1 + while :; do + key="$dir" + command -v cygpath >/dev/null 2>&1 && key="$(cygpath -w "$dir")" + key="$(printf '%s' "$key" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')" + grep -qF "\"$key\"" "$cache" 2>/dev/null && return 0 + case "$dir" in "$top"|/) return 1 ;; esac + dir="$(dirname "$dir")" + done +} + case "$EVENT" in session-start) - # Only a repository with workspace rules state, or one the workspace link cache names (the repository or a folder - # inside it), can have team rules: elsewhere the CLI is not started at all. - if [ ! -f "$STATE" ]; then - CACHE="${XDG_STATE_HOME:-$HOME/.local/state}/altimate-code/altimate-workspace-bindings.json" - REAL="$(cd "$ROOT" 2>/dev/null && pwd -P)" - [ -n "$REAL" ] && grep -qF "\"$REAL" "$CACHE" 2>/dev/null || exit 0 - fi + # Only a repository with workspace rules state, or a directory the link cache names, can have team rules: + # elsewhere the CLI is not started at all. + [ -f "$STATE" ] || 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 diff --git a/tests/workspace-rules-hook.sh b/tests/workspace-rules-hook.sh index 5e2e64d..a18a806 100755 --- a/tests/workspace-rules-hook.sh +++ b/tests/workspace-rules-hook.sh @@ -1,74 +1,162 @@ #!/usr/bin/env bash -# Checks the workspace-rules hook against a stub CLI: it passes the hook input through, prints only the CLI's hook -# JSON at session start, runs the per-turn events in the background and only where workspace rules state exists, -# and never fails or blocks the session. +# Checks the workspace-rules hook against a stub CLI: hooks.json runs it for the right events, arguments and timeouts; +# it passes the hook input through, prints only the CLI's hook JSON at session start, starts the CLI at session start +# only where workspace rules state exists or the link cache names the directory or a folder above it in the +# repository, runs the per-turn events in the background and only where workspace rules state exists, starts nothing +# with ALTIMATE_LEARN=0, and never fails or blocks the session. set -u cd "$(dirname "$0")/.." -HOOK="$(find "$PWD/plugins/altimate-code" -name '*workspace-rules.sh' | head -n1)" +PLUGIN="$PWD/plugins/altimate-code" +HOOK="$(find "$PLUGIN" -name '*workspace-rules.sh' | head -n1)" [ -f "$HOOK" ] || { echo "FAIL: no workspace-rules hook script"; exit 1; } -grep -q "$(basename "$HOOK")" plugins/altimate-code/hooks/hooks.json || { echo "FAIL: hooks.json does not run $(basename "$HOOK")"; exit 1; } +command -v python3 >/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; } -trap 'rm -rf "$WORK"' EXIT +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" -mkdir -p "$BIN" "$WORK/project" "$WORK/bare" +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 records its arguments and stdin; STUB_MODE picks how it answers. +# 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) sleep 4 ;; + slow) echo \$\$ >> "$PIDS"; exec sleep 4 ;; esac exit 0 STUB chmod +x "$BIN/altimate-code" -# hook [STUB_MODE] → prints "exit|stdout" +# hook [STUB_MODE] [VAR=value ...] → prints "exit|stdout" hook() { - local out rc - out="$(cd "$1" && printf '{"session_id":"s1","prompt":"no, that is wrong"}' | env PATH="$3" STUB_MODE="${4:-ok}" XDG_STATE_HOME="$WORK/xdg" bash "$HOOK" "$2" 2>&1)" + 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"; } -: > "$LOG" +reset # Neither workspace rules state nor a 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" -[ ! -s "$LOG" ] || fail "session start ran the CLI in a project with no link: $(cat "$LOG")" -# A link for a folder inside the repository (a dbt project), started from the repository root: the CLI runs. -mkdir -p "$WORK/xdg/altimate-code" "$WORK/project/dbt" -printf '{"bindings":{"%s/dbt":{"datamateId":1}}}' "$(cd "$WORK/project" && pwd -P)" > "$WORK/xdg/altimate-code/altimate-workspace-bindings.json" -[ "$(hook "$WORK/project" session-start "$BIN:$SYS")" = '0|{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"rules"}}' ] \ - || fail "session start does not print the CLI's hook JSON" +! started || fail "session start ran the CLI in a project with no link: $(cat "$LOG")" + +# 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" +# 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)" +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 -: > "$LOG" +# Per-turn events with no workspace rules state, and with ALTIMATE_LEARN=0 where the state exists, 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. +reset for event in user-prompt-submit stop; do [ "$(hook "$WORK/project" "$event" "$BIN:$SYS")" = "0|" ] || fail "$event without rules state did not exit quietly" done -sleep 0.5 -[ ! -s "$LOG" ] || fail "per-turn events ran the CLI without workspace rules state: $(cat "$LOG")" - mkdir -p "$WORK/project/.altimate-code/learn/workspace-rules" echo '{}' > "$WORK/project/.altimate-code/learn/workspace-rules/rules.json" -mkdir -p "$WORK/project/models" +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 with rules state did not exit quietly" +await_log "learn hook stop --host $HOST" || fail "stop with rules state 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 rules state 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" From 5978ebee2fb287167e74a138d2b732c124d6f00f Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 5 Nov 2026 08:04:04 +0530 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20team=20rules=20hook=20=E2=80=94=20ru?= =?UTF-8?q?n=20only=20where=20the=20link=20cache=20names=20the=20folder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every hook event now starts altimate-code only where altimate-code's workspace link cache (in the person's own state directory) names the session's folder or a folder above it in the repository. The per-turn hooks no longer run because `.altimate-code/learn/workspace-rules/rules.json` exists: a file in the repository can come from a branch or a merge, and altimate-code no longer keeps its rules state there. - Hook script: one gate (`linked`) for every event; `$STATE` removed. - Tests: a rules state file planted in the repository starts nothing, at session start or per turn; the per-turn checks use the link cache. - PRIVACY.md, README, CHANGELOG: the hooks run only in a linked folder; the rules state, the proposals waiting to be sent and review notices are kept in the person's own state directory, outside the project. Co-Authored-By: Claude Opus 5.5 --- PRIVACY.md | 6 ++-- plugins/altimate-code/CHANGELOG.md | 2 +- plugins/altimate-code/README.md | 2 +- .../hooks-handlers/workspace-rules.sh | 23 ++++++------- tests/workspace-rules-hook.sh | 34 +++++++++++-------- 5 files changed, 36 insertions(+), 31 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 81fffb6..66e546c 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -24,19 +24,19 @@ Data leaves Claude Code's process only when the delegation skill, the `/altimate ## Workspace rules -**When the hooks start the CLI.** At session start, the workspace-rules hook starts the local altimate-code CLI only in a repository that already has workspace rules state (`.altimate-code/learn/workspace-rules/rules.json` at the repository root), or when the folder the session starts in, or a folder above it in the repository, is named in altimate-code's local workspace link cache (`altimate-workspace-bindings.json`). Elsewhere it starts nothing. The hooks that run on each message and after each turn start the CLI only where workspace rules state exists. With `ALTIMATE_LEARN=0` (or `false`) no hook starts the CLI. +**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. -- Where workspace rules state exists, 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 learning is off, you are signed out or the project is not linked, the CLI discards them and nothing leaves your machine. +- 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 learning is off, you are signed out or the project is not linked, the CLI discards them and nothing leaves your machine. **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 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 | +| 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 (in your state directory, `~/.local/state/altimate-code/`, outside the project) holds the same rules. That state also keeps proposals waiting to be sent and your review notices | | 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 | diff --git a/plugins/altimate-code/CHANGELOG.md b/plugins/altimate-code/CHANGELOG.md index 086c32f..eb1b0f7 100644 --- a/plugins/altimate-code/CHANGELOG.md +++ b/plugins/altimate-code/CHANGELOG.md @@ -9,7 +9,7 @@ All notable changes to this plugin will be documented in this file. - 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. -- Session start runs altimate-code only in a repository with workspace rules state, or where the session's folder (or a folder above it in the repository) is in altimate-code's workspace link cache; the per-turn hooks run it only where workspace rules state exists. 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. +- 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. diff --git a/plugins/altimate-code/README.md b/plugins/altimate-code/README.md index 50d8120..b4ae335 100644 --- a/plugins/altimate-code/README.md +++ b/plugins/altimate-code/README.md @@ -67,7 +67,7 @@ Rule drafting uses altimate-code's own model (sign in to the Altimate LLM Gatewa - **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), and only in a repository with team rules state or a linked 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 diff --git a/plugins/altimate-code/hooks-handlers/workspace-rules.sh b/plugins/altimate-code/hooks-handlers/workspace-rules.sh index 01749f8..2c965e9 100755 --- a/plugins/altimate-code/hooks-handlers/workspace-rules.sh +++ b/plugins/altimate-code/hooks-handlers/workspace-rules.sh @@ -11,11 +11,11 @@ # 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. The per-turn events never block: they run in the background, and only -# where workspace rules state exists. Session start waits for the CLI, and starts -# it only where that state exists or the workspace link cache names the host's -# directory or a folder above it in the repository. With ALTIMATE_LEARN=0 (or -# false) nothing is started at all. +# 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. @@ -34,10 +34,10 @@ for ALTIMATE_NAME in altimate altimate-code; do done [ -n "$ALTIMATE_BIN" ] || exit 0 -# $ROOT only locates the state file, which the CLI keeps at the repository root. 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). +# $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)" -STATE="$ROOT/.altimate-code/learn/workspace-rules/rules.json" # 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 @@ -62,9 +62,8 @@ linked() { case "$EVENT" in session-start) - # Only a repository with workspace rules state, or a directory the link cache names, can have team rules: - # elsewhere the CLI is not started at all. - [ -f "$STATE" ] || linked || exit 0 + # 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 @@ -72,7 +71,7 @@ case "$EVENT" in esac ;; user-prompt-submit|stop) - [ -f "$STATE" ] || exit 0 + linked || exit 0 ( printf '%s' "$INPUT" | nohup "$ALTIMATE_BIN" learn hook "$EVENT" --host "$HOST" & ) >/dev/null 2>&1 "$LOG"; : > "$STARTED"; } reset -# Neither workspace rules state nor a workspace link: the CLI is not started at session start. +# 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" @@ -138,23 +144,23 @@ for off in 0 false FALSE; do ! started || fail "session start with ALTIMATE_LEARN=$off ran the CLI" done -# Per-turn events with no workspace rules state, and with ALTIMATE_LEARN=0 where the state exists, 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. +# 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 rules state did not exit quietly" + [ "$(hook "$WORK/project" "$event" "$BIN:$SYS")" = "0|" ] || fail "$event without a link did not exit quietly" done -mkdir -p "$WORK/project/.altimate-code/learn/workspace-rules" -echo '{}' > "$WORK/project/.altimate-code/learn/workspace-rules/rules.json" +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 with rules state did not exit quietly" -await_log "learn hook stop --host $HOST" || fail "stop with rules state did not run the CLI" +[ "$(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 rules state or with ALTIMATE_LEARN=0: $(cat "$STARTED")" + || 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 From fb70ac1148dfddf6f445a9879f414b03f210073e Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 5 Nov 2026 10:46:05 +0530 Subject: [PATCH 6/8] fix: team rules hook matches link-cache keys only; privacy and test docs - The link-cache check matches a folder only as a JSON key (quoted path then `:`), escaped for the regular expression, so a folder named only as another row's `projectPath` value does not start the CLI. New fixtures: a folder named only as a value, a dot in a folder name, and a pretty-printed cache. - PRIVACY.md: team rules are given only while signed in as the account that pulled them; the rules state is in the user state directory from the altimate-code release that moves it there (earlier releases keep it in the project). Adds `rules_command`, says session events carry the session id, and names `ALTIMATE_LEARN_MODEL` ahead of `learn.model`. - CI: a workflow runs `tests/*.sh` on Linux and macOS; CONTRIBUTING lists the tests and the current layout. - The hook header says which switch gates what. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tests.yml | 30 +++++++++++++++++++ CONTRIBUTING.md | 29 ++++++++++++++---- PRIVACY.md | 6 ++-- .../hooks-handlers/workspace-rules.sh | 19 +++++++----- tests/workspace-rules-hook.sh | 13 ++++++++ 5 files changed, 81 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/tests.yml 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 911bf60..7385b82 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,22 +28,34 @@ 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/ │ │ └── plugin.json # plugin manifest │ ├── skills/ -│ │ └── altimate-code/ -│ │ └── SKILL.md # delegation logic +│ │ ├── altimate-code/ +│ │ │ └── SKILL.md # delegation logic +│ │ └── review/ +│ │ └── SKILL.md # dbt/SQL PR reviewer routing │ ├── hooks/ -│ │ └── hooks.json # SessionStart hook config +│ │ └── hooks.json # SessionStart, UserPromptSubmit and Stop hook config │ ├── hooks-handlers/ -│ │ └── session-start.sh # SessionStart hook handler +│ │ ├── session-start.sh # SessionStart capability description +│ │ └── workspace-rules.sh # team rules hooks (session-start, user-prompt-submit, stop) │ ├── commands/ -│ │ └── altimate.md # /altimate-code:altimate slash command +│ │ ├── altimate.md # /altimate-code:altimate slash command +│ │ ├── review.md # /altimate-code:review slash command +│ │ └── 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 @@ -81,6 +97,7 @@ claude plugin install altimate-code@altimate-claude-plugin - **Skill** — `plugins/altimate-code/skills/altimate-code/SKILL.md` — the delegation logic (`altimate-code run "$USER_TASK"` and surface the result). - **Hook** — `plugins/altimate-code/hooks/hooks.json` + `plugins/altimate-code/hooks-handlers/session-start.sh` — SessionStart hook that describes the plugin's capabilities in the system prompt so Claude can route to it when the task shape fits. +- **Hooks** — `plugins/altimate-code/hooks-handlers/workspace-rules.sh` — the team rules hooks (SessionStart, UserPromptSubmit, Stop), which call `altimate learn hook ` only in a folder altimate-code's link cache names, and never fail the session. - **Command** — `plugins/altimate-code/commands/altimate.md` — `/altimate-code:altimate` slash command for explicit delegation. ## Reporting bugs / requesting features diff --git a/PRIVACY.md b/PRIVACY.md index 66e546c..34fb8af 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -32,16 +32,16 @@ Data leaves Claude Code's process only when the delegation skill, the `/altimate - *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 learning is off, you are signed out or the project is not linked, the CLI discards them and nothing leaves your machine. -**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 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. +**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 (in your state directory, `~/.local/state/altimate-code/`, outside the project) holds the same rules. That state also keeps proposals waiting to be sent and your review notices | +| 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` (with the Claude Code session id, as `claude:`), `rule_proposal_answered`, `rule_proposal_skipped`, `rule_remember_routed`. 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 | +| 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. diff --git a/plugins/altimate-code/hooks-handlers/workspace-rules.sh b/plugins/altimate-code/hooks-handlers/workspace-rules.sh index 2c965e9..a38f3ad 100755 --- a/plugins/altimate-code/hooks-handlers/workspace-rules.sh +++ b/plugins/altimate-code/hooks-handlers/workspace-rules.sh @@ -1,9 +1,13 @@ #!/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 on, lets corrections made here become proposed rules, through -# `altimate learn hook ` (which applies every gate: learning and -# workspaces on, signed in, linked). +# 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 @@ -41,8 +45,9 @@ 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) and escaped, and matched with -# both quotes, so `/x/app` never matches `/x/app-old`. +# 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. linked() { local cache="${XDG_STATE_HOME:-$HOME/.local/state}/altimate-code/altimate-workspace-bindings.json" local top dir key @@ -53,8 +58,8 @@ linked() { while :; do key="$dir" command -v cygpath >/dev/null 2>&1 && key="$(cygpath -w "$dir")" - key="$(printf '%s' "$key" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')" - grep -qF "\"$key\"" "$cache" 2>/dev/null && return 0 + 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 dir="$(dirname "$dir")" done diff --git a/tests/workspace-rules-hook.sh b/tests/workspace-rules-hook.sh index b1812be..640d168 100755 --- a/tests/workspace-rules-hook.sh +++ b/tests/workspace-rules-hook.sh @@ -114,6 +114,19 @@ 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 From cd8eede412091eec6d44afa875fbf20bf06f1839 Mon Sep 17 00:00:00 2001 From: Sarav Date: Fri, 6 Nov 2026 08:04:49 +0530 Subject: [PATCH 7/8] docs: team rules request wording; what is sent with learning off Co-Authored-By: Claude Opus 5.5 --- PRIVACY.md | 2 +- plugins/altimate-code/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 34fb8af..ad7143c 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -30,7 +30,7 @@ Data leaves Claude Code's process only when the delegation skill, the `/altimate - *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 learning is off, you are signed out or the project is not linked, the CLI discards them and nothing leaves your machine. +- 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. diff --git a/plugins/altimate-code/README.md b/plugins/altimate-code/README.md index b4ae335..edc9365 100644 --- a/plugins/altimate-code/README.md +++ b/plugins/altimate-code/README.md @@ -60,7 +60,7 @@ In a project linked to an Altimate workspace, the workspace's approved **team ru - **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 or in a task handed to altimate-code, waits as a proposal the same way. It needs the words "team rule" (or "as a rule"): "remember to run the tests" is just part of the conversation. +- **"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. From 390e8823cdd48b3ff381831ef23dbbc5693c6df4 Mon Sep 17 00:00:00 2001 From: Sarav Date: Tue, 10 Nov 2026 11:08:04 +0530 Subject: [PATCH 8/8] fix: the workspace-rules gate finds a link keyed by a symlinked path, and never matches an empty key - `linked` tries the physical path, then the path as the host reached it (a symlink, `/tmp`), still bounded by the repository root. - An empty key (a `cygpath` that prints nothing) no longer matches an empty string in the cache. - PRIVACY.md: the change note names plugin 1.5.0. Co-Authored-By: Claude Opus 5.5 --- PRIVACY.md | 2 +- .../hooks-handlers/workspace-rules.sh | 22 ++++++++++++++----- tests/workspace-rules-hook.sh | 18 +++++++++++++++ 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 63558ce..b35aadc 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -128,7 +128,7 @@ 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 4, 2026:** added [Workspace rules](#workspace-rules) (the workspace-rules hooks and `/altimate-code:rules` in plugin 1.2.0), and the rules telemetry the altimate-code CLI sends. +**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 diff --git a/plugins/altimate-code/hooks-handlers/workspace-rules.sh b/plugins/altimate-code/hooks-handlers/workspace-rules.sh index a38f3ad..fe2552d 100755 --- a/plugins/altimate-code/hooks-handlers/workspace-rules.sh +++ b/plugins/altimate-code/hooks-handlers/workspace-rules.sh @@ -47,20 +47,32 @@ ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" # 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. +# 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 dir key + local top [ -f "$cache" ] || return 1 top="$(cd "$ROOT" 2>/dev/null && pwd -P)" - dir="$(pwd -P)" - [ -n "$top" ] && [ -n "$dir" ] || return 1 + [ -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")" + 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 } diff --git a/tests/workspace-rules-hook.sh b/tests/workspace-rules-hook.sh index 640d168..d268018 100755 --- a/tests/workspace-rules-hook.sh +++ b/tests/workspace-rules-hook.sh @@ -145,6 +145,24 @@ 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"