From 246d75597f4b02deb0474af56f286392a1796532 Mon Sep 17 00:00:00 2001 From: Sarav Date: Wed, 11 Nov 2026 08:35:33 +0530 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20workspace=20roles=20=E2=80=94=20adm?= =?UTF-8?q?ins=20approve=20and=20publish=20like=20the=20owner,=20members?= =?UTF-8?q?=20send=20for=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server now sends the caller's `role` (`owner`, `admin` or `member`) next to `is_owner` on the review summary, the rules summary and rules `/sync`. `WorkspaceRole.roleOf` reads it, falling back to `is_owner` for an older server or a role this version does not know; `manages` is owner or admin. - Rule cards: an admin is offered "approve" like the owner; a member "send". The role from the last pull is kept in the rules state, and the remember tool says "you (a workspace admin)". - `skill publish`: review off refuses members only; an admin's skill is attached directly by the server; an admin whose server turns out to have no review falls back to attaching. A server without review still needs ownership. - Wording that named only the owner as reviewer now says "the owner or an admin" (plugin host sessions, the pending heading, the review-off error, `learn propose --send`), and the docs say admins review too. Co-Authored-By: Claude Opus 5.5 --- docs/docs/configure/learn.md | 6 ++-- docs/docs/configure/skills.md | 6 ++-- docs/docs/usage/cli.md | 4 +-- .../opencode/src/altimate/learn/select.ts | 2 +- packages/opencode/src/altimate/rules/api.ts | 2 ++ .../opencode/src/altimate/rules/events.ts | 10 ++++++- packages/opencode/src/altimate/rules/host.ts | 6 ++-- .../opencode/src/altimate/rules/proposal.ts | 4 ++- .../opencode/src/altimate/rules/remember.ts | 11 +++++-- packages/opencode/src/altimate/rules/store.ts | 10 ++++++- .../opencode/src/altimate/rules/summary.ts | 2 +- packages/opencode/src/altimate/rules/sync.ts | 7 ++++- .../src/altimate/workspace/review-api.ts | 4 ++- .../opencode/src/altimate/workspace/role.ts | 21 ++++++++++++++ .../src/altimate/workspace/skill-publish.ts | 23 ++++++++------- packages/opencode/src/cli/cmd/learn.ts | 2 +- .../opencode/test/altimate/rules/host.test.ts | 8 ++--- .../test/altimate/rules/my-pending.test.ts | 9 ++++++ .../test/altimate/rules/rules.test.ts | 21 ++++++++++++++ .../test/altimate/workspace/role.test.ts | 24 +++++++++++++++ .../altimate/workspace/skill-publish.test.ts | 29 +++++++++++++++++++ 21 files changed, 176 insertions(+), 35 deletions(-) create mode 100644 packages/opencode/src/altimate/workspace/role.ts create mode 100644 packages/opencode/test/altimate/workspace/role.test.ts diff --git a/docs/docs/configure/learn.md b/docs/docs/configure/learn.md index dbfd2610f0..077d8b2460 100644 --- a/docs/docs/configure/learn.md +++ b/docs/docs/configure/learn.md @@ -187,7 +187,7 @@ Delivery waits at most about 5 seconds for the learn lock; if another process st ## Workspace rules -In a project linked to an Altimate workspace (`altimate-code link`), what you teach the agent can become a **team rule**: a one-line instruction every teammate's sessions in the workspace's repositories follow once the workspace owner approves it. +In a project linked to an Altimate workspace (`altimate-code link`), what you teach the agent can become a **team rule**: a one-line instruction every teammate's sessions in the workspace's repositories follow once the workspace owner or one of its admins approves it. Admins are named on the workspace's share screen in the Altimate web app and have the owner's review rights. **Rules need a git remote.** Team rules are kept per repository, keyed by its git remote. A project with no remote can be linked by its path, but rules stay off there: `learn status` and `learn sync` say so. Add a remote and run `altimate-code link` again, picking the same workspace: the link keeps its workspace and gains the remote, and rules sync from then on. @@ -199,7 +199,7 @@ In a project linked to an Altimate workspace (`altimate-code link`), what you te Cards are shown by the TUI and by the VS Code extension from its release with workspace rules, for the project they have open and in its top-level sessions. Where no attached client shows cards (`altimate-code run`, ACP, the Claude Code and Codex plugins), a "remember …" is kept as a **stored proposal** instead: it waits, with the same actions as a card, until you answer it with `altimate-code learn proposals` (the plugins' `/altimate-code:rules`), and nothing is saved or sent before that. Stored proposals are kept per project: at most 20 for the account and workspace you are signed in to, the ones you asked for included, and a "remember" is still kept past that, up to 100 in all (past that, it is saved as before cards in `altimate-code run` and ACP, and said at the next session start in Claude Code and Codex). What a stored proposal offers, and whether it needs "Approve anyway", is worked out again from its wording when you answer it; one that changes or retires a team rule is closed if that rule was changed or removed since; one raised before the project was linked can only be saved for this project; one raised from corrections is dropped after 14 days unanswered, one you asked for waits until you answer it. In a subagent's session the save tools work as they did before cards: a rule is saved for this project only, a memory to memory, and nothing goes to review. Corrections in `altimate-code run` and ACP sessions are kept, and proposed the next time that session runs with a client that shows cards. A card you asked for ("remember …") that closes without an answer (after a day, or when the session server stops) is saved the same way. Requests are kept in your rules state for the project while their card is open: if the server exits unexpectedly, or a save fails for a passing reason (a busy disk, a lock), the next session server that opens the project saves them. A request is dropped, with a log line, only when it can never be saved as asked (the project's memory or training is full, or the text is too long for a memory block). While 50 requested cards are open, a new request is saved right away instead of raising a card. -The card never blocks the input: keep typing, and answer it when you are ready. **ctrl+x p** (or a click) moves to the card; **←/→** choose an action, **Enter** confirms, **Esc** goes back to typing. The card shows the proposed wording, what it applies to, why it was proposed and your correction. **Enter** on the first action sends it to the owner's review queue (the workspace's **Rules** tab); for the workspace owner, it approves it at once instead. **e** edits the wording, and **n** ("Not a rule") stops a correction's topic from being proposed for 30 days (on a card you asked for, it just closes it). A rule that tells the agent to skip or disable verification (tests, CI, hooks, `--no-verify`) is flagged: the owner approves it with "Approve anyway", which takes a second Enter (a client answers it with the `approve_anyway` action; a plain `approve` is refused). Nothing reaches a teammate until it is approved. A card nobody answers is dropped when the session server stops, and after a day (an answer after that closes it instead). A card is answered for the account and workspace it was shown for: after signing in as someone else or relinking, it closes without sending anything. +The card never blocks the input: keep typing, and answer it when you are ready. **ctrl+x p** (or a click) moves to the card; **←/→** choose an action, **Enter** confirms, **Esc** goes back to typing. The card shows the proposed wording, what it applies to, why it was proposed and your correction. **Enter** on the first action sends it to the review queue (the workspace's **Rules** tab); for the workspace owner or an admin, it approves it at once instead. **e** edits the wording, and **n** ("Not a rule") stops a correction's topic from being proposed for 30 days (on a card you asked for, it just closes it). A rule that tells the agent to skip or disable verification (tests, CI, hooks, `--no-verify`) is flagged: the owner or an admin approves it with "Approve anyway", which takes a second Enter (a client answers it with the `approve_anyway` action; a plain `approve` is refused). Nothing reaches a teammate until it is approved. A card nobody answers is dropped when the session server stops, and after a day (an answer after that closes it instead). A card is answered for the account and workspace it was shown for: after signing in as someone else or relinking, it closes without sending anything. **Rule or memory.** A personal preference ("I prefer CTEs") or a fact ("our warehouse is Snowflake") is saved to memory without review. When the agent saved it with `training_save`, a preference goes to your own memory (all your projects) and a fact to this project's memory (outside a linked project, `training_save` saves project training as before). When it saved it with the memory tool, it stays in that tool's project memory. When the wording does not say which, the card asks: Memory, Team rule, or Skip. @@ -565,7 +565,7 @@ The rules themselves did not change the cost of an agent run measurably while re Session state (the frozen section, the redacted request text used for matching) is in `.altimate-code/learn/.sessions/`, shared by all stores. -Local learning publishes nothing to a workspace unless you run `promote --publish`. Workspace rules are separate (see [Workspace rules](#workspace-rules)): in a project linked to a workspace, a proposal is sent to the owner's review queue only when you send it from a card, or confirm `bootstrap` / `import-reviews` sending (`--send-for-review`). It carries the rule's wording and paths, and, when the workspace keeps "Include correction quotes" on, the redacted excerpt of the correction behind it. An approved rule reaches teammates without any local `promote`. Reflection, `bootstrap`, and `import-reviews` send redacted text to the model provider (see below). +Local learning publishes nothing to a workspace unless you run `promote --publish`. Workspace rules are separate (see [Workspace rules](#workspace-rules)): in a project linked to a workspace, a proposal is sent to the review queue only when you send it from a card, or confirm `bootstrap` / `import-reviews` sending (`--send-for-review`). It carries the rule's wording and paths, and, when the workspace keeps "Include correction quotes" on, the redacted excerpt of the correction behind it. An approved rule reaches teammates without any local `promote`. Reflection, `bootstrap`, and `import-reviews` send redacted text to the model provider (see below). **Redaction.** Before signals, session excerpts, review comments, and request text are stored or sent to a model, they pass through a secret filter: known token formats, credential assignments such as `password=...`, credential arguments to commands, and long high-entropy strings. This is best-effort pattern matching. It will miss some secrets and can occasionally redact harmless text. Read the dry-run output of `bootstrap` and `import-reviews` before you confirm. diff --git a/docs/docs/configure/skills.md b/docs/docs/configure/skills.md index c5bbfafc57..41717ebeab 100644 --- a/docs/docs/configure/skills.md +++ b/docs/docs/configure/skills.md @@ -203,13 +203,13 @@ altimate-code workspace review status # changes nothing on the server; --j **Workspace review.** When the workspace service supports review, `skill publish` uploads the skill to your own skills and then submits it to the linked workspace, and what it prints comes from the service's answer: -- `Published "" to ""`: you own the workspace, so the skill is attached directly. -- `Sent "" for review to `: you are a member of a workspace whose owner turned review on. The team gets the skill once the owner approves it in the Altimate web app; until then only you get your pending version. Approving and rejecting happen only in the web app. +- `Published "" to ""`: you own the workspace or are one of its admins, so the skill is attached directly. +- `Sent "" for review to `: you are a member of a workspace whose owner turned review on. The team gets the skill once the owner or an admin approves it in the Altimate web app; until then only you get your pending version. Approving and rejecting happen only in the web app. - `Already waiting for review` or `Already approved`: the same content was already submitted or approved, so nothing new was sent. Requests sent from the CLI are recorded as such in the request's history. What the team gets is the exact version that was approved: editing the skill afterwards changes nothing for them until you run `skill publish` again, which submits the new version as an update. A member publishing to a workspace whose owner has review off is refused before anything is uploaded. If the skill uploads but cannot be sent (offline, an expired sign-in, too many open requests), the command says so and exits non-zero; running it again updates the uploaded skill and submits it. Against a workspace service without review, `skill publish` attaches the skill directly as before, which only the workspace owner can do. With `--json`, the output carries `ok`, the upload (`action`, `public_id`, `files`, `bytes`, `workspace_id`, `workspace_name`) and `review` with the service's `status`, `attached_directly`, `duplicate`, `owner_name` and `contribution` (`null` without review); every failure exits non-zero with `{"ok": false, "error": ...}`. -When the owner approves or rejects one of your requests that this client has seen pending (submitted from it, or listed by an earlier check), the TUI shows a notice once in each project linked to that workspace (with the reason for a rejection). Each altimate-code process with a linked project open checks at start and every 5 minutes (a server without review is asked again only after 6 hours) and keeps the 50 most recent decisions in your own state directory, so a decision that arrives while the TUI is closed or disconnected is shown when it next starts (as rule review notices are). The first time a TUI opens a project, decisions already kept are recorded without a notice; `workspace review status` lists them. `altimate-code workspace review status` lists your requests on the linked workspace (skills and knowledge bases): kind, name, status, whether it is new or an update, the reviewer and their reason, and `expired` for a request waiting longer than 14 days (it can still be approved). +When the owner or an admin approves or rejects one of your requests that this client has seen pending (submitted from it, or listed by an earlier check), the TUI shows a notice once in each project linked to that workspace (with the reason for a rejection). Each altimate-code process with a linked project open checks at start and every 5 minutes (a server without review is asked again only after 6 hours) and keeps the 50 most recent decisions in your own state directory, so a decision that arrives while the TUI is closed or disconnected is shown when it next starts (as rule review notices are). The first time a TUI opens a project, decisions already kept are recorded without a notice; `workspace review status` lists them. `altimate-code workspace review status` lists your requests on the linked workspace (skills and knowledge bases): kind, name, status, whether it is new or an update, the reviewer and their reason, and `expired` for a request waiting longer than 14 days (it can still be approved). `skill publish` sends the whole skill directory, not just `SKILL.md`, so keep secrets out of it. A built-in filter skips known file and directory names — `.env*`, `.git`, `id_rsa`, `*.pem`, `*.key`, `*.p12`, `.npmrc`/`.netrc`, `credentials.json`, `secrets.*`, `.ssh`/`.aws`, editor swap files — but it matches names only and never scans file contents, so a token inside `config.yaml` or a key named `server.crt` would still be uploaded. Built-in skills, global skills and skills the workspace itself sent you cannot be published — including the copies the VS Code / Cursor extension delivers into `.claude/skills/` and `.agents/skills/`, which carry an `.altimate-managed.json` marker. diff --git a/docs/docs/usage/cli.md b/docs/docs/usage/cli.md index 2e58f20135..651cc9fc17 100644 --- a/docs/docs/usage/cli.md +++ b/docs/docs/usage/cli.md @@ -66,8 +66,8 @@ With workspaces on: - `/workspace` in the TUI opens a menu: **Get started** shows what the workspace provides and prompts to try (choosing one puts it in the prompt without sending it; after you link from the TUI, the confirmation shows the same summary and prompts), **Refresh** pulls the workspace's skills and memory into this project, **Sync** re-sends local memory the workspace never received, **Open in browser** (when a web URL is available) shows the workspace on the web, **Switch workspace** relinks the project, **Unlink** detaches it. In a project that is not linked yet, it offers **Link to a workspace** instead. - `/integrations` in the TUI opens the workspace's integration status directly (the same view as `/workspace` → Status). In a project that is not linked it offers the link, and when the workspace engine is missing or too old it offers the install and says which. - In a project linked to a workspace, integrations come only from the workspace's engine, so the `datamate_manager` tool is not offered there and refuses to run. `datamate-*` MCP servers already in the config still load, and a one-time notice names the file they are in; the config is not edited. -- `altimate-code skill publish ` uploads a project skill to the linked workspace, attached directly for the workspace owner and sent for the owner's review for a member when the owner has turned review on; see [Skills](../configure/skills.md#cli-commands). -- When the owner approves or rejects one of your skill or knowledge base requests that altimate-code has seen pending (submitted from it, or listed by an earlier check), the TUI shows a notice once in each project linked to that workspace, with the reason for a rejection. Every altimate-code process that opens a linked project checks at start and every 5 minutes while workspaces are on and you are signed in: it lists your own requests on the workspace (up to 10 pages of 50); a server without review is asked again only after 6 hours. The decision is kept per account and workspace in your own state directory (`$XDG_STATE_HOME/altimate-code/workspace-reviews/`, the 50 most recent), so a TUI that was closed or disconnected when it arrived shows it when it next starts. The first time a TUI opens a project, decisions already kept are recorded without a notice; `altimate-code workspace review status` lists them. +- `altimate-code skill publish ` uploads a project skill to the linked workspace, attached directly for the workspace owner or an admin and sent for review for a member when review is on; see [Skills](../configure/skills.md#cli-commands). +- When the owner or an admin approves or rejects one of your skill or knowledge base requests that altimate-code has seen pending (submitted from it, or listed by an earlier check), the TUI shows a notice once in each project linked to that workspace, with the reason for a rejection. Every altimate-code process that opens a linked project checks at start and every 5 minutes while workspaces are on and you are signed in: it lists your own requests on the workspace (up to 10 pages of 50); a server without review is asked again only after 6 hours. The decision is kept per account and workspace in your own state directory (`$XDG_STATE_HOME/altimate-code/workspace-reviews/`, the 50 most recent), so a TUI that was closed or disconnected when it arrived shows it when it next starts. The first time a TUI opens a project, decisions already kept are recorded without a notice; `altimate-code workspace review status` lists them. - Workspace skills are synced at the version the workspace serves you: the approved version, or your own pending version of a skill you submitted. The next sync follows what the workspace serves: a skill whose first submission is rejected, or that is detached from the workspace, is removed from the project; when an update to an approved skill is rejected or replaced, the skill stays at its approved version. Each skill is downloaded as one version: if it is approved or rejected while its files are being read, nothing of it is installed from that sync and it is fetched again on the next one. When a download fails, the copy already in the project is kept only if it is still the version the workspace serves, as far as the server has said (its list, or a read refused because the version just changed), or if neither the copy nor the list names a version (a copy synced before revisions, or an older server), in which case it is kept until the server says otherwise; a copy of a version it no longer serves (for example your rejected pending version), or of a skill it no longer lists, is removed until the download succeeds, even when no other skill could be downloaded. Two processes syncing the same project take turns: a sync never replaces or removes a snapshot another process installed after it started, and the next sync starts from the newer one. - When the linked workspace has knowledge, the agent is told to check it for best practices and team conventions before answering, without you having to mention the knowledge base. - In an ordinary session the agent is told every turn which workspace the project is linked to — or that none is, or that the link could not be verified just now. In an extension-pinned session it is told the pinned workspace instead, and that it differs from the project's own link. Either way "which workspace am I in?" has an answer, and the agent is told not to confuse it with a Databricks workspace or an IDE workspace folder. diff --git a/packages/opencode/src/altimate/learn/select.ts b/packages/opencode/src/altimate/learn/select.ts index 80c4e5e2f9..17cded23f4 100644 --- a/packages/opencode/src/altimate/learn/select.ts +++ b/packages/opencode/src/altimate/learn/select.ts @@ -135,7 +135,7 @@ export function estimateTokens(text: string): number { } /** The heading of the person's own proposed rules that are still waiting for review. */ -export const PENDING_REVIEW_HEADING = "Your rules pending review (proposed by you, not yet approved by the workspace owner):" +export const PENDING_REVIEW_HEADING = "Your rules pending review (proposed by you, not yet approved by the workspace owner or an admin):" /** Include only complete lesson lines; the heading and newlines count against the budget. The person's own proposals * still waiting for review (`review: "pending"`) are listed apart, under `PENDING_REVIEW_HEADING`. */ diff --git a/packages/opencode/src/altimate/rules/api.ts b/packages/opencode/src/altimate/rules/api.ts index 6635003487..b7c4739ff9 100644 --- a/packages/opencode/src/altimate/rules/api.ts +++ b/packages/opencode/src/altimate/rules/api.ts @@ -66,6 +66,8 @@ export const SyncResponse = z.object({ workspace_name: z.string(), owner_name: z.string().nullable().optional(), is_owner: z.boolean(), + /** `owner`, `admin` or `member` (absent from servers older than workspace roles: see `WorkspaceRole.roleOf`). */ + role: z.string().nullable().optional(), settings: Settings, pending_count: z.number().int(), // Each rule and each review is validated on its own in `sync` below: one this version cannot read is skipped, not a diff --git a/packages/opencode/src/altimate/rules/events.ts b/packages/opencode/src/altimate/rules/events.ts index 44d2191764..e61719b558 100644 --- a/packages/opencode/src/altimate/rules/events.ts +++ b/packages/opencode/src/altimate/rules/events.ts @@ -22,7 +22,15 @@ export const ProposalCard = z.object({ chars: z.number().int(), weakens_verification: z.string().optional(), /** Absent when the project is not linked: the card then offers to save the rule for this project only. */ - workspace: z.object({ name: z.string(), owner_name: z.string().nullable().optional(), is_owner: z.boolean() }).optional(), + workspace: z + .object({ + name: z.string(), + owner_name: z.string().nullable().optional(), + is_owner: z.boolean(), + // `owner`, `admin` or `member` from the last pull; absent before workspace roles (`WorkspaceRole.roleOf`). + role: z.string().nullable().optional(), + }) + .optional(), actions: z.array(z.enum(["send", "approve", "edit", "dismiss", "save_local", "save_memory"])), /** The wording did not say rule or memory: the card asks "Save as: Memory · Team rule · Skip". */ choose: z.boolean().optional(), diff --git a/packages/opencode/src/altimate/rules/host.ts b/packages/opencode/src/altimate/rules/host.ts index ebdb433d0c..bf943f687a 100644 --- a/packages/opencode/src/altimate/rules/host.ts +++ b/packages/opencode/src/altimate/rules/host.ts @@ -50,7 +50,7 @@ export interface HookInput { /** What a host session start learns about workspace rules. `rules` is empty when nothing may be shown. */ export interface Start { - workspace?: { name: string; owner_name?: string | null; is_owner: boolean; id?: number } + workspace?: { name: string; owner_name?: string | null; is_owner: boolean; role?: string | null; id?: number } /** `review: "pending"`: the person's own proposal still waiting for the owner (`RulesStore.vouched`), never presented * as approved. */ rules: { text: string; applies_to: string[]; review?: "pending" }[] @@ -206,13 +206,13 @@ function ruleLines(start: Start, ws: string): string[] { return [ ...(approved.length ? [ - `Team rules from ${ws} for this repository (approved by the workspace owner; they apply to all work here, including code you write yourself):`, + `Team rules from ${ws} for this repository (approved by the workspace owner or an admin; they apply to all work here, including code you write yourself):`, ...approved.map(line), ] : []), ...(pending.length ? [ - `Rules the person proposed in ${ws}, still waiting for the workspace owner's review (not approved yet; they apply to the person's own work here):`, + `Rules the person proposed in ${ws}, still waiting for review by the workspace owner or an admin (not approved yet; they apply to the person's own work here):`, ...pending.map(line), ] : []), diff --git a/packages/opencode/src/altimate/rules/proposal.ts b/packages/opencode/src/altimate/rules/proposal.ts index a73ccd8069..9c3091b611 100644 --- a/packages/opencode/src/altimate/rules/proposal.ts +++ b/packages/opencode/src/altimate/rules/proposal.ts @@ -20,6 +20,7 @@ import { RulesStore } from "./store" import { RulesSync } from "./sync" import { RulesTelemetry, type AnswerOutcome, type CardTrigger } from "./telemetry" import { processAlive, saveWithoutCard } from "./remember" +import { WorkspaceRole } from "../workspace/role" const log = Log.create({ service: "rules.proposal" }) export const MAX_EXCERPT = 280 @@ -257,7 +258,8 @@ async function publish[0] export interface Summary { linked: boolean - workspace?: { name: string; owner_name?: string | null; is_owner: boolean } + workspace?: { name: string; owner_name?: string | null; is_owner: boolean; role?: string | null } team_rules: number /** Proposals waiting for the owner, workspace-wide in this repository's scope. */ pending_review: number diff --git a/packages/opencode/src/altimate/rules/sync.ts b/packages/opencode/src/altimate/rules/sync.ts index 4d20453054..a99eaf8de5 100644 --- a/packages/opencode/src/altimate/rules/sync.ts +++ b/packages/opencode/src/altimate/rules/sync.ts @@ -166,7 +166,12 @@ export async function pull(root: string, ctx: Ready): Promise { // pulled a newer revision, while this answer was on its way. if (!ownedBy(state, ctx) || (state.revision !== undefined && response.revision < state.revision)) return void (applied = false) state.scope = ctx.scope - state.workspace = { name: response.workspace_name, owner_name: response.owner_name ?? null, is_owner: response.is_owner } + state.workspace = { + name: response.workspace_name, + owner_name: response.owner_name ?? null, + is_owner: response.is_owner, + ...(response.role ? { role: response.role } : {}), + } state.settings = response.settings // A rule this version could not read was skipped: without the revision, the next pull fetches the full list // (a known revision would answer `unchanged` and the rule would stay missing). diff --git a/packages/opencode/src/altimate/workspace/review-api.ts b/packages/opencode/src/altimate/workspace/review-api.ts index c1b73a3436..45ad92e37b 100644 --- a/packages/opencode/src/altimate/workspace/review-api.ts +++ b/packages/opencode/src/altimate/workspace/review-api.ts @@ -1,7 +1,7 @@ // altimate_change - new file // // Wire client for workspace review in altimate-backend (`/datamates/{id}/reviews*`): a member submits their own skill or -// knowledge base to a workspace, and the owner approves or rejects it in the web app. The CLI only submits and reads +// knowledge base to a workspace, and the owner or an admin approves or rejects it in the web app. The CLI only submits and reads // status; it never approves or rejects. // // Errors are classified by the rules client's `classify` (one mapping for both features): a 404 `reviews_not_available`, @@ -24,6 +24,8 @@ const PAGE_SIZE = 50 export const Summary = z.object({ is_owner: z.boolean(), + /** `owner`, `admin` or `member` (absent from servers older than workspace roles: see `WorkspaceRole.roleOf`). */ + role: z.string().nullable().optional(), review_enabled: z.boolean(), owner_name: z.string().nullable().optional(), pending_count: z.number().int(), diff --git a/packages/opencode/src/altimate/workspace/role.ts b/packages/opencode/src/altimate/workspace/role.ts new file mode 100644 index 0000000000..6c71a54afb --- /dev/null +++ b/packages/opencode/src/altimate/workspace/role.ts @@ -0,0 +1,21 @@ +// altimate_change - new file +// +// The caller's role on a workspace, from a server answer: `owner`, `admin` or `member`. Owner and admins manage a +// workspace (they decide review requests and rules, and their own items reach it without review); members use it and +// send items for review. A server older than workspace roles sends only `is_owner`, and a role this version does not +// know is read the same way, so a client never offers a decision the server would refuse. + +export type Role = "owner" | "admin" | "member" + +/** `role` as the server sent it, else what `is_owner` says. */ +export function roleOf(answer: { is_owner: boolean; role?: string | null }): Role { + if (answer.role === "owner" || answer.role === "admin" || answer.role === "member") return answer.role + return answer.is_owner ? "owner" : "member" +} + +/** Owner or admin: decides review requests and rules. */ +export function manages(answer: { is_owner: boolean; role?: string | null }): boolean { + return roleOf(answer) !== "member" +} + +export * as WorkspaceRole from "./role" diff --git a/packages/opencode/src/altimate/workspace/skill-publish.ts b/packages/opencode/src/altimate/workspace/skill-publish.ts index 93dacedc73..f357e57e9e 100644 --- a/packages/opencode/src/altimate/workspace/skill-publish.ts +++ b/packages/opencode/src/altimate/workspace/skill-publish.ts @@ -44,6 +44,7 @@ import { accountDigest, credentialDigest, resolveBinding } from "./state" import { printable } from "../rules/printable" import { RulesApiError } from "../rules/api" import { ReviewApi } from "./review-api" +import { WorkspaceRole } from "./role" import { ReviewNotices } from "./review-notices" const log = Log.create({ service: "altimate-workspace-skill-publish" }) @@ -273,8 +274,8 @@ export class ReviewDisabledError extends Error { ) { const owner = ownerName ? printable(ownerName) : "its owner" super( - `"${printable(workspaceName)}" belongs to ${owner}, and review is off there, so only ${owner} can add skills to it. ` + - `Ask them to turn on review in the workspace, or to publish the skill.`, + `"${printable(workspaceName)}" belongs to ${owner}, and review is off there, so only ${owner} or an admin of it can ` + + `add skills to it. Ask one of them to turn on review in the workspace, or to publish the skill.`, ) this.name = "ReviewDisabledError" } @@ -286,7 +287,7 @@ export class TooManyReviewRequestsError extends Error { constructor(readonly publicId: string) { super( `The skill was uploaded (id ${publicId}), but you already have as many open review requests on this workspace as it ` + - `allows. Publish again once the owner has decided some of them; \`altimate-code workspace review status\` lists them.`, + `allows. Publish again once the owner or an admin has decided some of them; \`altimate-code workspace review status\` lists them.`, ) this.name = "TooManyReviewRequestsError" } @@ -775,10 +776,12 @@ async function publishSkillUnlocked(input: PublishInput): Promise const scope = await currentScope(actAs) // Whether the workspace reviews what members add, asked before anything is uploaded. With review, a member's own skill - // is submitted for the owner's review and the owner's is attached directly; with review off only the owner adds - // skills. A server without review answers `unsupported`: the skill is attached as before, which needs ownership. + // is submitted for review and a manager's (the owner's or an admin's) is attached directly by the server; with review + // off only managers add skills. A server without review answers `unsupported`: the skill is attached as before, which + // needs ownership (such a server has no admins). const review = await reviewSummary(binding.datamateId, actAs) - if (review && !review.is_owner && !review.review_enabled) throw new ReviewDisabledError(binding.datamateName, review.owner_name) + if (review && !WorkspaceRole.manages(review) && !review.review_enabled) + throw new ReviewDisabledError(binding.datamateName, review.owner_name) // Without review the workspace must be the caller's. Linking needs only visibility, so // a project can be bound to a colleague's shared workspace — where the // attach would answer 404, on every publish, leaving a skill nothing from @@ -806,10 +809,10 @@ async function publishSkillUnlocked(input: PublishInput): Promise contribution: result.contribution, } } catch (err) { - // Only the owner falls back to attaching directly when the server turns out to have no review: a member cannot - // attach, and the fallback would answer 404 and take the upload back. The member's upload is kept and the - // submission reported as failed. - if (ReviewApi.unavailable(err) && review.is_owner) return undefined + // Only a manager (owner or admin) falls back to attaching directly when the server turns out to have no review: a + // member cannot attach, and the fallback would answer 404 and take the upload back. The member's upload is kept + // and the submission reported as failed. + if (ReviewApi.unavailable(err) && WorkspaceRole.manages(review)) return undefined throw submitError(err, publicId, binding.datamateName, review.owner_name) } } diff --git a/packages/opencode/src/cli/cmd/learn.ts b/packages/opencode/src/cli/cmd/learn.ts index 9ea94d0a76..d65c87d30a 100644 --- a/packages/opencode/src/cli/cmd/learn.ts +++ b/packages/opencode/src/cli/cmd/learn.ts @@ -580,7 +580,7 @@ const ProposalsReplyCommand = effectCmd({ builder: (yargs: Argv) => yargs .positional("id", { type: "string", demandOption: true }) - .option("send", { type: "boolean", describe: "send it to the workspace owner's review" }) + .option("send", { type: "boolean", describe: "send it for review by the workspace owner or an admin" }) .option("approve", { type: "boolean", describe: "approve it now (workspace owner)" }) .option("approve-anyway", { type: "boolean", describe: "approve a rule flagged as weakening verification" }) .option("edit", { type: "string", describe: "change the wording (it stays open)" }) diff --git a/packages/opencode/test/altimate/rules/host.test.ts b/packages/opencode/test/altimate/rules/host.test.ts index 6cc0efd019..e8897cfa04 100644 --- a/packages/opencode/test/altimate/rules/host.test.ts +++ b/packages/opencode/test/altimate/rules/host.test.ts @@ -618,8 +618,8 @@ describe("host session", () => { { text: "Prefix marts with fct_.", applies_to: [], review: "pending" }, ]) const context = RulesHost.context(added) - const approvedAt = context.indexOf("approved by the workspace owner") - const pendingAt = context.indexOf("still waiting for the workspace owner's review") + const approvedAt = context.indexOf("approved by the workspace owner or an admin") + const pendingAt = context.indexOf("still waiting for review by the workspace owner or an admin") expect(approvedAt).toBeGreaterThan(-1) expect(pendingAt).toBeGreaterThan(approvedAt) expect(context.slice(pendingAt)).toContain("- Prefix marts with fct_.") @@ -632,8 +632,8 @@ describe("host session", () => { const edited = await RulesHost.start({ root: edit, directory: edit, learn, ...(session ? { session: `${session}-e` } : {}) }) expect(edited.rules).toEqual([{ text: "Name staging models stg___.", applies_to: [], review: "pending" }]) const editContext = RulesHost.context(edited) - expect(editContext).not.toContain("approved by the workspace owner") - expect(editContext).toContain("still waiting for the workspace owner's review") + expect(editContext).not.toContain("approved by the workspace owner or an admin") + expect(editContext).toContain("still waiting for review by the workspace owner or an admin") } }) diff --git a/packages/opencode/test/altimate/rules/my-pending.test.ts b/packages/opencode/test/altimate/rules/my-pending.test.ts index 41e24e7b3a..30acf32205 100644 --- a/packages/opencode/test/altimate/rules/my-pending.test.ts +++ b/packages/opencode/test/altimate/rules/my-pending.test.ts @@ -76,6 +76,15 @@ describe("pulled", () => { ]) }) + test("the caller's workspace role is kept from a pull, and absent from an older server", async () => { + answer = () => Response.json(syncBody([ACTIVE], { role: "admin" })) + await RulesSync.pull(root, ready()) + expect((await RulesStore.read(root)).workspace).toMatchObject({ is_owner: false, role: "admin" }) + answer = () => Response.json(syncBody([ACTIVE])) + await RulesSync.pull(root, ready()) + expect((await RulesStore.read(root)).workspace?.role).toBeUndefined() + }) + test("an older server without my_pending still pulls, and delivers no pending rules", async () => { answer = () => Response.json(syncBody([ACTIVE])) expect((await RulesSync.pull(root, ready())).ok).toBe(true) diff --git a/packages/opencode/test/altimate/rules/rules.test.ts b/packages/opencode/test/altimate/rules/rules.test.ts index 4d2fa2adaf..bef869d637 100644 --- a/packages/opencode/test/altimate/rules/rules.test.ts +++ b/packages/opencode/test/altimate/rules/rules.test.ts @@ -168,6 +168,27 @@ describe("proposal card", () => { expect(flagged.ok && flagged.card?.weakens_verification).toBe("mentions skipping or disabling verification") }) + test("a workspace admin is offered approve like the owner; a member, or an older server's non-owner, send", async () => { + const cases: [Partial>, "approve" | "send"][] = [ + [{ is_owner: false, role: "admin" }, "approve"], + [{ is_owner: true, role: "owner" }, "approve"], + [{ is_owner: false, role: "member" }, "send"], + // A server older than workspace roles sends no role: `is_owner` decides. + [{ is_owner: false }, "send"], + [{ is_owner: true }, "approve"], + // A role this version does not know reads as `is_owner` too. + [{ is_owner: false, role: "reviewer" }, "send"], + ] + for (const [workspace, expected] of cases) { + const root = await tmp() + await RulesStore.update(root, (s) => Object.assign(s, linked(), { workspace: { name: "Team", owner_name: "Olive", ...workspace } })) + const card = (await RuleProposal.ask({ sessionID: "r1", root, directory: root, learn: undefined, draft: { ...draft, text: "Prefix seeds with seed_ everywhere." } }))! + expect(card.actions).toContain(expected) + expect(card.actions).not.toContain(expected === "approve" ? "send" : "approve") + RuleProposal.drop(root) + } + }) + test("a flagged card is approved only with approve_anyway; a plain approve is refused", async () => { const root = await tmp() await RulesStore.update(root, (s) => Object.assign(s, linked(), { workspace: { name: "Team", owner_name: "Olive", is_owner: true } })) diff --git a/packages/opencode/test/altimate/workspace/role.test.ts b/packages/opencode/test/altimate/workspace/role.test.ts new file mode 100644 index 0000000000..d779aebb33 --- /dev/null +++ b/packages/opencode/test/altimate/workspace/role.test.ts @@ -0,0 +1,24 @@ +// altimate_change - new file +import { describe, expect, test } from "bun:test" +import { WorkspaceRole } from "../../../src/altimate/workspace/role" + +describe("workspace role", () => { + test("the server's role is used as sent", () => { + expect(WorkspaceRole.roleOf({ is_owner: false, role: "admin" })).toBe("admin") + expect(WorkspaceRole.roleOf({ is_owner: true, role: "owner" })).toBe("owner") + expect(WorkspaceRole.roleOf({ is_owner: false, role: "member" })).toBe("member") + }) + + test("without a role (an older server), or with one this version does not know, is_owner decides", () => { + expect(WorkspaceRole.roleOf({ is_owner: true })).toBe("owner") + expect(WorkspaceRole.roleOf({ is_owner: false, role: null })).toBe("member") + expect(WorkspaceRole.roleOf({ is_owner: false, role: "reviewer" })).toBe("member") + }) + + test("owners and admins manage; members do not", () => { + expect(WorkspaceRole.manages({ is_owner: false, role: "admin" })).toBe(true) + expect(WorkspaceRole.manages({ is_owner: true })).toBe(true) + expect(WorkspaceRole.manages({ is_owner: false, role: "member" })).toBe(false) + expect(WorkspaceRole.manages({ is_owner: false })).toBe(false) + }) +}) diff --git a/packages/opencode/test/altimate/workspace/skill-publish.test.ts b/packages/opencode/test/altimate/workspace/skill-publish.test.ts index ffe120761a..143ecddee7 100644 --- a/packages/opencode/test/altimate/workspace/skill-publish.test.ts +++ b/packages/opencode/test/altimate/workspace/skill-publish.test.ts @@ -1230,6 +1230,35 @@ describe("workspace review", () => { expect(describePublish(await publish())).toStartWith("Already approved:") }) + test("an admin is not refused when review is off: the server attaches their skill directly", async () => { + workspaceOwners = { 42: 99 } + server( + () => reply(summary({ role: "admin", review_enabled: false })), + () => reply({ status: "active", attached_directly: true, duplicate: false, contribution: null }), + ) + const report = await publish() + expect(posted()).toHaveLength(1) + expect(report.review).toMatchObject({ status: "active", attachedDirectly: true }) + expect(describePublish(report)).toStartWith('Published "deploy" to "Growth"') + }) + + test("an admin's submission to a server that turns out to have no review falls back to attaching", async () => { + workspaceOwners = { 42: 99 } + server(() => reply(summary({ role: "admin" })), () => reply({ detail: { code: "reviews_not_available", message: "x" } }, 404)) + const report = await publish() + expect(report.review).toBeUndefined() + expect(attaches()).toHaveLength(1) + }) + + test("an older server's summary without a role: a non-owner is a member, refused while review is off", async () => { + workspaceOwners = { 42: 99 } + server(() => reply(summary({ review_enabled: false })), () => reply({}, 500)) + const err = await publish().catch((e) => e) + expect(err).toBeInstanceOf(ReviewDisabledError) + expect(err.message).toContain("or an admin of it") + expect(uploads()).toEqual([]) + }) + test("a member is refused before anything is uploaded when review is off", async () => { workspaceOwners = { 42: 99 } server(() => reply(summary({ review_enabled: false })), () => reply({}, 500)) From cf650417a4184502062b5659091e44b4c526ae95 Mon Sep 17 00:00:00 2001 From: Sarav Date: Wed, 11 Nov 2026 09:05:57 +0530 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20workspace=20roles=20=E2=80=94=20revi?= =?UTF-8?q?ew=20wording=20names=20the=20owner=20or=20an=20admin=20everywhe?= =?UTF-8?q?re?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `learn reply --approve` help: "(workspace owner or an admin)"; `--send-for-review` help and the bootstrap / import-reviews epilogs and prompts say the owner or an admin decides. - Rule replies, queued sends and history-staged sends say "Sent for review in : or an admin decides" instead of naming the owner's queue. - TUI card hint: an admin's approve says "You are an admin of ", not "You own". - The training tool's description: a rule goes for review by the workspace owner or an admin. Co-Authored-By: Claude Opus 5.5 --- .../opencode/src/altimate/rules/from-history.ts | 2 +- packages/opencode/src/altimate/rules/proposal.ts | 12 ++++++------ .../opencode/src/altimate/tools/training-save.ts | 2 +- packages/opencode/src/cli/cmd/learn.ts | 14 +++++++------- .../opencode/src/plugin/tui/altimate/rules.tsx | 8 +++++--- 5 files changed, 20 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/altimate/rules/from-history.ts b/packages/opencode/src/altimate/rules/from-history.ts index 3f68e0e5e1..346dfc70e9 100644 --- a/packages/opencode/src/altimate/rules/from-history.ts +++ b/packages/opencode/src/altimate/rules/from-history.ts @@ -145,7 +145,7 @@ export async function sendStaged(input: { const why = report.context.status !== "ready" ? report.context.reason : report.push?.error ?? "behind older queued proposals" - const message = `${sent} ${sent === 1 ? "rule" : "rules"} sent for ${owner}'s review in ${name}` + + const message = `${sent} ${sent === 1 ? "rule" : "rules"} sent for review in ${name} (${owner} or an admin decides)` + (queued ? `; ${queued} kept queued (${why}) and sent on a later sync` : "") + (refused ? `; ${refused} refused by the workspace` : "") + (lost ? `; ${lost} not sent (${RulesStore.scopeDropNote(state) ?? "the account or workspace changed"})` : "") + heldNote + "." + diff --git a/packages/opencode/src/altimate/rules/proposal.ts b/packages/opencode/src/altimate/rules/proposal.ts index 9c3091b611..c36b5ffa7d 100644 --- a/packages/opencode/src/altimate/rules/proposal.ts +++ b/packages/opencode/src/altimate/rules/proposal.ts @@ -5,7 +5,7 @@ // Bus wildcard (so it reaches `/event`), the client answers with `POST /rule-proposal/:id/reply`. // // The card never blocks the turn. Actions depend on the project: -// - linked, member: send (to the owner's review queue), edit, dismiss ("Not a rule") +// - linked, member: send (to the review queue: the owner or an admin decides), edit, dismiss ("Not a rule") // - linked, owner: approve instead of send (send and approve in one step; "approve anyway" when flagged): an owner // reviewing their own proposal later adds nothing, so the card offers one team action, never both // - not linked: save_local (project training, no review), edit, dismiss @@ -870,7 +870,7 @@ async function act(entry: Pending, action: Exclude): Promis : undefined if (priorSent && (!approve || prior!.approved || priorSent.status === "active")) { if (approve || priorSent.status === "active") return { message: `Approved: now a team rule in ${ws}.`, outcome: "approved" } - return { message: `Sent to ${owner}'s review queue in ${ws}.`, outcome: "sent" } + return { message: `Sent for review in ${ws}: ${owner} or an admin decides.`, outcome: "sent" } } const quotes = current.settings?.include_quotes !== false const why = card.why ? (quotes ? redactSecrets(card.why) : neutralWhy(card)) : undefined @@ -898,7 +898,7 @@ async function act(entry: Pending, action: Exclude): Promis } else if (sent.status === "queued") { message = sent.error ? `Not sent yet (${sent.error}): kept queued and sent again on the next sync.` - : `Queued: it will be sent to ${owner}'s review queue when the workspace is reachable.` + : `Queued: it will be sent for review in ${ws} when the workspace is reachable.` outcome = "queued" } else if (!sent.row) { // Sent by another run whose answer is no longer recorded. @@ -918,13 +918,13 @@ async function act(entry: Pending, action: Exclude): Promis message = `Approved: now a team rule in ${ws}.` outcome = "approved" } else if (sent.row?.approve_error && sent.row.approve_retry) { - message = `Sent to ${owner}'s review queue in ${ws}. Approving it failed for now (${sent.row.approve_error}); it is retried on the next sync.` + message = `Sent for review in ${ws}. Approving it failed for now (${sent.row.approve_error}); it is retried on the next sync.` outcome = "approve_retry" } else if (sent.row?.approve_error) { - message = `Sent to ${owner}'s review queue in ${ws}, but approving it failed (${sent.row.approve_error}). Approve it in the workspace's Rules tab.` + message = `Sent for review in ${ws}, but approving it failed (${sent.row.approve_error}). Approve it in the workspace's Rules tab.` outcome = "approve_failed" } else { - message = `Sent to ${owner}'s review queue in ${ws}.` + message = `Sent for review in ${ws}: ${owner} or an admin decides.` outcome = "sent" } } diff --git a/packages/opencode/src/altimate/tools/training-save.ts b/packages/opencode/src/altimate/tools/training-save.ts index 58d4ef35b5..0fece57b4f 100644 --- a/packages/opencode/src/altimate/tools/training-save.ts +++ b/packages/opencode/src/altimate/tools/training-save.ts @@ -21,7 +21,7 @@ export const TrainingSaveTool = Tool.define("training_save", { "- pattern: A coding pattern learned from an example file (e.g., how staging models should look)", "- rule: A specific rule from a correction (e.g., 'never use FLOAT for financial columns')", " A rule is an instruction for how work must be done in this repository, for everyone. In a project linked", - " to an Altimate workspace it becomes a proposed team rule the person sends for the owner's review.", + " to an Altimate workspace it becomes a proposed team rule the person sends for review by the workspace owner or an admin.", " A personal preference ('I prefer...', 'for me...') or a fact ('our warehouse is...') is not a rule:", " save it with altimate_memory_write instead.", "- glossary: A domain-specific term definition (e.g., 'ARR means Annual Recurring Revenue')", diff --git a/packages/opencode/src/cli/cmd/learn.ts b/packages/opencode/src/cli/cmd/learn.ts index d65c87d30a..fdc464d1b4 100644 --- a/packages/opencode/src/cli/cmd/learn.ts +++ b/packages/opencode/src/cli/cmd/learn.ts @@ -581,7 +581,7 @@ const ProposalsReplyCommand = effectCmd({ yargs .positional("id", { type: "string", demandOption: true }) .option("send", { type: "boolean", describe: "send it for review by the workspace owner or an admin" }) - .option("approve", { type: "boolean", describe: "approve it now (workspace owner)" }) + .option("approve", { type: "boolean", describe: "approve it now (workspace owner or an admin)" }) .option("approve-anyway", { type: "boolean", describe: "approve a rule flagged as weakening verification" }) .option("edit", { type: "string", describe: "change the wording (it stays open)" }) .option("dismiss", { type: "boolean", describe: "not a rule: don't propose this topic again for 30 days" }) @@ -758,11 +758,11 @@ async function offerStagedForReview(input: { name: string; origin: "bootstrap" | if (!staged.items.length) return const owner = printable(staged.workspace.owner) const name = printable(staged.workspace.name) - out(`Staged ${staged.items.length} ${staged.items.length === 1 ? "rule" : "rules"} that can go to ${owner}'s review in ${name}:`) + out(`Staged ${staged.items.length} ${staged.items.length === 1 ? "rule" : "rules"} that can go for review in ${name} (${owner} or an admin decides):`) for (const item of staged.items) out(` - ${printable(item.text)}`) let send = input.send if (!send && process.stdin.isTTY && process.stdout.isTTY) - send = (await prompts.confirm({ message: `Send ${staged.items.length === 1 ? "this rule" : `these ${staged.items.length} rules`} to ${owner}'s review in ${name}?`, initialValue: false })) === true + send = (await prompts.confirm({ message: `Send ${staged.items.length === 1 ? "this rule" : `these ${staged.items.length} rules`} for review in ${name}?`, initialValue: false })) === true if (!send) { out("Not sent; they stay local candidates for this project only (`learn show`, `learn promote`).") return @@ -784,11 +784,11 @@ const BootstrapCommand = effectCmd({ .option("yes", { type: "boolean", default: false, describe: "confirm sending the displayed scope; required outside a TTY" }) .option("dry-run", { type: "boolean", default: false, describe: "print scope and redacted signals; send nothing and leave bootstrap state unchanged" }) // altimate_change start — workspace rules - .option("send-for-review", { type: "boolean", default: false, describe: "in a project linked to a workspace, send the staged rules to the owner's review without asking (--yes does not imply it)" }) + .option("send-for-review", { type: "boolean", default: false, describe: "in a project linked to a workspace, send the staged rules for review by the workspace owner or an admin without asking (--yes does not imply it)" }) // altimate_change end .option("max-reflections", { type: "number", default: DEFAULT_MAX_REFLECTIONS, describe: "maximum reflection batches; 0 imports signals only" }) .option("max-seconds", { type: "number", default: DEFAULT_MAX_SECONDS, describe: "total time budget after confirmation, in seconds" }) - .epilog("Bootstrap sends redacted excerpts of past sessions to the chosen model. Review the scope before confirming. Rules are candidates only: learn show, then learn promote; in a project linked to a workspace they can be sent to the workspace owner's review (you are asked; --send-for-review skips the question). Rerun to finish pending reflections and continue the history cursor."), + .epilog("Bootstrap sends redacted excerpts of past sessions to the chosen model. Review the scope before confirming. Rules are candidates only: learn show, then learn promote; in a project linked to a workspace they can be sent to the workspace workspace owner's or an admin's review (you are asked; --send-for-review skips the question). Rerun to finish pending reflections and continue the history cursor."), handler: Effect.fn("Cli.learn.bootstrap")(function* (args) { // altimate_change start — workspace rules: the candidate before this run, so only what it stages is sent const staged = args["dry-run"] ? undefined : yield* run("", async () => @@ -854,10 +854,10 @@ const ImportReviewsCommand = effectCmd({ .option("yes", { type: "boolean", default: false, describe: "confirm sending the displayed scope; required outside a TTY" }) .option("dry-run", { type: "boolean", default: false, describe: "fetch and print redacted review comments; send nothing and leave import state unchanged" }) // altimate_change start — workspace rules - .option("send-for-review", { type: "boolean", default: false, describe: "in a project linked to a workspace, send the staged rules to the owner's review without asking (--yes does not imply it)" }) + .option("send-for-review", { type: "boolean", default: false, describe: "in a project linked to a workspace, send the staged rules for review by the workspace owner or an admin without asking (--yes does not imply it)" }) // altimate_change end .option("max-reflections", { type: "number", default: DEFAULT_MAX_REFLECTIONS, describe: "maximum reflection batches; 0 imports signals only" }) - .epilog("Requires gh installed and authenticated for the repository host. Review import sends redacted review comments to the chosen model after confirmation. Rules are candidates only: learn show, then learn promote; in a project linked to a workspace they can be sent to the workspace owner's review (you are asked; --send-for-review skips the question). Rerun to resume a checkpoint or finish pending reflections."), + .epilog("Requires gh installed and authenticated for the repository host. Review import sends redacted review comments to the chosen model after confirmation. Rules are candidates only: learn show, then learn promote; in a project linked to a workspace they can be sent to the workspace workspace owner's or an admin's review (you are asked; --send-for-review skips the question). Rerun to resume a checkpoint or finish pending reflections."), handler: Effect.fn("Cli.learn.importReviews")(function* (args) { // altimate_change start — workspace rules: the candidate before this run, so only what it stages is sent const staged = args["dry-run"] ? undefined : yield* run("", async () => diff --git a/packages/opencode/src/plugin/tui/altimate/rules.tsx b/packages/opencode/src/plugin/tui/altimate/rules.tsx index 4b9f4b556f..7eae37fbbb 100644 --- a/packages/opencode/src/plugin/tui/altimate/rules.tsx +++ b/packages/opencode/src/plugin/tui/altimate/rules.tsx @@ -5,7 +5,7 @@ // - The proposed-rule card, inline above the session's input (the `session_prompt` slot, which renders the // regular input below it): renders `rule.proposal.asked` (and `.updated` after an edit) and answers with // `POST /rule-proposal/:id/reply`. Typing is never blocked. `p` (or a click) focuses the card; then -// Enter takes the selected action (first: send for review for a member, approve now for the owner), `e` edits +// Enter takes the selected action (first: send for review for a member, approve now for the owner or an admin), `e` edits // the wording, `n` is "Not a rule", Escape goes back to typing and leaves the card. When the wording did not // say rule or memory, the card asks "Memory · Team rule · Skip". Open proposals load at startup, so a card // asked before this TUI subscribed still shows; one answered in another client disappears. @@ -23,6 +23,7 @@ import { RuleClassify } from "@/altimate/rules/classify" import { printable } from "@/altimate/rules/printable" import type { RulesSummary } from "@/altimate/rules/summary" import { describeAge } from "@/altimate/workspace/attach-snapshot" +import { WorkspaceRole } from "@/altimate/workspace/role" const id = "altimate:rules" @@ -111,8 +112,9 @@ function buttons(card: ProposalCard): { action: ProposalCard["actions"][number]; function explain(card: ProposalCard, action: string): string { const owner = card.workspace?.owner_name ? printable(card.workspace.owner_name) : "the workspace owner" const ws = card.workspace?.name ? printable(card.workspace.name) : "the workspace" - if (action === "send") return `Goes to ${owner}'s queue in ${ws} → Rules. Nothing changes for the team until approved.` - if (action === "approve") return `You own ${ws}: it reaches every teammate's sessions within a few minutes.` + if (action === "send") return `Goes to the review queue in ${ws} → Rules (${owner} or an admin decides). Nothing changes for the team until approved.` + if (action === "approve") + return `${card.workspace && WorkspaceRole.roleOf(card.workspace) === "admin" ? "You are an admin of" : "You own"} ${ws}: it reaches every teammate's sessions within a few minutes.` if (action === "save_local") return "Not linked to a workspace: saved as project training, no review." if (action === "save_memory") return memoryScope(card) === "global"