From a4d1e5704067273c3f79152d4514234e7a0d5c6b Mon Sep 17 00:00:00 2001 From: Sarav Date: Tue, 27 Oct 2026 10:43:39 +0530 Subject: [PATCH 01/20] =?UTF-8?q?feat:=20workspace=20rules=20=E2=80=94=20p?= =?UTF-8?q?roposal=20card,=20review=20notices,=20and=20approved=20rules=20?= =?UTF-8?q?in=20every=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A linked project's corrections become proposed team rules that the person confirms on a card and the workspace owner reviews; approved rules reach every teammate's sessions within minutes. - `src/altimate/rules/`: client for the backend `/datamates/{id}/rules` API (`api.ts`); local state and a read-only `team` store that mirrors approved rules so delivery, recurrence and `learn show` treat them like any lesson (`store.ts`); pull, outbox push, "Approve now" and usage reporting (applied, corrected again) with a session-start pull and a 5-minute refresh (`sync.ts`, `service.ts`) - Proposal card built once in the server (`proposal.ts`, `events.ts`): `rule.proposal.asked` on `/event`, answered with `POST /rule-proposal/:id/reply` (send, approve, edit, dismiss, save_local, save_memory) - Trigger and drafter (`trigger.ts`): a topic corrected in 2+ sessions, a correction of a shown team rule (edit or retire card), or an explicit "remember this as a rule" from `training_save`; one model call words the rule. Each check's outcome is recorded for `learn status` - Rule or memory (`classify.ts`): preferences and facts go to memory without review; ambiguous wording shows "Save as: Memory · Team rule · Skip" - Learning from sessions: the person's setting wins, otherwise the workspace's "Learn from sessions" (capture can now start for a linked project with no local setting) - Server routes `/rule-proposal`, `/altimate/rules/{summary,sync}` and `/altimate/rules`; TUI card, review toasts, `/rules` menu and a sidebar "rules:" line; `learn sync`, rules block in `learn status`; `learn bootstrap` / `import-reviews` send findings for review in a linked project - Docs: "Workspace rules" in `configure/learn.md` Co-Authored-By: Claude Opus 5.5 --- docs/docs/configure/learn.md | 20 ++ .../opencode/src/altimate/learn/capture.ts | 6 +- .../src/altimate/learn/project-config.ts | 98 ++++++ packages/opencode/src/altimate/rules/api.ts | 177 ++++++++++ .../opencode/src/altimate/rules/classify.ts | 48 +++ .../opencode/src/altimate/rules/events.ts | 57 ++++ .../src/altimate/rules/from-history.ts | 53 +++ .../opencode/src/altimate/rules/proposal.ts | 182 +++++++++++ .../opencode/src/altimate/rules/service.ts | 111 +++++++ packages/opencode/src/altimate/rules/store.ts | 192 +++++++++++ .../opencode/src/altimate/rules/summary.ts | 83 +++++ packages/opencode/src/altimate/rules/sync.ts | 306 ++++++++++++++++++ .../opencode/src/altimate/rules/trigger.ts | 262 +++++++++++++++ .../src/altimate/tools/training-save.ts | 63 ++++ .../src/altimate/workspace/api-client.ts | 19 +- packages/opencode/src/cli/cmd/learn.ts | 61 ++++ .../opencode/src/plugin/tui/altimate/index.ts | 3 +- .../src/plugin/tui/altimate/rules.tsx | 289 +++++++++++++++++ .../plugin/tui/altimate/workspace-sidebar.tsx | 25 ++ packages/opencode/src/project/bootstrap.ts | 6 + packages/opencode/src/server/server.ts | 62 ++++ packages/opencode/src/session/prompt.ts | 4 + .../test/altimate/rules/rules.test.ts | 157 +++++++++ 23 files changed, 2277 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/src/altimate/learn/project-config.ts create mode 100644 packages/opencode/src/altimate/rules/api.ts create mode 100644 packages/opencode/src/altimate/rules/classify.ts create mode 100644 packages/opencode/src/altimate/rules/events.ts create mode 100644 packages/opencode/src/altimate/rules/from-history.ts create mode 100644 packages/opencode/src/altimate/rules/proposal.ts create mode 100644 packages/opencode/src/altimate/rules/service.ts create mode 100644 packages/opencode/src/altimate/rules/store.ts create mode 100644 packages/opencode/src/altimate/rules/summary.ts create mode 100644 packages/opencode/src/altimate/rules/sync.ts create mode 100644 packages/opencode/src/altimate/rules/trigger.ts create mode 100644 packages/opencode/src/plugin/tui/altimate/rules.tsx create mode 100644 packages/opencode/test/altimate/rules/rules.test.ts diff --git a/docs/docs/configure/learn.md b/docs/docs/configure/learn.md index 67a80b0f0c..c8b1addc35 100644 --- a/docs/docs/configure/learn.md +++ b/docs/docs/configure/learn.md @@ -185,6 +185,26 @@ Delivery runs whenever approved lessons exist in the project, even if `learn.cap Delivery waits at most about 5 seconds for the learn lock; if another process still holds it, that request goes without lessons. A store whose `approved.json` cannot be read is skipped and the others are still delivered. Every lesson is checked again at delivery, including lessons committed to the repository by hand: lessons over 140 characters or rejected by the curator's lint are left out, and per-request and per-file notes are limited by `learn.budget_tokens`. +## 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. + +**When a rule is proposed.** A card appears at the end of a turn when: + +- you correct the agent on the same topic in 2 or more sessions; +- you correct a team rule the session was shown (the card proposes changing it, with the current text struck through); +- you ask the agent to remember something as a rule. + +The card shows the proposed wording, what it applies to, why it was proposed and your correction. **Enter** sends it to the owner's review queue (the workspace's **Rules** tab), **e** edits the wording, **n** ("Not a rule") stops that topic from being proposed for 30 days, and the owner also sees **a** to approve it at once. A rule that tells the agent to skip or disable verification (tests, CI, hooks, `--no-verify`) is flagged and needs "approve anyway". Nothing reaches a teammate until it is approved. + +**Rule or memory.** A personal preference ("I prefer CTEs") or a fact ("our warehouse is Snowflake") is saved to memory without review. When the wording does not say which, the card asks: Memory, Team rule, or Skip. + +**After review.** Approved rules reach every session within a few minutes (a pull at session start and every 5 minutes; `/rules` → Sync now, or `altimate-code learn sync`). You see a notice when the owner approves or rejects your rule, with the reason. Usage is reported back to the workspace: how many sessions a rule was applied in, and how often you corrected the agent on its topic after it was shown ("corrected again"). + +**Learning on or off.** Your own setting wins (`learn enable` / `learn disable`, `learn.capture`, or `/rules` → Turn learning on/off); otherwise a linked project follows the workspace's "Learn from sessions" toggle. Approved rules are delivered either way. + +**Commands.** `/rules` in the TUI lists team rules and your proposals with their status. `altimate-code learn sync` syncs now; `learn status` shows the workspace rules line, your learning setting and the last proposal check. In a linked project, `learn bootstrap` and `learn import-reviews` send what they find from history to the owner's review instead of staging local candidates. + ## Learn vs memory vs a knowledge base | | Learn (lessons) | [Memory](../data-engineering/tools/memory-tools.md) | Knowledge base | diff --git a/packages/opencode/src/altimate/learn/capture.ts b/packages/opencode/src/altimate/learn/capture.ts index 700d6fd69a..971f0b5b1c 100644 --- a/packages/opencode/src/altimate/learn/capture.ts +++ b/packages/opencode/src/altimate/learn/capture.ts @@ -272,11 +272,13 @@ async function subscribeWhenReady(subscribe: () => T): Promise { * Starts capture for one instance when enabled. Called once from instance bootstrap inside the instance * context. Never throws. */ -export async function startCapture(ctx: { directory: string; worktree: string }): Promise { +export async function startCapture(ctx: { directory: string; worktree: string }, options: { force?: boolean } = {}): Promise { try { const root = ctx.worktree !== "/" ? ctx.worktree : ctx.directory const { Config } = await import("@/config/config") - if (!captureEnabled((await Config.get()).learn)) return + // altimate_change start — `force`: workspace rules turned learning on for this project (the person set nothing) + if (!options.force && !captureEnabled((await Config.get()).learn)) return + // altimate_change end const { Bus } = await import("@/bus") const { MessageV2 } = await import("../../session/message-v2") const { Session } = await import("../../session") diff --git a/packages/opencode/src/altimate/learn/project-config.ts b/packages/opencode/src/altimate/learn/project-config.ts new file mode 100644 index 0000000000..37ce5089f3 --- /dev/null +++ b/packages/opencode/src/altimate/learn/project-config.ts @@ -0,0 +1,98 @@ +// altimate_change - new file +// +// The project config writer behind the TUI's `/rules` learning toggle; `directory` is where config discovery starts (the session's +// or the command's working directory), `root` the project root it stops at. +import fs from "node:fs/promises" +import path from "node:path" +import { constants } from "node:fs" +import { randomUUID } from "node:crypto" +import { applyEdits, modify, parse, type ParseError } from "jsonc-parser" +import * as SafeFS from "./safe-fs" + +/** + * The highest-precedence existing project config file, from `directory` up to `root`, and its text; the default + * `.altimate-code/altimate-code.json` (empty) when there is none. Config merges in the opposite order, so editing + * this file means a second supported config cannot silently override the change. + */ +async function projectConfigFile(root: string, directory: string): Promise<{ file: string; text: string }> { + // altimate_change start — match discovered project config precedence from the current directory + const { Filesystem } = await import("@/util/filesystem") + const names = ["opencode.jsonc", "opencode.json", "altimate-code.jsonc", "altimate-code.json"] + const directories: string[] = [] + for await (const dir of Filesystem.up({ targets: [".altimate-code", ".opencode"], start: directory, stop: root })) + directories.push(dir) + const candidates = directories.reverse().flatMap((dir) => names.map((name) => path.join(dir, name))) + for await (const file of Filesystem.up({ targets: ["opencode.jsonc", "opencode.json"], start: directory, stop: root })) + candidates.push(file) + // altimate_change end + let file = path.join(root, ".altimate-code", "altimate-code.json") + let text = "{}\n" + for (const candidate of candidates) { + // altimate_change start — reject linked project config files and their ancestors before reading + await SafeFS.assertSafePath(root, candidate, root) + // altimate_change end + const current = await fs.readFile(candidate, "utf8").catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return undefined + throw error + }) + if (current === undefined) continue + file = candidate + text = current + break + } + return { file, text } +} + +/** + * `learn` as the project config file sets it: read from disk, so a change written by `/learn` shows at once + * rather than when the session's config is next loaded. Undefined when the file has no `learn` object. + */ +export async function readProjectLearning(root: string, directory: string): Promise | undefined> { + const { text } = await projectConfigFile(root, directory) + const learn = parse(text, [], { allowTrailingComma: true })?.learn + return learn && typeof learn === "object" && !Array.isArray(learn) ? learn : undefined +} + +/** + * Set `learn.capture` (learning from this person's sessions) in the project config: what `/rules` → "Turn + * learning on/off for me" writes. Match the project locations loaded by Config, never the user's global one. + */ +export async function writeProjectLearning(root: string, directory: string, capture: boolean): Promise { + const found = await projectConfigFile(root, directory) + const file = found.file + let text = found.text + const errors: ParseError[] = [] + const config = parse(text, errors, { allowTrailingComma: true }) + if (errors.length || !config || typeof config !== "object" || Array.isArray(config)) + throw new Error(`Cannot update ${file}: expected a JSON configuration object.`) + const indent = /^([ \t]+)"/m.exec(text)?.[1] + const formattingOptions = { + insertSpaces: !indent?.includes("\t"), + tabSize: indent?.length ?? 2, + eol: text.includes("\r\n") ? "\r\n" : "\n", + } + // Like the MCP config writer, patch JSONC without expanding variables or rewriting other keys. + text = applyEdits(text, modify(text, ["learn", "capture"], capture, { formattingOptions })) + // altimate_change start — preserve regular config permissions without following linked targets or ancestors + await SafeFS.mkdir(root, path.dirname(file), root) + await SafeFS.assertSafePath(root, file, root) + const mode = await fs.lstat(file).then((stat) => stat.mode & 0o777).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return 0o600 + throw error + }) + const temporary = `${file}.${randomUUID()}.tmp` + try { + const handle = await SafeFS.open(root, temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, mode, root) + try { + await handle.writeFile(text) + await handle.chmod(mode) + } finally { + await handle.close() + } + await SafeFS.rename(root, temporary, file, root) + } finally { + await SafeFS.remove(root, temporary, root) + } + // altimate_change end + return file +} diff --git a/packages/opencode/src/altimate/rules/api.ts b/packages/opencode/src/altimate/rules/api.ts new file mode 100644 index 0000000000..3942835b39 --- /dev/null +++ b/packages/opencode/src/altimate/rules/api.ts @@ -0,0 +1,177 @@ +// altimate_change - new file +// +// Wire client for the workspace rules routes in altimate-backend (`/datamates/{id}/rules/*`). +// +// Every call takes the credential captured for one scope (`ActAs`), never the ambient one: rules, +// proposals and usage are per tenant and account, and an account switch part-way through a pull must +// not send one account's data with another's key. Responses are parsed with zod; a malformed body is a +// contract break and fails the call. +import z from "zod" +import { altimateRequest, ForbiddenError, NotFoundError, WorkspaceApiError, type ActAs } from "../workspace/api-client" + +export const Settings = z.object({ + learning_enabled: z.boolean(), + include_quotes: z.boolean(), + share_across_repos: z.boolean(), +}) +export type Settings = z.infer + +export const RuleOut = z.object({ + public_id: z.string(), + rule_key: z.string().regex(/^R-[0-9a-f]{12}$/), + repo_identity: z.string().nullable().optional(), + text: z.string(), + applies_to: z.array(z.string()), + tags: z.array(z.string()), + pinned: z.boolean(), + version: z.number().int(), + updated_at: z.string(), +}) +export type RuleOut = z.infer + +export const Reviewed = z.object({ + public_id: z.string(), + rule_key: z.string(), + text: z.string(), + change: z.string(), + status: z.string(), + review_reason: z.string().nullable().optional(), + reviewed_by_name: z.string().nullable().optional(), + reviewed_at: z.string().nullable().optional(), + updated_at: z.string(), +}) +export type Reviewed = z.infer + +export const SyncResponse = z.object({ + unchanged: z.boolean(), + revision: z.number().int(), + repo_identity: z.string(), + workspace_name: z.string(), + owner_name: z.string().nullable().optional(), + is_owner: z.boolean(), + settings: Settings, + pending_count: z.number().int(), + rules: z.array(RuleOut), + my_reviewed: z.array(Reviewed), + cursor: z.string(), +}) +export type SyncResponse = z.infer + +export const ORIGINS = ["session", "explicit", "bootstrap", "import_reviews"] as const +export type Origin = (typeof ORIGINS)[number] + +export interface ProposalItem { + change: "add" | "edit" | "retire" + text: string + applies_to: string[] + why?: string + origin: Origin + replaces?: string + replaces_version?: number + evidence_sessions: number + corrections: { excerpt: string; corrected_at: string; session_ref?: string }[] +} + +export const ProposalResult = z.object({ + index: z.number().int(), + status: z.string(), + public_id: z.string().nullable().optional(), + rule_key: z.string().nullable().optional(), + version: z.number().int().nullable().optional(), + duplicate: z.boolean().default(false), + weakens_verification: z.boolean().default(false), + verification_reason: z.string().nullable().optional(), + error_code: z.string().nullable().optional(), + error_detail: z.string().nullable().optional(), +}) +export type ProposalResult = z.infer + +export const RuleDetail = z.object({ public_id: z.string(), status: z.string(), version: z.number().int() }).passthrough() + +export interface UsageItem { + public_id: string + applied: number + helpful: number + harmful: number + corrected_again: number +} + +export type FailureKind = + /** The server has no rules routes, or the tenant is not migrated yet. */ + | "unsupported" + /** The workspace is gone or no longer visible. */ + | "workspace_not_found" + | "forbidden" + /** This repository is not linked to the workspace (any more). */ + | "repo_not_bound" + /** A review refused for a coded reason (verification_flagged, target_changed, version_conflict...). */ + | "conflict" + | "transient" + | "rejected" + +export class RulesApiError extends Error { + constructor( + readonly kind: FailureKind, + message: string, + readonly code?: string, + readonly status?: number, + ) { + super(message) + this.name = "RulesApiError" + } +} + +const codeOf = (detail: Record | undefined) => (typeof detail?.code === "string" ? detail.code : undefined) +const messageOf = (detail: Record | undefined, fallback: string) => + typeof detail?.message === "string" ? detail.message : fallback + +/** Map api-client's typed errors onto what callers do about them. */ +export function classify(error: unknown): RulesApiError { + if (error instanceof RulesApiError) return error + const message = error instanceof Error ? error.message : String(error) + if (error instanceof NotFoundError) { + const code = codeOf(error.detail) + if (code === "workspace_not_found") return new RulesApiError("workspace_not_found", messageOf(error.detail, message), code, 404) + if (code === "rules_not_available" || !error.detail) return new RulesApiError("unsupported", messageOf(error.detail, message), code, 404) + return new RulesApiError("rejected", messageOf(error.detail, message), code, 404) + } + if (error instanceof ForbiddenError) return new RulesApiError("forbidden", messageOf(error.detail, message), codeOf(error.detail), 403) + if (error instanceof WorkspaceApiError) { + const code = codeOf(error.detail) + if (code === "repo_not_bound") return new RulesApiError("repo_not_bound", messageOf(error.detail, message), code, error.status) + if (error.status === undefined || error.status === 408 || error.status === 429 || error.status >= 500) + return new RulesApiError("transient", message, code, error.status) + return new RulesApiError("rejected", messageOf(error.detail, message), code, error.status) + } + const named = error as { name?: string; detail?: Record } | undefined + if (named?.name === "ConflictError") return new RulesApiError("conflict", messageOf(named.detail, message), codeOf(named.detail), 409) + return new RulesApiError(named?.name === "ZodError" ? "rejected" : "transient", message) +} + +async function call(schema: z.ZodType, actAs: ActAs, method: string, datamateId: number, subpath: string, body?: unknown): Promise { + try { + const raw = await altimateRequest(method, subpath, { base: `/datamates/${datamateId}/rules`, actAs, body }) + return schema.parse(raw) + } catch (error) { + throw classify(error) + } +} + +export function sync(actAs: ActAs, datamateId: number, body: { repo_remote: string; known_revision?: number; reviewed_since?: string }) { + return call(SyncResponse, actAs, "POST", datamateId, "/sync", body) +} + +export async function propose(actAs: ActAs, datamateId: number, body: { repo_remote: string; items: ProposalItem[] }) { + return (await call(z.object({ results: z.array(ProposalResult) }), actAs, "POST", datamateId, "/proposals", body)).results +} + +/** Owner only: "Approve now" from the card. */ +export function review(actAs: ActAs, datamateId: number, publicId: string, body: { action: "approve" | "approve_anyway" | "reject"; expected_version: number; reason?: string }) { + return call(RuleDetail, actAs, "POST", datamateId, `/${encodeURIComponent(publicId)}/review`, { ...body, source: "cli" }) +} + +export function usage(actAs: ActAs, datamateId: number, body: { batch_id: string; items: UsageItem[] }) { + return call(z.object({ duplicate: z.boolean(), applied_items: z.number(), skipped_items: z.number() }), actAs, "POST", datamateId, "/usage", body) +} + +export * as RulesApi from "./api" diff --git a/packages/opencode/src/altimate/rules/classify.ts b/packages/opencode/src/altimate/rules/classify.ts new file mode 100644 index 0000000000..6a825ea05f --- /dev/null +++ b/packages/opencode/src/altimate/rules/classify.ts @@ -0,0 +1,48 @@ +// altimate_change - new file +// +// Rule or memory? Deterministic, no model call, like the correction classifier. +// +// - A rule is an instruction about how the agent must work in this repository, for everyone, that its +// output can be checked against: "name staging models stg___", "never use FLOAT for money". +// - A memory is a fact or a personal preference that informs the agent but is not a team constraint: +// "our warehouse is Snowflake", "ARR means annual recurring revenue", "I prefer CTEs". +// +// Repetition is the strongest rule signal and is handled by the recurrence trigger, not here. This reads +// the wording of something the person asked to remember. When neither side has a cue, or both do, the +// answer is `ambiguous` and the card lets the person choose: Memory · Team rule · Skip. + +export type Kind = "rule" | "memory" | "ambiguous" + +const MEMORY_CUES: readonly [RegExp, string][] = [ + [/\bi\s+(?:prefer|like|want|usually|tend to)\b/i, "a personal preference"], + [/\bfor\s+me\b|\bmy\s+(?:preference|style|default|setup|machine|laptop)\b/i, "a personal preference"], + [/\bremember\s+(?:that|our|the)\b(?!.*\b(?:always|never|must)\b)/i, "a fact to remember"], + [/\b(?:our|the)\s+(?:warehouse|database|account|cluster|stack|region|team|company|org)\s+(?:is|uses|runs|lives)\b/i, "a fact about the environment"], + [/\b[A-Z]{2,}\s+(?:means|stands for|is short for)\b|\bmeans\b.*\b(?:revenue|users?|customers?|metric)\b/, "a glossary term"], +] + +const RULE_CUES: readonly [RegExp, string][] = [ + [/\b(?:always|never)\b/i, "an always/never instruction"], + [/\bmust(?:\s+not)?\b|\bshould(?:\s+not|n't)?\s+(?:use|be|have|name|add|run)\b/i, "a must/should instruction"], + [/\bwe\s+(?:always|never|use|don't|do not)\b|\b(?:our|team|house|repo|project)\s+(?:convention|standard|rule|style|policy)\b/i, "a team convention"], + [/\buse\s+\S+\s+instead\s+of\b|\b(?:don't|do not|avoid)\s+(?:use|using|add|write|name|cast)\b/i, "a do/don't instruction"], + [/\b(?:name|prefix|suffix|alias)\s+\S+(?:\s+\S+)?\s+(?:as|with|like)\b/i, "a naming convention"], + [/\bas\s+a\s+(?:team\s+)?rule\b/i, "asked for a rule"], +] + +export function classify(text: string): { kind: Kind; cue?: string } { + const memory = MEMORY_CUES.find(([pattern]) => pattern.test(text)) + const rule = RULE_CUES.find(([pattern]) => pattern.test(text)) + if (rule && !memory) return { kind: "rule", cue: rule[1] } + if (memory && !rule) return { kind: "memory", cue: memory[1] } + // "As a rule" wins over a stray preference cue: the person named the kind. + if (rule?.[1] === "asked for a rule") return { kind: "rule", cue: rule[1] } + return { kind: "ambiguous" } +} + +/** A preference is the person's own (global memory); a fact belongs to the project. */ +export function memoryScope(text: string): "global" | "project" { + return /\bi\s+(?:prefer|like|want|usually|tend to)\b|\bfor\s+me\b|\bmy\s+/i.test(text) ? "global" : "project" +} + +export * as RuleClassify from "./classify" diff --git a/packages/opencode/src/altimate/rules/events.ts b/packages/opencode/src/altimate/rules/events.ts new file mode 100644 index 0000000000..0e2d7b0cc4 --- /dev/null +++ b/packages/opencode/src/altimate/rules/events.ts @@ -0,0 +1,57 @@ +// altimate_change - new file +// +// Rule events on the Bus wildcard, so they reach `/event` (the TUI, and the IDE extension later). A client +// renders `rule.proposal.asked` as the proposal card and answers with `POST /rule-proposal/:id/reply`. +import z from "zod" +import { BusEvent } from "@/bus/bus-event" + +export const ProposalCard = z.object({ + id: z.string(), + sessionID: z.string(), + text: z.string(), + applies_to: z.array(z.string()), + why: z.string().optional(), + change: z.enum(["add", "edit", "retire"]), + /** For an edit: the active rule it replaces, shown struck through. */ + replaces: z.object({ public_id: z.string(), text: z.string(), version: z.number().int() }).optional(), + origin: z.enum(["session", "explicit"]), + evidence: z.object({ + sessions: z.number().int(), + correction: z.object({ excerpt: z.string(), at: z.string() }).optional(), + }), + 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(), + 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(), + /** Topics a "Not a rule" suppresses for 30 days. */ + topics: z.array(z.string()), +}) +export type ProposalCard = z.infer + +export const Event = { + Asked: BusEvent.define("rule.proposal.asked", ProposalCard), + /** The card changed after an edit: same id, new text. */ + Updated: BusEvent.define("rule.proposal.updated", ProposalCard), + Replied: BusEvent.define( + "rule.proposal.replied", + z.object({ id: z.string(), sessionID: z.string(), action: z.string(), message: z.string() }), + ), + /** The owner reviewed one of this person's proposals. */ + Reviewed: BusEvent.define( + "rule.proposal.reviewed", + z.object({ + public_id: z.string(), + text: z.string(), + status: z.string(), + reason: z.string().optional(), + reviewer: z.string().optional(), + }), + ), + /** The local rules state changed (pull, send, review): clients refresh their summary. */ + Changed: BusEvent.define("altimate.rules.changed", z.object({ directory: z.string() })), +} + +export * as RuleEvents from "./events" diff --git a/packages/opencode/src/altimate/rules/from-history.ts b/packages/opencode/src/altimate/rules/from-history.ts new file mode 100644 index 0000000000..f8e143e7d1 --- /dev/null +++ b/packages/opencode/src/altimate/rules/from-history.ts @@ -0,0 +1,53 @@ +// altimate_change - new file +// +// `learn bootstrap` and `learn import-reviews` in a linked project: the lessons they staged from history become +// proposals in the owner's review queue (origin `bootstrap` / `import_reviews`), sent without a card per rule, +// and the local candidate is discarded. One summary line reports how many were sent. +import * as Store from "../learn/store" +import { RulesStore } from "./store" +import { RulesSync } from "./sync" + +export async function proposeStaged(input: { + root: string + directory: string + learn: Parameters[2] + name: string + origin: "bootstrap" | "import_reviews" +}): Promise<{ status: "not_linked" } | { status: "none" } | { status: "sent"; proposed: number; sent: number; queued: number; message: string }> { + const state = await RulesStore.read(input.root) + if (!state.scope) return { status: "not_linked" } + const [approved, candidate] = await Promise.all([ + Store.loadApproved(input.root, input.name), + Store.loadCandidateLessons(input.root, input.name), + ]) + if (!candidate) return { status: "none" } + const before = new Set(approved.map((lesson) => lesson.text)) + const added = candidate.filter((lesson) => !before.has(lesson.text)) + if (!added.length) return { status: "none" } + for (const lesson of added) + await RulesSync.enqueue(input.root, { + change: "add", + text: lesson.text, + applies_to: lesson.trigger?.paths ?? [], + why: input.origin === "bootstrap" ? "Found in past sessions (learn bootstrap)" : "Found in past pull request reviews", + origin: input.origin, + evidence_sessions: 1, + corrections: [], + }) + // The workspace's review queue replaces the local candidate. + await Store.reject(input.root, input.name) + const report = await RulesSync.run(input.root, input.directory, input.learn) + const sent = report.push?.sent.filter((s) => s.status === "pending").length ?? 0 + const queued = (await RulesStore.read(input.root)).outbox.length + const owner = state.workspace?.owner_name ?? "the workspace owner" + return { + status: "sent", + proposed: added.length, + sent, + queued, + message: `${sent} ${sent === 1 ? "rule" : "rules"} sent for ${owner}'s review in ${state.workspace?.name ?? "the workspace"}` + + (queued ? `; ${queued} queued until the workspace is reachable` : "") + ".", + } +} + +export * as RulesFromHistory from "./from-history" diff --git a/packages/opencode/src/altimate/rules/proposal.ts b/packages/opencode/src/altimate/rules/proposal.ts new file mode 100644 index 0000000000..9179067938 --- /dev/null +++ b/packages/opencode/src/altimate/rules/proposal.ts @@ -0,0 +1,182 @@ +// altimate_change - new file +// +// The proposed-rule card, built once in the session server and rendered by every client (the TUI now, the +// IDE extension later) — the same ask/reply shape as `question`: `ask` publishes `rule.proposal.asked` on the +// 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, owner: approve too (send and approve in one step; "approve anyway" when flagged) +// - not linked: save_local (project training, no review), edit, dismiss +import { randomUUID } from "node:crypto" +import { Bus } from "@/bus" +import { Log } from "@/util/log" +import { lint, normalizeText, verificationWarning } from "../learn/curator" +import { redactSecrets } from "../learn/digest" +import { RuleEvents, type ProposalCard } from "./events" +import { RulesStore } from "./store" +import { RulesSync } from "./sync" + +const log = Log.create({ service: "rules.proposal" }) +export const MAX_EXCERPT = 280 + +export interface Draft { + text: string + applies_to: string[] + why?: string + change: "add" | "edit" | "retire" + replaces?: { public_id: string; text: string; version: number } + origin: "session" | "explicit" + evidence: { sessions: number; correction?: { excerpt: string; at: string } } + topics: string[] + /** Offer "Save as: Memory · Team rule · Skip": the wording did not say which. */ + choose?: boolean +} + +interface Pending { + card: ProposalCard + root: string + directory: string + learn: { enabled?: boolean } | undefined +} + +// Process-global like `question`'s registry: the server routes and the session loop may load this module +// through different import paths in one process. +const REGISTRY = Symbol.for("altimate.rules.proposal.pending") +const pending: Map = ((globalThis as Record)[REGISTRY] ??= new Map()) as Map + +export const excerpt = (text: string) => { + const line = redactSecrets(normalizeText(text)).replace(/\s+/g, " ").trim() + return line.length <= MAX_EXCERPT ? line : line.slice(0, MAX_EXCERPT - 1) + "…" +} + +async function publish(def: T, properties: Parameters>[1]) { + await Bus.publish(def, properties).catch((error) => log.warn("rule event not published", { type: def.type, error })) +} + +function build(id: string, sessionID: string, draft: Draft, state: RulesStore.State): ProposalCard { + const linked = !!state.scope && !!state.workspace + const flag = verificationWarning(draft.text) + const team: ProposalCard["actions"] = linked + ? [...(state.workspace!.is_owner ? (["approve"] as const) : []), "send"] + : ["save_local"] + const actions: ProposalCard["actions"] = draft.choose ? ["save_memory", ...team, "dismiss"] : [...team, "edit", "dismiss"] + return { + id, + sessionID, + text: draft.text, + applies_to: draft.applies_to, + ...(draft.why ? { why: draft.why } : {}), + change: draft.change, + ...(draft.replaces ? { replaces: draft.replaces } : {}), + origin: draft.origin, + evidence: draft.evidence, + chars: draft.text.length, + ...(flag ? { weakens_verification: flag } : {}), + ...(linked ? { workspace: state.workspace! } : {}), + actions, + ...(draft.choose ? { choose: true } : {}), + topics: draft.topics.map((t) => t.toLowerCase()), + } +} + +/** Show a card for `draft` in `sessionID`. Returns the card, or undefined when its topic was dismissed. */ +export async function ask(input: { sessionID: string; root: string; directory: string; learn: Pending["learn"]; draft: Draft }) { + const state = await RulesStore.read(input.root) + if (input.draft.origin === "session" && input.draft.topics.some((topic) => RulesStore.dismissed(state, topic))) return undefined + for (const existing of pending.values()) + if (existing.root === input.root && existing.card.text.toLowerCase() === input.draft.text.toLowerCase()) return existing.card + const card = build(randomUUID(), input.sessionID, input.draft, state) + pending.set(card.id, { card, root: input.root, directory: input.directory, learn: input.learn }) + await publish(RuleEvents.Event.Asked, card) + return card +} + +export function list(directory?: string): ProposalCard[] { + return [...pending.values()].filter((p) => !directory || p.directory === directory).map((p) => p.card) +} + +export type ReplyAction = ProposalCard["actions"][number] +export type ReplyResult = + | { ok: true; message: string; card?: ProposalCard; closed: boolean } + | { ok: false; error: string; card?: ProposalCard } + +/** Lint an edited wording like the curator does for any rule: one line, within the limit, no commands. */ +function editProblem(text: string): string | undefined { + const reason = lint(text) + if (reason) return reason + return normalizeText(text).trim() !== text ? "contains hidden or non-normalized characters" : undefined +} + +export async function reply(id: string, input: { action: ReplyAction; text?: string }): Promise { + const entry = pending.get(id) + if (!entry) return { ok: false, error: "This proposal is no longer open." } + const { card } = entry + if (!card.actions.includes(input.action)) return { ok: false, error: `"${input.action}" is not available for this proposal.`, card } + + if (input.action === "edit") { + const text = (input.text ?? "").trim() + const problem = editProblem(text) + if (problem) return { ok: false, error: `Not changed: the rule ${problem}.`, card } + const flag = verificationWarning(text) + const next: ProposalCard = { ...card, text, chars: text.length, weakens_verification: flag } + if (!flag) delete next.weakens_verification + entry.card = next + await publish(RuleEvents.Event.Updated, next) + return { ok: true, message: "Wording updated.", card: next, closed: false } + } + + pending.delete(id) + let message: string + if (input.action === "dismiss") { + const until = new Date(Date.now() + RulesStore.DISMISS_MS).toISOString() + await RulesStore.update(entry.root, (state) => { + for (const topic of card.topics) state.dismissed.push({ topic, until }) + }) + message = "Not a rule: it won't be proposed again for 30 days." + } else if (input.action === "save_memory") { + const [{ MemoryStore }, { RuleClassify }] = await Promise.all([import("../../memory/store"), import("./classify")]) + const scope = RuleClassify.memoryScope(card.text) + const id = `notes/${card.text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "note"}` + const now = new Date().toISOString() + await MemoryStore.write({ id, scope, tags: ["remembered"], created: now, updated: now, content: card.text }) + message = scope === "global" ? "Saved to your memory (all your projects)." : "Saved to this project's memory." + } else if (input.action === "save_local") { + const { TrainingStore } = await import("../training") + const name = card.text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "rule" + await TrainingStore.save({ kind: "rule", name, scope: "project", content: card.text, source: "proposed rule" }) + message = "Saved as a rule for this project." + } else { + const approve = input.action === "approve" ? (card.weakens_verification ? "approve_anyway" : "approve") : undefined + await RulesSync.enqueue(entry.root, { + change: card.change, + text: card.text, + applies_to: card.applies_to, + ...(card.why ? { why: card.why } : {}), + origin: card.origin, + ...(card.replaces ? { replaces: card.replaces.public_id, replaces_version: card.replaces.version } : {}), + evidence_sessions: card.evidence.sessions, + corrections: card.evidence.correction + ? [{ excerpt: card.evidence.correction.excerpt, corrected_at: card.evidence.correction.at, session_ref: hashRef(card.sessionID) }] + : [], + }, approve) + const report = await RulesSync.runShared(entry.root, entry.directory, entry.learn).catch(() => undefined) + const sent = report?.push?.sent.find((s) => s.text === card.text) + const owner = card.workspace?.owner_name ?? "the workspace owner" + if (!sent) message = `Queued: it will be sent to ${owner}'s review queue when the workspace is reachable.` + else if (sent.error) message = `Not sent: ${sent.error}` + else if (sent.status === "exists") message = "Already a team rule (or waiting for review): nothing new sent." + else if (sent.approved) message = `Approved: now a team rule in ${card.workspace?.name ?? "the workspace"}.` + else message = `Sent to ${owner}'s review queue in ${card.workspace?.name ?? "the workspace"}.` + } + await publish(RuleEvents.Event.Replied, { id, sessionID: card.sessionID, action: input.action, message }) + await publish(RuleEvents.Event.Changed, { directory: entry.directory }) + return { ok: true, message, closed: true } +} + +/** A session reference the server can count without learning the session id. */ +function hashRef(sessionID: string) { + return new Bun.CryptoHasher("sha256").update(sessionID).digest("hex").slice(0, 16) +} + +export * as RuleProposal from "./proposal" diff --git a/packages/opencode/src/altimate/rules/service.ts b/packages/opencode/src/altimate/rules/service.ts new file mode 100644 index 0000000000..122ee18e36 --- /dev/null +++ b/packages/opencode/src/altimate/rules/service.ts @@ -0,0 +1,111 @@ +// altimate_change - new file +// +// Per instance: keep workspace rules fresh and turn a session's corrections into proposal cards. +// +// - Sync in the background at start, then every `REFRESH_MS` (approved rules reach sessions within minutes). +// - Capture corrections when learning is on for this project: the person's own setting, else the +// workspace's "Learn from sessions" (see `learningEnabled`). Capture enabled in config starts on its own. +// - After each turn (session idle), check the session's new corrections (`RuleTrigger.afterTurn`). +// - Publish review notices from a pull as `rule.proposal.reviewed`. +import fs from "node:fs/promises" +import { Effect } from "effect" +import { Bus } from "@/bus" +import { registerDisposer } from "@/effect/instance-registry" +import type { InstanceContext } from "@/project/instance-context" +import { Log } from "@/util/log" +import { captureEnabled } from "../learn/config" +import { RuleEvents } from "./events" +import { RulesStore } from "./store" +import { RulesSync } from "./sync" +import { RuleTrigger } from "./trigger" + +const log = Log.create({ service: "rules.service" }) + +async function learnConfig(ctx: InstanceContext) { + const { Config } = await import("@/config/config") + return Config.peek(ctx)?.learn as ({ enabled?: boolean; capture?: boolean; model?: string } | undefined) +} + +function deps(ctx: InstanceContext, model?: string): RuleTrigger.TriggerDeps { + return { + source: async (sessionID) => (await import("../learn/session-reflect")).sourceFromSession(sessionID, ctx), + generate: async (source) => { + const [{ Provider }, { AppRuntime }, { InstanceRef }, { DEFAULT_TIMEOUT_MS, providerGenerate }, { learnModel }] = await Promise.all([ + import("@/provider/provider"), + import("@/effect/app-runtime"), + import("@/effect/instance-ref"), + import("../learn/reflect"), + import("../learn/auto"), + ]) + const chosen = learnModel(model) + return AppRuntime.runPromise( + providerGenerate(chosen ? Provider.parseModel(chosen) : source.model, DEFAULT_TIMEOUT_MS).pipe(Effect.provideService(InstanceRef, ctx)), + ) + }, + } +} + +async function publishNotices(directory: string, report: RulesSync.RunReport) { + for (const notice of report.pull?.notices ?? []) + await Bus.publish(RuleEvents.Event.Reviewed, { + public_id: notice.public_id, + text: notice.text, + status: notice.status, + ...(notice.reason ? { reason: notice.reason } : {}), + ...(notice.reviewer ? { reviewer: notice.reviewer } : {}), + }).catch(() => {}) + if (report.context.status === "ready") await Bus.publish(RuleEvents.Event.Changed, { directory }).catch(() => {}) +} + +/** Sync now, publishing notices and a change event. Shared with routes and the TUI menu. */ +export async function syncNow(root: string, directory: string, learn: Parameters[2]) { + const report = await RulesSync.runShared(root, directory, learn) + await publishNotices(directory, report) + return report +} + +export async function start(ctx: InstanceContext): Promise { + try { + const learn = await learnConfig(ctx) + if (!RulesSync.rulesEnabled(learn)) return + const root = await fs.realpath(ctx.worktree !== "/" ? ctx.worktree : ctx.directory) + const { Instance } = await import("@/project/instance") + let captureStarted = captureEnabled(learn) + const maybeCapture = async () => { + if (captureStarted) return + if (!RuleTrigger.learningEnabled(learn, await RulesStore.read(root))) return + captureStarted = true + await import("../learn/capture").then((m) => m.startCapture(ctx, { force: true })) + } + const refresh = Instance.bind(async () => { + const report = await syncNow(root, ctx.directory, learn).catch((error) => { + log.warn("rules sync failed", { error: error instanceof Error ? error.message : String(error) }) + return undefined + }) + if (report) await maybeCapture() + }) + await maybeCapture() + void refresh() + const timer = setInterval(() => void refresh(), RulesSync.REFRESH_MS) + timer.unref?.() + const onIdle = Instance.bind(async (sessionID: string) => { + const { flushCapture } = await import("../learn/capture") + await flushCapture() + await RuleTrigger.afterTurn({ sessionID, root, directory: ctx.directory, learn, deps: deps(ctx, learn?.model) }) + }) + const stop = Bus.subscribeAll((event) => { + if (event.type === "session.idle") void onIdle(event.properties.sessionID) + }) + const unregister = registerDisposer(async (directory) => { + if (directory !== ctx.directory) return + clearInterval(timer) + stop() + unregister() + }) + log.info("rules service started", { root }) + } catch (error) { + log.warn("rules service not started", { error: error instanceof Error ? error.message : String(error) }) + } +} + +export * as RulesService from "./service" diff --git a/packages/opencode/src/altimate/rules/store.ts b/packages/opencode/src/altimate/rules/store.ts new file mode 100644 index 0000000000..44b5027eab --- /dev/null +++ b/packages/opencode/src/altimate/rules/store.ts @@ -0,0 +1,192 @@ +// altimate_change - new file +// +// Local state for workspace rules, under `.altimate-code/learn/team/` (git-ignored): +// +// - `approved.json`: the workspace's approved rules for this repository, as lessons of a read-only `team` +// store. Delivery, recurrence and `learn show` read it like any other store, so team rules reach the +// prompt ("Team rules"), their exposure is recorded, and "corrected again" falls out of recurrence. +// Only a pull writes it; reflection and promotion never target this store. +// - `rules.json`: everything else this client keeps: the scope it was pulled for, workspace and owner, +// settings, revision and review cursor, the outbox of proposals not yet sent, the person's sent +// proposals and review notices, dismissed topics, and what usage has been reported. +// +// Writes take the learn lock (delivery reads the same directory) and are atomic. +import fs from "node:fs/promises" +import path from "node:path" +import z from "zod" +import { canonical, type Lesson } from "../learn/lesson" +import { withLearnLock } from "../learn/lock" +import * as Store from "../learn/store" +import { RuleOut, Settings } from "./api" + +/** The read-only store team rules are mirrored into. */ +export const TEAM_STORE = "team" +const LOCK = { timeoutMs: 5_000 } +const MAX_NOTICES = 50 +const MAX_SENT = 200 +/** "Not a rule" keeps a topic from being proposed again in this repository for this long. */ +export const DISMISS_MS = 30 * 24 * 60 * 60_000 + +export const Scope = z.object({ + apiUrl: z.string(), + tenant: z.string(), + account: z.string(), + datamateId: z.number().int(), + repoRemote: z.string(), +}) +export type Scope = z.infer +export const sameScope = (a: Scope | undefined, b: Scope | undefined) => + !!a && !!b && JSON.stringify([a.apiUrl, a.tenant, a.account, a.datamateId, a.repoRemote]) === + JSON.stringify([b.apiUrl, b.tenant, b.account, b.datamateId, b.repoRemote]) + +export const OutboxItem = z.object({ + id: z.string(), + created_at: z.string(), + attempts: z.number().int().default(0), + last_error: z.string().optional(), + /** The `ProposalItem` sent to `/proposals`. */ + item: z.object({ + change: z.enum(["add", "edit", "retire"]), + text: z.string(), + applies_to: z.array(z.string()), + why: z.string().optional(), + origin: z.enum(["session", "explicit", "bootstrap", "import_reviews"]), + replaces: z.string().optional(), + replaces_version: z.number().int().optional(), + evidence_sessions: z.number().int(), + corrections: z.array(z.object({ excerpt: z.string(), corrected_at: z.string(), session_ref: z.string().optional() })), + }), + /** Set by "Approve now": approve once the proposal is stored. */ + approve: z.enum(["approve", "approve_anyway"]).optional(), +}) +export type OutboxItem = z.infer + +export const Sent = z.object({ + public_id: z.string(), + text: z.string(), + change: z.string(), + status: z.string(), + reason: z.string().optional(), + reviewer: z.string().optional(), + sent_at: z.string(), + updated_at: z.string(), +}) +export type Sent = z.infer + +export const Notice = z.object({ + public_id: z.string(), + status: z.string(), + text: z.string(), + reason: z.string().optional(), + reviewer: z.string().optional(), + at: z.string(), +}) +export type Notice = z.infer + +export const State = z.object({ + version: z.literal(1), + scope: Scope.optional(), + workspace: z.object({ name: z.string(), owner_name: z.string().nullable().optional(), is_owner: z.boolean() }).optional(), + settings: Settings.optional(), + revision: z.number().int().optional(), + cursor: z.string().optional(), + pending_count: z.number().int().default(0), + pulled_at: z.string().optional(), + last_error: z.string().optional(), + rules: z.array(RuleOut).default([]), + outbox: z.array(OutboxItem).default([]), + sent: z.array(Sent).default([]), + notices: z.array(Notice).default([]), + dismissed: z.array(z.object({ topic: z.string(), until: z.string() })).default([]), + /** Sessions whose deliveries were already reported as `applied`. */ + usage_sessions: z.array(z.string()).default([]), + /** Per rule, the "corrected again" count already reported. */ + corrected_reported: z.record(z.string(), z.number().int()).default({}), + /** What the last post-turn check decided, for `learn status`. */ + last_check: z.object({ at: z.string(), session: z.string(), outcome: z.string() }).optional(), + /** Correction signals already considered for a proposal. */ + processed_signals: z.array(z.string()).default([]), + /** Set once `learn bootstrap` proposed rules from history for this scope. */ + bootstrapped_at: z.string().optional(), +}) +export type State = z.infer + +const EMPTY: State = State.parse({ version: 1 }) + +export function dir(root: string) { + return Store.paths(root, TEAM_STORE).learnDir +} +const stateFile = (root: string) => path.join(dir(root), "rules.json") + +export async function read(root: string): Promise { + const raw = await fs.readFile(stateFile(root), "utf8").catch(() => undefined) + if (!raw) return structuredClone(EMPTY) + try { + return State.parse(JSON.parse(raw)) + } catch { + return structuredClone(EMPTY) + } +} + +/** The lesson id a team rule is delivered and attributed under. */ +export const lessonId = (ruleKey: string) => `L-${ruleKey.slice(2)}` + +export function toLesson(rule: RuleOut): Lesson { + return { + id: lessonId(rule.rule_key), + text: rule.text, + tags: rule.tags, + scope: "project", + ...(rule.pinned ? { pinned: true } : {}), + ...(rule.applies_to.length ? { trigger: { paths: rule.applies_to } } : {}), + helpful: 0, + harmful: 0, + applied: 0, + created: rule.updated_at, + updated: rule.updated_at, + provenance: `workspace rule ${rule.public_id}`, + } +} + +async function ensureDir(root: string) { + await fs.mkdir(dir(root), { recursive: true }) + const ignore = path.join(dir(root), ".gitignore") + // The mirror and the client state are per person and per account: never commit them. + await fs.writeFile(ignore, "*\n", { flag: "wx" }).catch(() => {}) +} + +/** Read-modify-write under the learn lock. `change` may return a new state or mutate the one given. */ +export async function update(root: string, change: (state: State) => State | void): Promise { + await ensureDir(root) + return withLearnLock(root, async () => { + const current = await read(root) + const next = change(current) ?? current + next.notices = next.notices.slice(-MAX_NOTICES) + next.sent = next.sent.slice(-MAX_SENT) + next.usage_sessions = next.usage_sessions.slice(-1000) + next.processed_signals = next.processed_signals.slice(-1000) + const now = Date.now() + next.dismissed = next.dismissed.filter((d) => Date.parse(d.until) > now) + await Store.writeAtomic(root, stateFile(root), JSON.stringify(next, null, 2) + "\n") + await writeMirror(root, next.rules) + return next + }, LOCK) +} + +async function writeMirror(root: string, rules: readonly RuleOut[]) { + const file = Store.paths(root, TEAM_STORE).approved + const text = canonical(rules.map(toLesson)) + const previous = await fs.readFile(file, "utf8").catch(() => undefined) + if (previous !== text) await Store.writeAtomic(root, file, text) +} + +/** Forget everything pulled for another scope (account, workspace or remote changed) or after unlinking. */ +export async function reset(root: string): Promise { + await update(root, (state) => ({ ...structuredClone(EMPTY), dismissed: state.dismissed })) +} + +export function dismissed(state: State, topic: string): boolean { + return state.dismissed.some((d) => d.topic === topic.toLowerCase()) +} + +export * as RulesStore from "./store" diff --git a/packages/opencode/src/altimate/rules/summary.ts b/packages/opencode/src/altimate/rules/summary.ts new file mode 100644 index 0000000000..eeff22510d --- /dev/null +++ b/packages/opencode/src/altimate/rules/summary.ts @@ -0,0 +1,83 @@ +// altimate_change - new file +// +// What clients show about workspace rules, from local state only (no network): the sidebar line, the +// `/rules` menu, `GET /altimate/rules/summary` and `learn status`. +import { RulesStore } from "./store" +import { RuleTrigger } from "./trigger" + +type LearnConfig = Parameters[0] + +export interface Summary { + linked: boolean + workspace?: { name: string; owner_name?: string | null; is_owner: boolean } + team_rules: number + /** Proposals waiting for the owner, workspace-wide in this repository's scope. */ + pending_review: number + /** This person's proposals still waiting. */ + mine_pending: number + queued: number + learning: boolean + /** Who decided `learning`: the person's own setting, or the workspace's "Learn from sessions". */ + learning_source: "you" | "workspace" | "default" + pulled_at?: string + last_error?: string + notices: RulesStore.Notice[] + /** What the last post-turn check decided. */ + last_check?: { at: string; session: string; outcome: string } +} + +export async function summary(root: string, learn: LearnConfig, env: NodeJS.ProcessEnv = process.env): Promise { + const state = await RulesStore.read(root) + const own = env.ALTIMATE_LEARN_CAPTURE !== undefined && env.ALTIMATE_LEARN_CAPTURE !== "" || learn?.capture !== undefined || learn?.enabled === false + return { + linked: !!state.scope, + ...(state.workspace ? { workspace: state.workspace } : {}), + team_rules: state.rules.length, + pending_review: state.pending_count, + mine_pending: state.sent.filter((s) => s.status === "pending").length, + queued: state.outbox.length, + learning: RuleTrigger.learningEnabled(learn, state, env), + learning_source: own ? "you" : state.scope ? "workspace" : "default", + ...(state.pulled_at ? { pulled_at: state.pulled_at } : {}), + ...(state.last_error ? { last_error: state.last_error } : {}), + notices: state.notices.slice(-10).reverse(), + ...(state.last_check ? { last_check: state.last_check } : {}), + } +} + +/** "6 team rules · 3 pending review": zero counts after the first are left out. */ +export function sidebarLine(s: Pick): string { + const parts = [`${s.team_rules} team ${s.team_rules === 1 ? "rule" : "rules"}`] + if (s.pending_review > 0) parts.push(`${s.pending_review} pending review`) + if (s.queued > 0) parts.push(`${s.queued} queued`) + return parts.join(" · ") +} + +export interface RuleRow { + public_id: string + text: string + applies_to: string[] + pinned: boolean + status: string + reason?: string + reviewer?: string + source: "team" | "mine" +} + +/** Active team rules, then this person's proposals (newest first) with their status and reason. */ +export async function rows(root: string, view: "active" | "mine" | "all" = "all"): Promise { + const state = await RulesStore.read(root) + const active: RuleRow[] = state.rules.map((rule) => ({ + public_id: rule.public_id, text: rule.text, applies_to: rule.applies_to, pinned: rule.pinned, status: "active", source: "team", + })) + const mine: RuleRow[] = [ + ...state.outbox.map((entry) => ({ public_id: entry.id, text: entry.item.text, applies_to: entry.item.applies_to, pinned: false, status: "queued", source: "mine" as const })), + ...[...state.sent].reverse().map((sent) => ({ + public_id: sent.public_id, text: sent.text, applies_to: [], pinned: false, status: sent.status, + ...(sent.reason ? { reason: sent.reason } : {}), ...(sent.reviewer ? { reviewer: sent.reviewer } : {}), source: "mine" as const, + })), + ] + return view === "active" ? active : view === "mine" ? mine : [...active, ...mine] +} + +export * as RulesSummary from "./summary" diff --git a/packages/opencode/src/altimate/rules/sync.ts b/packages/opencode/src/altimate/rules/sync.ts new file mode 100644 index 0000000000..a14cc6a7c6 --- /dev/null +++ b/packages/opencode/src/altimate/rules/sync.ts @@ -0,0 +1,306 @@ +// altimate_change - new file +// +// Workspace rules sync for a linked project: +// +// - Pull (`POST /rules/sync`): the approved rules for this repository into the `team` store mirror, the +// workspace settings, and this person's proposals the owner reviewed since the last pull. Each review +// becomes a notice (`rule.proposal.reviewed`): "approved", or "rejected" with the owner's reason. +// - Push: the outbox of proposals the person sent from a card (or bootstrap), then "Approve now" for an +// owner who chose it. Offline-safe: an unsent proposal stays queued for the next run. +// - Usage: per rule, sessions it was delivered in (`applied`) and sessions where the person corrected the +// same topic after it was shown (`corrected_again`, from recurrence). +// +// Timing: a pull at session start (bounded wait), then every `REFRESH_MS` in the background, plus +// "Sync now". Never throws to an automatic caller: failures are recorded and reported. +import { randomUUID } from "node:crypto" +import { Log } from "@/util/log" +import { learnEnabled } from "../learn/config" +import type { ActAs } from "../workspace/api-client" +import { RulesApi, RulesApiError, type ProposalItem } from "./api" +import { RulesStore, type Scope, type State } from "./store" + +const log = Log.create({ service: "rules.sync" }) +export const REFRESH_MS = 5 * 60_000 +export const SESSION_WAIT_MS = 2_000 +const MAX_PROPOSALS = 50 +const MAX_USAGE_DELTA = 50 + +type LearnConfig = { enabled?: boolean } | undefined + +export type Context = + | { status: "off"; reason: string } + | { status: "skipped"; reason: string; unlinked?: boolean } + | { status: "ready"; scope: Scope; actAs: ActAs } + +/** Rules need learning on and workspaces on. */ +export function rulesEnabled(learn: LearnConfig, env: NodeJS.ProcessEnv = process.env): boolean { + const disabled = env["ALTIMATE_DISABLE_WORKSPACE"]?.trim().toLowerCase() + return learnEnabled(learn, env) && (disabled === undefined || disabled === "" || disabled === "0" || disabled === "false") +} + +/** Resolve the gate and scope. Binding resolution may ask the server; never call this under the learn lock. */ +export async function context(directory: string, learn: LearnConfig): Promise { + if (!rulesEnabled(learn)) return { status: "off", reason: "learning or workspaces are turned off" } + const [{ WorkspaceApi }, { resolveBindingOutcome, credentialDigest, accountDigest }] = await Promise.all([ + import("../workspace/api-client"), + import("../workspace/state"), + ]) + const actAs = await WorkspaceApi.captureCredentials() + if (!actAs) return { status: "skipped", reason: "not signed in to Altimate" } + const outcome = await resolveBindingOutcome(directory).catch(() => ({ status: "unknown" as const })) + if (outcome.status === "unbound") return { status: "skipped", reason: "this project is not linked to a workspace", unlinked: true } + if (outcome.status !== "bound") return { status: "skipped", reason: "the workspace link could not be confirmed" } + if (!outcome.binding.repoRemote) return { status: "skipped", reason: "this project has no git remote" } + const account = credentialDigest(actAs.url, actAs.instance, actAs.apiKey) + // The binding was resolved with the ambient credential: it must still be the captured one. + if ((await accountDigest()) !== account) return { status: "skipped", reason: "the Altimate account changed" } + return { + status: "ready", + actAs, + scope: { apiUrl: actAs.url, tenant: actAs.instance, account, datamateId: outcome.binding.datamateId, repoRemote: outcome.binding.repoRemote }, + } +} + +type Ready = Extract + +export interface PullResult { + ok: boolean + error?: string + rules: number + pending: number + notices: State["notices"] +} + +const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error)) + +export async function pull(root: string, ctx: Ready): Promise { + const before = await RulesStore.read(root) + const same = RulesStore.sameScope(before.scope, ctx.scope) + if (before.scope && !same) await RulesStore.reset(root) + try { + const response = await RulesApi.sync(ctx.actAs, ctx.scope.datamateId, { + repo_remote: ctx.scope.repoRemote, + ...(same && before.revision !== undefined ? { known_revision: before.revision } : {}), + ...(same && before.cursor ? { reviewed_since: before.cursor } : {}), + }) + const fresh: State["notices"] = [] + const state = await RulesStore.update(root, (state) => { + state.scope = ctx.scope + state.workspace = { name: response.workspace_name, owner_name: response.owner_name ?? null, is_owner: response.is_owner } + state.settings = response.settings + state.revision = response.revision + state.cursor = response.cursor + state.pending_count = response.pending_count + state.pulled_at = new Date().toISOString() + state.last_error = undefined + if (!response.unchanged) state.rules = response.rules + for (const reviewed of response.my_reviewed) { + if (state.notices.some((n) => n.public_id === reviewed.public_id && n.status === reviewed.status)) continue + const notice = { + public_id: reviewed.public_id, + status: reviewed.status, + text: reviewed.text, + ...(reviewed.review_reason ? { reason: reviewed.review_reason } : {}), + ...(reviewed.reviewed_by_name ? { reviewer: reviewed.reviewed_by_name } : {}), + at: reviewed.reviewed_at ?? reviewed.updated_at, + } + state.notices.push(notice) + fresh.push(notice) + const sent = state.sent.find((s) => s.public_id === reviewed.public_id) + if (sent) Object.assign(sent, { status: reviewed.status, reason: notice.reason, reviewer: notice.reviewer, updated_at: notice.at }) + } + }) + return { ok: true, rules: state.rules.length, pending: state.pending_count, notices: fresh } + } catch (error) { + if (error instanceof RulesApiError && (error.kind === "workspace_not_found" || error.kind === "repo_not_bound")) { + // The link is gone: stop delivering its rules. + await RulesStore.reset(root) + } else { + await RulesStore.update(root, (state) => { state.last_error = errorText(error) }) + } + log.warn("rules pull failed", { error: errorText(error) }) + return { ok: false, error: errorText(error), rules: before.rules.length, pending: before.pending_count, notices: [] } + } +} + +export interface PushResult { + sent: { text: string; status: string; public_id?: string; error?: string; approved?: boolean }[] + queued: number + error?: string +} + +/** Send the outbox. An item is dropped only once the server answered it; a transient failure keeps it. */ +export async function push(root: string, ctx: Ready): Promise { + const state = await RulesStore.read(root) + const batch = state.outbox.slice(0, MAX_PROPOSALS) + if (!batch.length) return { sent: [], queued: 0 } + let results + try { + results = await RulesApi.propose(ctx.actAs, ctx.scope.datamateId, { + repo_remote: ctx.scope.repoRemote, + items: batch.map((entry) => entry.item as ProposalItem), + }) + } catch (error) { + await RulesStore.update(root, (s) => { + for (const entry of s.outbox) if (batch.some((b) => b.id === entry.id)) { + entry.attempts += 1 + entry.last_error = errorText(error) + } + }) + return { sent: [], queued: state.outbox.length, error: errorText(error) } + } + const sent: PushResult["sent"] = [] + const approvals: { entry: (typeof batch)[number]; publicId: string; version: number }[] = [] + for (const result of results) { + const entry = batch[result.index] + if (!entry) continue + const ok = result.status === "pending" || result.status === "exists" || result.duplicate + sent.push({ + text: entry.item.text, + status: result.status, + ...(result.public_id ? { public_id: result.public_id } : {}), + ...(!ok ? { error: result.error_detail ?? result.error_code ?? result.status } : {}), + }) + if (entry.approve && result.status === "pending" && result.public_id && result.version) + approvals.push({ entry, publicId: result.public_id, version: result.version }) + } + for (const approval of approvals) { + try { + const reviewed = await RulesApi.review(ctx.actAs, ctx.scope.datamateId, approval.publicId, { + action: approval.entry.approve!, + expected_version: approval.version, + }) + const row = sent.find((s) => s.public_id === approval.publicId) + if (row) Object.assign(row, { status: reviewed.status, approved: true }) + } catch (error) { + const row = sent.find((s) => s.public_id === approval.publicId) + if (row) row.error = `sent for review; approving failed: ${errorText(error)}` + } + } + const now = new Date().toISOString() + await RulesStore.update(root, (s) => { + s.outbox = s.outbox.filter((entry) => !batch.some((b) => b.id === entry.id)) + for (const row of sent) { + if (!row.public_id || row.status === "exists") continue + const existing = s.sent.find((x) => x.public_id === row.public_id) + const change = batch.find((b) => b.item.text === row.text)?.item.change ?? "add" + if (existing) Object.assign(existing, { status: row.status, updated_at: now }) + else s.sent.push({ public_id: row.public_id, text: row.text, change, status: row.status, sent_at: now, updated_at: now }) + if (row.status === "pending") s.pending_count += 1 + } + }) + return { sent, queued: Math.max(0, state.outbox.length - batch.length) } +} + +/** Queue a proposal; it is sent by the next push. */ +export async function enqueue(root: string, item: ProposalItem, approve?: "approve" | "approve_anyway") { + await RulesStore.update(root, (state) => { + state.outbox.push({ id: randomUUID(), created_at: new Date().toISOString(), attempts: 0, item, ...(approve ? { approve } : {}) }) + }) +} + +/** Report usage of team rules: delivered sessions and "corrected again", each counted once. */ +export async function reportUsage(root: string, ctx: Ready): Promise<{ items: number; error?: string }> { + const state = await RulesStore.read(root) + if (!state.rules.length) return { items: 0 } + const byLesson = new Map(state.rules.map((rule) => [RulesStore.lessonId(rule.rule_key), rule])) + const [{ readShownSnapshots }, { analyzeRecurrence }] = await Promise.all([import("../learn/delivery"), import("../learn/recurrence")]) + const { snapshots } = await readShownSnapshots(root) + const reported = new Set(state.usage_sessions) + const counts = new Map() + const sessions: string[] = [] + for (const snapshot of snapshots) { + if (reported.has(snapshot.session)) continue + const shown = new Set(snapshot.shown.filter((entry) => entry.name === RulesStore.TEAM_STORE).map((entry) => entry.id)) + if (!shown.size) continue + sessions.push(snapshot.session) + for (const id of shown) { + const rule = byLesson.get(id) + if (!rule) continue + const row = counts.get(rule.public_id) ?? { applied: 0, corrected_again: 0 } + row.applied += 1 + counts.set(rule.public_id, row) + } + } + const corrected: Record = {} + const recurrence = await analyzeRecurrence(root).catch(() => undefined) + for (const row of recurrence?.lessons ?? []) { + if (row.name !== RulesStore.TEAM_STORE) continue + const rule = byLesson.get(row.id) + if (!rule) continue + corrected[rule.public_id] = row.recurred + const delta = row.recurred - (state.corrected_reported[rule.public_id] ?? 0) + if (delta <= 0) continue + const entry = counts.get(rule.public_id) ?? { applied: 0, corrected_again: 0 } + entry.corrected_again += delta + counts.set(rule.public_id, entry) + } + if (!counts.size) return { items: 0 } + const items = [...counts].map(([public_id, c]) => ({ + public_id, + applied: Math.min(c.applied, MAX_USAGE_DELTA), + helpful: 0, + harmful: 0, + corrected_again: Math.min(c.corrected_again, MAX_USAGE_DELTA), + })) + try { + await RulesApi.usage(ctx.actAs, ctx.scope.datamateId, { batch_id: randomUUID(), items }) + } catch (error) { + return { items: 0, error: errorText(error) } + } + await RulesStore.update(root, (s) => { + s.usage_sessions.push(...sessions) + s.corrected_reported = { ...s.corrected_reported, ...corrected } + }) + return { items: items.length } +} + +export interface RunReport { + context: Context + push?: PushResult + pull?: PullResult + usage?: { items: number; error?: string } +} + +/** Push, pull, then report usage: "Sync now", `learn sync`, and the background refresh. */ +export async function run(root: string, directory: string, learn: LearnConfig): Promise { + const ctx = await context(directory, learn) + if (ctx.status !== "ready") { + if (ctx.status === "skipped" && ctx.unlinked) { + const state = await RulesStore.read(root) + if (state.scope) await RulesStore.reset(root) + } + return { context: ctx } + } + const pushed = await push(root, ctx) + const pulled = await pull(root, ctx) + const usage = pulled.ok ? await reportUsage(root, ctx).catch((error) => ({ items: 0, error: errorText(error) })) : undefined + return { context: ctx, push: pushed, pull: pulled, ...(usage ? { usage } : {}) } +} + +const inflight = new Map>() + +/** One run per project at a time; concurrent callers share it. */ +export function runShared(root: string, directory: string, learn: LearnConfig): Promise { + const existing = inflight.get(root) + if (existing) return existing + const started = run(root, directory, learn).finally(() => inflight.delete(root)) + inflight.set(root, started) + return started +} + +/** A pull is due when none succeeded for this project within `REFRESH_MS`. */ +export async function due(root: string): Promise { + const state = await RulesStore.read(root) + const at = state.pulled_at ? Date.parse(state.pulled_at) : 0 + return !Number.isFinite(at) || Date.now() - at >= REFRESH_MS +} + +/** Session start: refresh when due, waiting at most `waitMs` so the first turn sees fresh rules. */ +export async function sessionStart(root: string, directory: string, learn: LearnConfig, waitMs = SESSION_WAIT_MS): Promise { + if (!rulesEnabled(learn) || !(await due(root))) return + const running = runShared(root, directory, learn).catch((error) => log.warn("rules session sync failed", { error: errorText(error) })) + await Promise.race([running, new Promise((resolve) => setTimeout(resolve, waitMs).unref?.())]) +} + +export * as RulesSync from "./sync" diff --git a/packages/opencode/src/altimate/rules/trigger.ts b/packages/opencode/src/altimate/rules/trigger.ts new file mode 100644 index 0000000000..b116ebb901 --- /dev/null +++ b/packages/opencode/src/altimate/rules/trigger.ts @@ -0,0 +1,262 @@ +// altimate_change - new file +// +// When a correction becomes a proposed rule, and the one model call that words it. +// +// After each turn (session idle) the new correction signals of that session are checked: +// - (a) recurrence: the same topic corrected in 2 or more sessions with no rule covering it → an "add" card; +// - (c) a correction of a team rule the session was shown → an "edit" card (or "retire" when the drafter +// says the rule is wrong), shown with the current text struck through. +// (b) — the person asking to remember something — comes from `training_save` (`explicit`). +// +// The drafter is the reflector, scoped to the triggering corrections with the team rules as its playbook: +// an ADD becomes an add card, an EDIT or REMOVE of a team rule becomes an edit or retire card, and a +// HELPFUL on a team rule (the rule already says it) makes no card. +import { Log } from "@/util/log" +import { lint } from "../learn/curator" +import { readShownSnapshots } from "../learn/delivery" +import { buildDigest, type DigestSource } from "../learn/digest" +import * as Playbook from "../learn/playbook" +import { analyzeRecurrence } from "../learn/recurrence" +import { reflect, type Generate } from "../learn/reflect" +import { readSignals, type Signal } from "../learn/signals" +import { RuleClassify } from "./classify" +import { RuleProposal, excerpt, type Draft } from "./proposal" +import { RulesStore } from "./store" + +const log = Log.create({ service: "rules.trigger" }) +export const RECURRENCE_SESSIONS = 2 +const MAX_WHY = 280 + +export interface TriggerDeps { + /** The model for one drafting call, resolved for this session. */ + generate: (source: DigestSource) => Promise + source: (sessionID: string) => Promise +} + +type LearnConfig = { enabled?: boolean; capture?: boolean } | undefined + +/** + * Whether sessions here learn (capture corrections and propose rules). The person's own setting wins + * (`learn enable` / `learn disable`, or `ALTIMATE_LEARN_CAPTURE`); otherwise a linked project follows the + * workspace's "Learn from sessions"; otherwise off. + */ +export function learningEnabled(learn: LearnConfig, state: RulesStore.State, env: NodeJS.ProcessEnv = process.env): boolean { + if (learn?.enabled === false || /^(0|false)$/i.test(env.ALTIMATE_LEARN ?? "")) return false + const forced = env.ALTIMATE_LEARN_CAPTURE + if (forced !== undefined && forced !== "") return /^(1|true)$/i.test(forced) + if (learn?.capture !== undefined) return learn.capture + return !!state.scope && state.settings?.learning_enabled === true +} + +const STOP = new Set("the and for with that this from into when then than are was were have has had not use using should must always never only all any each your you our its their them they these those also but can will would could does like just".split(" ")) +/** Content words of a text, for the edit-candidate filter. */ +const words = (text: string) => new Set((text.toLowerCase().match(/[a-z][a-z0-9_]{2,}/g) ?? []).filter((w) => !STOP.has(w))) +/** Shared content words that make a shown team rule the subject of a correction. */ +export const EDIT_OVERLAP = 3 + +const KNOWN_DIRS = new Set(["models", "macros", "seeds", "snapshots", "tests", "analyses", "src", "app", "lib", "dags", "sql", "scripts"]) + +/** Path globs a correction points at: directories of the paths it mentions. */ +export function appliesTo(texts: readonly string[]): string[] { + const globs = new Set() + for (const text of texts) + for (const match of text.matchAll(/(?:^|[\s`'"(])((?:[\w.-]+\/)+)[\w.*-]*/g)) { + const dir = match[1].replace(/^\.\//, "").replace(/\/$/, "") + // Paths are lowercase directory names ("models/staging"); "FLOAT/DOUBLE" or "and/or" are not. + const segments = dir.split("/") + if (!dir || dir.startsWith("..") || segments.some((segment) => !/^[a-z0-9_.-]+$/.test(segment))) continue + if (segments.length === 1 && !/[._]/.test(segments[0]) && !KNOWN_DIRS.has(segments[0])) continue + globs.add(`${dir}/**`) + } + return [...globs].slice(0, 4) +} + +const clip = (text: string, max: number) => (text.length <= max ? text : text.slice(0, max - 1) + "…") +const teamBullets = (state: RulesStore.State): Playbook.Bullet[] => + state.rules.map((rule) => ({ id: RulesStore.lessonId(rule.rule_key), text: rule.text, helpful: 0, harmful: 0 })) + +async function draft(input: { + deps: TriggerDeps + sessionID: string + signals: readonly Signal[] + state: RulesStore.State + target?: string + /** Why no proposal came out, for the check record. */ + note?: (outcome: string) => void +}): Promise<{ text: string; change: Draft["change"]; replaces?: Draft["replaces"]; reason: string } | undefined> { + const source = await input.deps.source(input.sessionID) + const generate = await input.deps.generate(source) + const bullets = teamBullets(input.state) + const deltas = await reflect({ + digest: buildDigest(source), + feedback: input.signals.map((signal) => signal.text).join("\n\n"), + kind: "user", + bullets: input.target ? bullets.filter((b) => b.id === input.target) : bullets, + }, generate) + log.info("rule draft", { session: input.sessionID, target: input.target, deltas: deltas.map((d) => d.op) }) + const rules = new Map(input.state.rules.map((rule) => [RulesStore.lessonId(rule.rule_key), rule])) + for (const delta of deltas) { + const target = delta.id ? rules.get(delta.id) : undefined + if (delta.op === "HELPFUL" && target) { + input.note?.(`the team rule already covers it (${delta.reason})`) + return undefined + } + if (delta.op === "EDIT" && target && delta.text && !lint(delta.text)) + return { text: delta.text.trim(), change: "edit", replaces: { public_id: target.public_id, text: target.text, version: target.version }, reason: delta.reason } + if (delta.op === "REMOVE" && target) + return { text: target.text, change: "retire", replaces: { public_id: target.public_id, text: target.text, version: target.version }, reason: delta.reason } + // With a target, an ADD is a separate constraint on the same subject: a new rule beside it. + if (delta.op === "ADD" && delta.text && !lint(delta.text)) + return { text: delta.text.trim(), change: "add", reason: delta.reason } + } + input.note?.(`drafter proposed nothing usable (${deltas.map((d) => d.op).join(", ") || "no changes"})`) + return undefined +} + +const running = new Set() + +/** Check the session's new corrections and show at most one card. Never throws. */ +export async function afterTurn(input: { + sessionID: string + root: string + directory: string + learn: LearnConfig + deps: TriggerDeps +}): Promise { + // Idle can fire twice for one turn; one check per session at a time. + if (running.has(input.sessionID)) return + running.add(input.sessionID) + let outcome = "no new corrections" + const note = (text: string) => { outcome = text } + try { + const state = await RulesStore.read(input.root) + if (!learningEnabled(input.learn, state)) return + const processed = new Set(state.processed_signals) + const fresh = (await readSignals(input.root, Playbook.DEFAULT_NAME)).filter((signal) => + signal.kind === "user_correction" && signal.sessionID === input.sessionID && !processed.has(signal.id)) + if (!fresh.length) return + // One card at a time per session: an explicit "remember" may already have shown one for this turn. + if (RuleProposal.list().some((card) => card.sessionID === input.sessionID)) return + await RulesStore.update(input.root, (s) => { s.processed_signals.push(...fresh.map((signal) => signal.id)) }) + const newest = fresh[fresh.length - 1] + const correction = { excerpt: excerpt(newest.text), at: newest.correctedAt ?? newest.at } + + // (c) A team rule shown in this session before the correction, on the same subject: propose changing it. + // A loose word-overlap filter picks the candidate; the drafter decides (HELPFUL = the rule already says it). + const snapshot = (await readShownSnapshots(input.root)).snapshots.find((s) => s.session === input.sessionID) + const correctedAt = Date.parse(newest.correctedAt ?? newest.at) + const shownIds = new Set((snapshot?.shown ?? []) + .filter((entry) => entry.name === RulesStore.TEAM_STORE && !(Date.parse(entry.at) >= correctedAt)) + .map((entry) => entry.id)) + const said = words(fresh.map((signal) => signal.text).join(" ")) + const hit = state.rules + .map((rule) => ({ id: RulesStore.lessonId(rule.rule_key), overlap: [...words(rule.text)].filter((w) => said.has(w)).length })) + .filter((rule) => shownIds.has(rule.id) && rule.overlap >= EDIT_OVERLAP) + .sort((a, b) => b.overlap - a.overlap)[0] + log.info("rule edit candidate", { session: input.sessionID, hit: hit?.id, overlap: hit?.overlap, shown: shownIds.size }) + if (hit) { + const drafted = await draft({ deps: input.deps, sessionID: input.sessionID, signals: fresh, state, target: hit.id, note }) + if (drafted) { + await RuleProposal.ask({ + sessionID: input.sessionID, root: input.root, directory: input.directory, learn: input.learn, + draft: { + text: drafted.text, + // An edit or retire keeps the rule's scope; a new rule beside it takes the correction's. + applies_to: drafted.change === "add" + ? appliesTo(fresh.map((signal) => signal.text)) + : state.rules.find((r) => RulesStore.lessonId(r.rule_key) === hit.id)?.applies_to ?? [], + why: clip(`${drafted.change === "add" ? "Corrected on the subject of a team rule" : "Corrected again after the rule was shown"}: ${drafted.reason}`, MAX_WHY), + change: drafted.change, ...(drafted.replaces ? { replaces: drafted.replaces } : {}), + origin: "session", evidence: { sessions: 1, correction }, topics: [hit.id], + }, + }) + outcome = `showed a ${drafted.change} card for a shown team rule` + return + } + } + + // (a) The same topic corrected in 2 or more sessions, with no rule covering it. + const recurrence = await analyzeRecurrence(input.root) + const freshIds = new Set(fresh.map((signal) => signal.id)) + const topic = recurrence.unmatched_topics.find((t) => + t.sessions >= RECURRENCE_SESSIONS && t.signal_ids.some((id) => freshIds.has(id)) && !RulesStore.dismissed(state, t.identifier)) + if (!topic) { + if (!hit) outcome = "no team rule shown on this subject and no topic corrected in 2+ sessions" + return + } + const all = await readSignals(input.root, Playbook.DEFAULT_NAME) + const backing = all.filter((signal) => topic.signal_ids.includes(signal.id)) + const drafted = await draft({ deps: input.deps, sessionID: input.sessionID, signals: backing, state, note }) + if (!drafted || drafted.change !== "add") return + await RuleProposal.ask({ + sessionID: input.sessionID, root: input.root, directory: input.directory, learn: input.learn, + draft: { + text: drafted.text, + applies_to: appliesTo(backing.map((signal) => signal.text)), + why: clip(`Corrected in ${topic.sessions} sessions: ${drafted.reason}`, MAX_WHY), + change: "add", origin: "session", + evidence: { sessions: topic.sessions, correction }, + topics: [topic.identifier], + }, + }) + outcome = `showed an add card for topic ${topic.identifier}` + } catch (error) { + outcome = `failed: ${error instanceof Error ? error.message : String(error)}` + log.warn("rule proposal check failed", { error: outcome }) + } finally { + running.delete(input.sessionID) + if (outcome !== "no new corrections") + await RulesStore.update(input.root, (s) => { s.last_check = { at: new Date().toISOString(), session: input.sessionID, outcome } }).catch(() => {}) + } +} + +/** "Remember this as a rule": a card right away, worded as the agent saved it. */ +export async function explicit(input: { + sessionID: string + root: string + directory: string + learn: LearnConfig + text: string + /** The person's own message that asked for it: their words decide rule or memory as much as the agent's. */ + said?: string +}): Promise< + | { status: "shown"; card: Awaited>; choose: boolean } + | { status: "invalid"; reason: string } + | { status: "memory"; cue: string } +> { + const text = input.text.replace(/\s+/g, " ").trim() + const own = input.said ? RuleClassify.classify(input.said) : { kind: "ambiguous" as const } + const { kind, cue } = own.kind === "ambiguous" ? RuleClassify.classify(text) : own + if (kind === "memory") return { status: "memory", cue: cue ?? "a fact or preference" } + const reason = lint(text) + if (reason) return { status: "invalid", reason } + const signals = (await readSignals(input.root, Playbook.DEFAULT_NAME).catch(() => [] as Signal[])) + .filter((signal) => signal.kind === "user_correction" && signal.sessionID === input.sessionID) + const last = signals[signals.length - 1] + // A rule that refines an active team rule is an edit of it, shown against the current text. + const state = await RulesStore.read(input.root) + const said = words(text) + const target = state.rules + .map((rule) => ({ rule, overlap: [...words(rule.text)].filter((w) => said.has(w)).length })) + .filter((r) => r.overlap >= EDIT_OVERLAP && r.rule.text.toLowerCase() !== text.toLowerCase()) + .sort((a, b) => b.overlap - a.overlap)[0]?.rule + const card = await RuleProposal.ask({ + sessionID: input.sessionID, root: input.root, directory: input.directory, learn: input.learn, + draft: { + text, + applies_to: target?.applies_to ?? appliesTo([text, ...(last ? [last.text] : [])]), + why: target + ? "You asked to remember this; it changes an existing team rule" + : kind === "rule" ? "You asked to remember this as a rule" : "You asked to remember this", + change: target ? "edit" : "add", + ...(target ? { replaces: { public_id: target.public_id, text: target.text, version: target.version } } : {}), + origin: "explicit", + evidence: { sessions: 1, ...(last ? { correction: { excerpt: excerpt(last.text), at: last.correctedAt ?? last.at } } : {}) }, + topics: [], + ...(kind === "ambiguous" ? { choose: true } : {}), + }, + }) + return { status: "shown", card, choose: kind === "ambiguous" } +} + +export * as RuleTrigger from "./trigger" diff --git a/packages/opencode/src/altimate/tools/training-save.ts b/packages/opencode/src/altimate/tools/training-save.ts index dd0880005c..7a29150449 100644 --- a/packages/opencode/src/altimate/tools/training-save.ts +++ b/packages/opencode/src/altimate/tools/training-save.ts @@ -8,6 +8,59 @@ import { CitationSchema } from "../../memory/types" const log = Log.create({ service: "tool.training_save" }) +// altimate_change start — workspace rules: route a project rule to the proposal card when linked +/** The text of the person's latest message in the session. */ +function lastUserText(messages: unknown): string | undefined { + if (!Array.isArray(messages)) return undefined + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] as { info?: { role?: string }; parts?: { type?: string; text?: string; synthetic?: boolean }[] } + if (message?.info?.role !== "user") continue + const text = (message.parts ?? []).filter((part) => part.type === "text" && !part.synthetic).map((part) => part.text ?? "").join(" ").trim() + if (text) return text + } + return undefined +} + +async function proposeTeamRule(content: string, sessionID: string, said?: string) { + const [{ Instance }, { Config }, { RulesStore }, { RuleTrigger }] = await Promise.all([ + import("../../project/instance"), + import("@/config/config"), + import("../rules/store"), + import("../rules/trigger"), + ]) + const root = Instance.worktree !== "/" ? Instance.worktree : Instance.directory + const state = await RulesStore.read(root) + if (!state.scope || !state.workspace) return undefined + const learn = (await Config.get()).learn + const result = await RuleTrigger.explicit({ sessionID, root, directory: Instance.directory, learn, text: content, said }) + const metadata = { action: "proposed" as string, kind: "rule", name: "", scope: "project" } + if (result.status === "memory") + return { + title: "Not a team rule", + metadata: { ...metadata, action: "redirect" }, + output: `This reads as ${result.cue}, not a team rule. Save it with altimate_memory_write instead (scope "global" for a personal preference, "project" for a fact about this project), then tell the person it was saved to memory.`, + } + if (result.status === "invalid") + return { + title: "Team rule: not proposed", + metadata: { ...metadata, action: "error" }, + output: `Not proposed: a team rule must be one line of at most 140 characters with no commands, links or secrets (${result.reason}). Restate it as one short line and call training_save again.`, + } + const owner = state.workspace.is_owner ? "you (the workspace owner)" : (state.workspace.owner_name ?? "the workspace owner") + return { + title: `Team rule proposed: "${content.slice(0, 60)}"`, + metadata, + output: [ + result.choose + ? `The person sees a card asking whether to save this as a memory (just for them, applies now) or as a team rule for ${state.workspace.name} (goes to ${owner} for review), or to skip it.` + : `Proposed as a team rule for ${state.workspace.name}. The person sees a card to send it to ${owner} for review` + + (state.workspace.is_owner ? " or approve it now" : "") + ", edit the wording, or decline.", + "Follow the rule for the rest of this session. Do not save it again, and do not say it is already a team rule: it applies to teammates only after approval.", + ].join("\n"), + } +} +// altimate_change end + export const TrainingSaveTool = Tool.define("training_save", { description: [ "Save a learned pattern, rule, glossary term, or standard to your teammate's training.", @@ -16,6 +69,10 @@ export const TrainingSaveTool = Tool.define("training_save", { "Training kinds:", "- 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.", + " 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')", "- standard: A team standard from documentation (e.g., SQL style guide rules)", "- context: Background knowledge explaining 'why' (e.g., why we chose Snowflake over BigQuery)", @@ -62,6 +119,12 @@ export const TrainingSaveTool = Tool.define("training_save", { }), async execute(args, ctx) { try { + // altimate_change start — in a project linked to a workspace, a project rule is a proposed team rule: + // it shows the proposal card (the person sends it to the owner's review, or the owner approves it), + // instead of training that applies at once and reaches the team through git without review. + const proposed = args.kind === "rule" && args.scope === "project" ? await proposeTeamRule(args.content, ctx.sessionID, lastUserText(ctx.messages)) : undefined + if (proposed) return proposed + // altimate_change end const scopeForCount = args.scope === "global" ? "global" : "project" // Check if this is an update to an existing entry diff --git a/packages/opencode/src/altimate/workspace/api-client.ts b/packages/opencode/src/altimate/workspace/api-client.ts index 47c4a9a676..d7c3cfb4fc 100644 --- a/packages/opencode/src/altimate/workspace/api-client.ts +++ b/packages/opencode/src/altimate/workspace/api-client.ts @@ -135,15 +135,23 @@ export class PreconditionFailedError extends Error { } } +/** `detail` is the response's `detail` value when it was an object (`{"code": ...}`). A route that + * does not exist answers FastAPI's bare string, so callers can tell the two 404s apart. */ export class NotFoundError extends Error { - constructor(msg = "Not found") { + constructor( + msg = "Not found", + public readonly detail?: Record, + ) { super(msg) this.name = "NotFoundError" } } export class ForbiddenError extends Error { - constructor(msg = "Forbidden") { + constructor( + msg = "Forbidden", + public readonly detail?: Record, + ) { super(msg) this.name = "ForbiddenError" } @@ -153,6 +161,7 @@ export class WorkspaceApiError extends Error { constructor( msg: string, public readonly status?: number, + public readonly detail?: Record, ) { super(msg) this.name = "WorkspaceApiError" @@ -284,8 +293,9 @@ async function req( } } const detail = (json as { detail?: unknown } | undefined)?.detail - if (res.status === 404) throw new NotFoundError(typeof detail === "string" ? detail : "Not found") - if (res.status === 403) throw new ForbiddenError(typeof detail === "string" ? detail : "Forbidden") + const coded = typeof detail === "object" && detail !== null && !Array.isArray(detail) ? (detail as Record) : undefined + if (res.status === 404) throw new NotFoundError(typeof detail === "string" ? detail : "Not found", coded) + if (res.status === 403) throw new ForbiddenError(typeof detail === "string" ? detail : "Forbidden", coded) if (res.status === 409) { const d = typeof detail === "object" && detail !== null @@ -304,6 +314,7 @@ async function req( throw new WorkspaceApiError( typeof detail === "string" ? detail : `Request failed with status ${res.status}`, res.status, + coded, ) } // A 2xx with an empty (or unparseable) body is not the same as a resource. diff --git a/packages/opencode/src/cli/cmd/learn.ts b/packages/opencode/src/cli/cmd/learn.ts index 8fbe0621dc..3731f2adc5 100644 --- a/packages/opencode/src/cli/cmd/learn.ts +++ b/packages/opencode/src/cli/cmd/learn.ts @@ -392,6 +392,9 @@ const StatusCommand = effectCmd({ ...base, // Read-only and advisory: runs after the lock is released; its failure must not hide the rest of the status. ...await analyzeRecurrence(root, { name }).then((recurrence) => ({ recurrence }), (error) => ({ recurrence_error: error instanceof Error ? error.message : String(error) })), + // altimate_change start — workspace rules (local state only) + rules: await import("../../altimate/rules/summary").then((m) => m.RulesSummary.summary(root, learn)), + // altimate_change end })) if (args.json) return out(JSON.stringify(status, null, 2)) out(`Learning enabled: ${status.enabled ? "yes" : "no"}`) @@ -420,10 +423,47 @@ const StatusCommand = effectCmd({ ? `Last reflection: ${status.last_reflection.at} - ${status.last_reflection.result}: ${status.last_reflection.summary}` : "Last reflection: never") out(`Limits: ${Object.entries(status.limits).map(([key, value]) => `${key}=${value}`).join(", ")}`) + // altimate_change start — workspace rules + const rules = status.rules + if (!rules.linked) out("Workspace rules: not linked (run `altimate-code link`)") + else { + const { RulesSummary } = await import("../../altimate/rules/summary") + out(`Workspace rules: ${rules.workspace?.name ?? "workspace"} — ${RulesSummary.sidebarLine(rules)}${rules.pulled_at ? `; last sync ${rules.pulled_at}` : ""}${rules.last_error ? `; last error: ${rules.last_error}` : ""}`) + out(`Learning from sessions: ${rules.learning ? "on" : "off"} (${rules.learning_source === "you" ? "your setting" : "workspace setting"})`) + if (rules.last_check) out(`Last proposal check: ${rules.last_check.at} — ${rules.last_check.outcome}`) + for (const notice of rules.notices.slice(0, 5)) + out(` ${notice.status === "active" ? "approved" : notice.status}${notice.reviewer ? ` by ${notice.reviewer}` : ""}: "${notice.text}"${notice.reason ? ` — ${notice.reason}` : ""}`) + } + // altimate_change end }) }), }) +// altimate_change start — workspace rules: send queued proposals, pull approved rules, report usage +const SyncCommand = effectCmd({ + command: "sync", + describe: "sync workspace rules now: send queued proposals and pull the approved rules", + builder: (yargs: Argv) => yargs.option("json", { type: "boolean", default: false, describe: "machine-readable output" }), + handler: Effect.fn("Cli.learn.sync")(function* (args) { + yield* run("", async () => { + const root = await projectRoot() + const { Config } = await import("@/config/config") + const { RulesSync } = await import("../../altimate/rules/sync") + const report = await RulesSync.run(root, process.cwd(), (await Config.get()).learn) + if (args.json) return out(JSON.stringify(report, (key, value) => (key === "actAs" ? undefined : value), 2)) + if (report.context.status !== "ready") return out(`Rules sync skipped: ${report.context.reason}.`) + for (const sent of report.push?.sent ?? []) out(`Sent "${sent.text}": ${sent.error ?? sent.status}`) + if (report.push?.error) out(`Proposals not sent (kept queued): ${report.push.error}`) + if (!report.pull?.ok) throw new Error(`Rules pull failed: ${report.pull?.error ?? "unknown error"}`) + out(`Synced: ${report.pull.rules} team ${report.pull.rules === 1 ? "rule" : "rules"}, ${report.pull.pending} pending review.`) + for (const notice of report.pull.notices) + out(` ${notice.status === "active" ? "approved" : notice.status}${notice.reviewer ? ` by ${notice.reviewer}` : ""}: "${notice.text}"${notice.reason ? ` — ${notice.reason}` : ""}`) + if (report.usage?.error) out(`Usage not reported: ${report.usage.error}`) + }) + }), +}) +// altimate_change end + const BootstrapCommand = effectCmd({ command: "bootstrap", describe: "seed candidate lessons from this project's past sessions", @@ -473,6 +513,16 @@ const BootstrapCommand = effectCmd({ }) }) if (result?.failures) return yield* fail("Bootstrap reflection failed; signals remain queued. Rerun `learn bootstrap` to continue.") + // altimate_change start — linked project: the staged lessons go to the workspace owner's review as rules + if (!args["dry-run"]) yield* run("", async () => { + const { Config } = await import("@/config/config") + const { RulesFromHistory } = await import("../../altimate/rules/from-history") + const sent = await RulesFromHistory.proposeStaged({ + root: await projectRoot(), directory: process.cwd(), learn: (await Config.get()).learn, name: args.name as string, origin: "bootstrap", + }) + if (sent.status === "sent") out(sent.message) + }) + // altimate_change end }), }) @@ -532,6 +582,16 @@ const ImportReviewsCommand = effectCmd({ }) }) if (result?.failures) return yield* fail("Review reflection failed; signals remain queued. Rerun `learn import-reviews` to continue.") + // altimate_change start — linked project: the staged lessons go to the workspace owner's review as rules + if (!args["dry-run"]) yield* run("", async () => { + const { Config } = await import("@/config/config") + const { RulesFromHistory } = await import("../../altimate/rules/from-history") + const sent = await RulesFromHistory.proposeStaged({ + root: await projectRoot(), directory: process.cwd(), learn: (await Config.get()).learn, name: args.name as string, origin: "import_reviews", + }) + if (sent.status === "sent") out(sent.message) + }) + // altimate_change end }), }) @@ -1060,6 +1120,7 @@ export const LearnCommand = cmd({ .command(DisableCommand) .command(NudgeCommand) .command(StatusCommand) + .command(SyncCommand) .command(BootstrapCommand) .command(ImportReviewsCommand) .command(ReflectCommand) diff --git a/packages/opencode/src/plugin/tui/altimate/index.ts b/packages/opencode/src/plugin/tui/altimate/index.ts index 62b465fd70..c42c47789f 100644 --- a/packages/opencode/src/plugin/tui/altimate/index.ts +++ b/packages/opencode/src/plugin/tui/altimate/index.ts @@ -18,6 +18,7 @@ import Workspace from "./workspace" import WorkspaceSidebar from "./workspace-sidebar" import LearnNudge from "./learn-nudge" import WorkspaceWelcome from "./workspace-welcome" +import Rules from "./rules" // Feature plugins are registered here as they are ported from the pre-merge sources on `main` // (see the ADR re-home plan). Each lives in its own file under this directory and default-exports @@ -35,6 +36,6 @@ export function altimateTuiPlugins(_flags: Pick }): Promise<{ data?: unknown; error?: unknown }> } +type ReplyResult = { ok: boolean; message?: string; error?: string; card?: ProposalCard } + +function raw(api: TuiPluginApi): Raw | undefined { + return (api.client as unknown as { client?: Raw }).client +} + +async function post(api: TuiPluginApi, url: string, body?: unknown): Promise { + const client = raw(api) + if (!client) return undefined + const result = await client.post({ url, body: body ?? {}, headers: { "Content-Type": "application/json" } }) + return (result.data ?? (result.error as T | undefined)) as T | undefined +} + +const on = (api: TuiPluginApi) => api.event.on as unknown as (type: string, handler: (event: { properties: any }) => void) => () => void +const root = (api: TuiPluginApi) => (api.state.path.worktree && api.state.path.worktree !== "/" ? api.state.path.worktree : api.state.path.directory) +const learnConfig = (api: TuiPluginApi) => (api.state.config as { learn?: { enabled?: boolean; capture?: boolean } }).learn + +function title(card: ProposalCard) { + if (card.choose) return "SAVE THIS?" + if (card.change === "edit") return "PROPOSED EDIT TO A TEAM RULE" + if (card.change === "retire") return "PROPOSED: RETIRE A TEAM RULE" + return card.workspace ? "PROPOSED TEAM RULE" : "PROPOSED RULE" +} + +function meta(card: ProposalCard) { + const parts = [card.applies_to.length ? `applies to ${card.applies_to.join(", ")}` : "applies to the whole repository"] + if (card.evidence.correction) parts.push(`from your correction ${describeAge(Date.parse(card.evidence.correction.at))}`) + if (card.evidence.sessions > 1) parts.push(`seen in ${card.evidence.sessions} sessions`) + parts.push(`${card.chars} chars`) + return parts.join(" · ") +} + +function options(card: ProposalCard): TuiDialogSelectOption[] { + const owner = card.workspace?.owner_name ?? "the workspace owner" + const ws = card.workspace?.name ?? "the workspace" + const all: Record> = { + send: { + title: card.choose ? "Team rule — send for review" : "Send for review", + value: "send", + description: `Goes to ${owner}'s queue in ${ws} → Rules. Nothing changes for the team until approved.`, + }, + approve: { + title: card.weakens_verification ? "Approve anyway (a)" : "Approve now (a)", + value: "approve", + description: `You own ${ws}: it reaches every teammate's sessions within a few minutes.`, + }, + edit: { title: "Edit wording (e)", value: "edit", description: "Change the text before deciding" }, + dismiss: card.choose + ? { title: "Skip (n)", value: "dismiss", description: "Don't save anything" } + : { title: "Not a rule (n)", value: "dismiss", description: "Won't propose this topic again for 30 days" }, + save_local: { title: "Save for this project", value: "save_local", description: "Not linked to a workspace: saved as project training, no review" }, + save_memory: { title: "Memory — just for you", value: "save_memory", description: "A fact or preference: saved now, no review, not a team rule" }, + } + // Enter on the first row sends: "Send for review" leads, then "Approve now" for the owner. + const order = ["save_memory", "send", "save_local", "approve", "edit", "dismiss"] + return [...card.actions].sort((a, b) => order.indexOf(a) - order.indexOf(b)).map((action) => all[action]) +} + +function CardView(props: { api: TuiPluginApi; card: ProposalCard; onAction: (action: string) => void }) { + const theme = () => props.api.theme.current + const card = props.card + const shortcut = (action: string, key: string, label: string) => + card.actions.includes(action as ProposalCard["actions"][number]) + ? [{ command: `rules.card.${action}`, title: label, onTrigger: () => props.onAction(action) }] + : [] + return ( + + + {title(card)} + + {card.replaces!.text} + + {card.text} + {meta(card)} + + {`Why: ${card.why}`} + + + {`Your correction: "${card.evidence.correction!.excerpt}"`} + + + {`⚠ ${card.weakens_verification} — the owner must "approve anyway"`} + + + props.onAction(option.value)} + actions={[...shortcut("approve", "a", "approve"), ...shortcut("edit", "e", "edit"), ...shortcut("dismiss", "n", card.choose ? "skip" : "not a rule")]} + bindings={[ + ...(card.actions.includes("approve") ? [{ key: "a", cmd: "rules.card.approve" }] : []), + ...(card.actions.includes("edit") ? [{ key: "e", cmd: "rules.card.edit" }] : []), + { key: "n", cmd: "rules.card.dismiss" }, + ]} + /> + + ) +} + +function installCards(api: TuiPluginApi) { + const queue: ProposalCard[] = [] + let showing: string | undefined + + const next = () => { + showing = undefined + const card = queue.shift() + if (card) show(card) + } + + const reply = async (card: ProposalCard, action: string, text?: string) => { + const result = await post(api, `/rule-proposal/${card.id}/reply`, { action, ...(text !== undefined ? { text } : {}) }) + if (!result) return api.ui.toast({ variant: "error", message: "Could not reach the session server.", duration: 6000 }) + if (!result.ok) { + api.ui.toast({ variant: "error", message: result.error ?? "Not done.", duration: 8000 }) + if (result.card) show(result.card) + return + } + if (action === "edit" && result.card) return show(result.card) + api.ui.toast({ variant: "success", message: result.message ?? "Done.", duration: 6000 }) + api.ui.dialog.clear() + next() + } + + const edit = (card: ProposalCard) => { + api.ui.dialog.replace(() => ( + One line, at most 140 characters.} + value={card.text} + onConfirm={(text) => void reply(card, "edit", text)} + onCancel={() => show(card)} + /> + )) + } + + const show = (card: ProposalCard) => { + showing = card.id + api.ui.dialog.replace( + () => ( + (action === "edit" ? edit(card) : void reply(card, action))} + /> + ), + () => { + if (showing === card.id) next() + }, + ) + api.ui.dialog.setSize("large") + } + + const offer = (card: ProposalCard) => { + if (showing === card.id || queue.some((c) => c.id === card.id)) return + if (showing || api.ui.dialog.open) queue.push(card) + else show(card) + } + + on(api)("rule.proposal.asked", (event) => offer(event.properties as ProposalCard)) + // Each review is shown once per project, whether it arrived as an event or was pulled before this TUI + // subscribed (the startup sync): remembered by id and status. + const seenKey = () => `altimate.rules.notices.${root(api)}` + const notify = (p: { public_id: string; text: string; status: string; reason?: string; reviewer?: string }) => { + const seen = new Set(api.kv.get(seenKey(), [])) + const key = `${p.public_id}:${p.status}` + if (seen.has(key)) return + api.kv.set(seenKey(), [...seen, key].slice(-200)) + reviewToast(api, p) + } + on(api)("rule.proposal.reviewed", (event) => notify(event.properties)) + const replay = () => void RulesSummary.summary(root(api), learnConfig(api)).then((s) => [...s.notices].reverse().forEach(notify)).catch(() => {}) + on(api)("altimate.rules.changed", replay) + setTimeout(replay, 4_000) +} + +function reviewToast(api: TuiPluginApi, p: { text: string; status: string; reason?: string; reviewer?: string }) { + const who = p.reviewer ? ` by ${p.reviewer}` : "" + const quoted = `"${p.text.length > 80 ? p.text.slice(0, 79) + "…" : p.text}"` + if (p.status === "active") api.ui.toast({ variant: "success", message: `Your rule was approved${who}: ${quoted}. It now applies to the team.`, duration: 10000 }) + else if (p.status === "rejected") api.ui.toast({ variant: "warning", message: `Your rule was rejected${who}: ${quoted}${p.reason ? ` — ${p.reason}` : ""}`, duration: 15000 }) + else if (p.status === "retired") api.ui.toast({ variant: "info", message: `Retired${who}: ${quoted}`, duration: 10000 }) +} + +async function showMenu(api: TuiPluginApi) { + const summary = await RulesSummary.summary(root(api), learnConfig(api)) + const rows = await RulesSummary.rows(root(api)) + const team = rows.filter((r) => r.source === "team") + const mine = rows.filter((r) => r.source === "mine") + const status = summary.linked + ? `${summary.workspace?.name ?? "Workspace"} · ${RulesSummary.sidebarLine(summary)}${summary.pulled_at ? ` · synced ${describeAge(Date.parse(summary.pulled_at))}` : ""}` + : "Not linked to a workspace — run `altimate-code link`" + const learning = `Learning from sessions: ${summary.learning ? "on" : "off"} (${summary.learning_source === "you" ? "your setting" : summary.learning_source === "workspace" ? "workspace setting" : "default"})` + const label: Record = { active: "approved", pending: "pending review", rejected: "rejected", queued: "queued", retired: "retired", superseded: "replaced" } + const options: TuiDialogSelectOption[] = [ + ...team.map((r) => ({ + title: `${r.pinned ? "📌 " : ""}${r.text}`, + value: `rule:${r.public_id}`, + description: r.applies_to.length ? `applies to ${r.applies_to.join(", ")}` : "whole repository", + category: `Team rules (${team.length})`, + })), + ...mine.map((r) => ({ + title: r.text, + value: `mine:${r.public_id}`, + description: `${label[r.status] ?? r.status}${r.reviewer ? ` by ${r.reviewer}` : ""}${r.reason ? ` — ${r.reason}` : ""}`, + category: `Your proposals (${mine.length})`, + })), + { title: "Sync now", value: "sync", description: summary.last_error ? `last sync failed: ${summary.last_error}` : "Pull approved rules and send queued proposals", category: "Actions" }, + { + title: summary.learning ? "Turn learning off for me" : "Turn learning on for me", + value: "toggle", + description: `${learning}. Your setting overrides the workspace's "Learn from sessions".`, + category: "Actions", + }, + ] + api.ui.dialog.replace(() => ( + { + if (option.value === "sync") { + api.ui.toast({ variant: "info", message: "Syncing rules…", duration: 3000 }) + const report = await post<{ ok: boolean; pull?: { ok: boolean; rules: number; pending: number; error?: string; notices: unknown[] }; push?: { sent: unknown[] }; context?: { status: string; reason?: string } }>(api, "/altimate/rules/sync") + // A review notice from this pull is its own toast; do not cover it with the summary. + if (report?.pull?.notices.length) return + if (report?.context && report.context.status !== "ready") + api.ui.toast({ variant: "warning", message: `Rules sync skipped: ${report.context.reason ?? report.context.status}.`, duration: 8000 }) + else if (report?.pull?.ok) + api.ui.toast({ variant: "success", message: `Synced: ${report.pull.rules} team ${report.pull.rules === 1 ? "rule" : "rules"}, ${report.pull.pending} pending review${report.push?.sent.length ? `, ${report.push.sent.length} sent` : ""}.`, duration: 6000 }) + else api.ui.toast({ variant: "error", message: `Rules sync failed: ${report?.pull?.error ?? "unknown error"}`, duration: 8000 }) + return showMenu(api) + } + if (option.value === "toggle") { + const file = await writeProjectLearning(root(api), api.state.path.directory, !summary.learning) + api.ui.toast({ variant: "success", message: `Learning ${summary.learning ? "off" : "on"} for you in this project (${file}). Applies from the next session.`, duration: 8000 }) + return + } + }} + /> + )) +} + +const tui: TuiPlugin = async (api) => { + installCards(api) + api.keymap.registerLayer({ + commands: [ + { + name: "altimate.rules", + title: "Rules", + desc: "Team rules, your proposed rules and their review status, sync", + category: "Altimate", + namespace: "palette", + slashName: "rules", + run() { + showMenu(api).catch((err) => api.ui.toast({ variant: "error", message: String(err), duration: 8000 })) + }, + }, + ], + }) +} + +const plugin: BuiltinTuiPlugin = { id, tui } + +export default plugin diff --git a/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx b/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx index 4ee9b859b3..b76a0e7fb1 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx @@ -21,6 +21,9 @@ import { accountScope, boundAttachSnapshot, sidebarAttachLine } from "@/altimate // altimate_change end import { AltimateApi } from "@/altimate/api/client" import { openManageUrl } from "./workspace" +// altimate_change start — workspace rules line +import { RulesSummary } from "@/altimate/rules/summary" +// altimate_change end const id = "altimate:sidebar-workspace" @@ -68,6 +71,14 @@ function View(props: { api: TuiPluginApi }) { const [manageUrl, setManageUrl] = createSignal(null) // altimate_change start - status lines const [detail, setDetail] = createSignal(null) + // altimate_change start — workspace rules: "6 team rules · 3 pending review", from local state + const [rules, setRules] = createSignal(null) + const rulesRoot = () => (props.api.state.path.worktree && props.api.state.path.worktree !== "/" ? props.api.state.path.worktree : props.api.state.path.directory) + const readRules = () => { + const learn = (props.api.state.config as { learn?: { enabled?: boolean; capture?: boolean } }).learn + void RulesSummary.summary(rulesRoot(), learn).then((s) => setRules(s.linked ? s : null)).catch(() => setRules(null)) + } + // altimate_change end // altimate_change end // altimate_change start - what the last session got, in numbers const [attachLine, setAttachLine] = createSignal(null) @@ -216,6 +227,7 @@ function View(props: { api: TuiPluginApi }) { return } setDetail(detail) + readRules() // altimate_change end } finally { refreshInFlight = false @@ -242,10 +254,15 @@ function View(props: { api: TuiPluginApi }) { // authoritative. The interval stays: it is what catches a change made by // another process, which no in-process listener can see. const unsubscribe = onBindingChanged(() => void refresh("notify")) + // altimate_change start — rule events from the session server refresh the rules line + const on = props.api.event.on as unknown as (type: string, handler: () => void) => () => void + const offRules = [on("altimate.rules.changed", readRules), on("rule.proposal.reviewed", readRules)] + // altimate_change end onCleanup(() => { disposed = true clearInterval(timer) unsubscribe() + for (const off of offRules) off() }) }) @@ -310,6 +327,14 @@ function View(props: { api: TuiPluginApi }) { {(at) => {`memory loaded ${describeAge(at())}`}} + + {(r) => ( + + {`rules: ${RulesSummary.sidebarLine(r())}`} + {r().pulled_at ? ` · synced ${describeAge(Date.parse(r().pulled_at!))}` : ""} + + )} + {/* altimate_change end */} {/* ``pinned via --workspace`` means "this SESSION was launched * with --workspace and it resolved to this id". It does NOT diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index 74a2da58fb..7b0005f3cf 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -146,6 +146,12 @@ const runBootstrap = Effect.gen(function* () { } } // altimate_change end + // altimate_change start — workspace rules: sync approved rules, gate capture on the workspace's + // "Learn from sessions", and propose rules from corrections (fail-safe, never throws) + yield* Effect.promise(() => + Instance.restore(ctx, () => import("../altimate/rules/service").then((m) => m.start(ctx))), + ).pipe(Effect.catchCause((cause) => Effect.logWarning("rules service startup failed", cause))) + // altimate_change end const projectID = ctx.project.id yield* Stream.runForEach(events.subscribe(Command.Event.Executed), (payload) => diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index a917f93d6a..c1abf6cb6e 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1257,6 +1257,68 @@ export namespace Server { } }) // altimate_change end + // altimate_change start — workspace rules: the proposal card and rules state for clients + // A client (the TUI now, the IDE extension later) renders `rule.proposal.asked` from `/event` and answers + // here. The `/altimate/rules/*` routes give the sidebar line, the rule list and "Sync now" for the request's + // directory. All rule logic stays in this server; clients only render. + .get("/rule-proposal", async (c) => { + const refused = workspaceRouteRefusal(c.req.header("origin"), c.req.header("host"), undefined, c.req.header("sec-fetch-site")) + if (refused) return c.json(refused.body, refused.status) + const { RuleProposal } = await import("../altimate/rules/proposal") + return c.json(RuleProposal.list(Instance.directory)) + }) + .post("/rule-proposal/:id/reply", async (c) => { + const refused = workspaceRouteRefusal(c.req.header("origin"), c.req.header("host"), undefined, c.req.header("sec-fetch-site")) + if (refused) return c.json(refused.body, refused.status) + const read = await readJsonObject(() => c.req.text(), "rule proposal reply") + if ("failure" in read) return c.json(read.failure.body, read.failure.status) + const { action, text } = read.body as { action?: unknown; text?: unknown } + const actions = ["send", "approve", "edit", "dismiss", "save_local", "save_memory"] as const + if (typeof action !== "string" || !actions.includes(action as (typeof actions)[number])) + return c.json({ ok: false, error: `action must be one of ${actions.join(", ")}.` }, 400) + if (text !== undefined && typeof text !== "string") return c.json({ ok: false, error: "text must be a string." }, 400) + try { + const { RuleProposal } = await import("../altimate/rules/proposal") + const result = await RuleProposal.reply(c.req.param("id"), { action: action as (typeof actions)[number], ...(text !== undefined ? { text } : {}) }) + return c.json(result, result.ok ? 200 : 409) + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + log.error("rule proposal reply failed", { error }) + return c.json({ ok: false, error }, 500) + } + }) + .get("/altimate/rules/summary", async (c) => { + const refused = workspaceRouteRefusal(c.req.header("origin"), c.req.header("host"), undefined, c.req.header("sec-fetch-site")) + if (refused) return c.json(refused.body, refused.status) + const { RulesSummary } = await import("../altimate/rules/summary") + const root = Instance.worktree !== "/" ? Instance.worktree : Instance.directory + const summary = await RulesSummary.summary(root, (await (await import("@/config/config")).Config.get()).learn) + return c.json({ ok: true as const, ...summary, line: RulesSummary.sidebarLine(summary) }) + }) + .get("/altimate/rules", async (c) => { + const refused = workspaceRouteRefusal(c.req.header("origin"), c.req.header("host"), undefined, c.req.header("sec-fetch-site")) + if (refused) return c.json(refused.body, refused.status) + const view = c.req.query("view") + if (view !== undefined && view !== "active" && view !== "mine") return c.json({ ok: false, error: "view must be active or mine." }, 400) + const { RulesSummary } = await import("../altimate/rules/summary") + const root = Instance.worktree !== "/" ? Instance.worktree : Instance.directory + return c.json({ ok: true as const, rules: await RulesSummary.rows(root, view ?? "all") }) + }) + .post("/altimate/rules/sync", async (c) => { + const refused = workspaceRouteRefusal(c.req.header("origin"), c.req.header("host"), undefined, c.req.header("sec-fetch-site")) + if (refused) return c.json(refused.body, refused.status) + try { + const { RulesService } = await import("../altimate/rules/service") + const root = Instance.worktree !== "/" ? Instance.worktree : Instance.directory + const report = await RulesService.syncNow(root, Instance.directory, (await (await import("@/config/config")).Config.get()).learn) + return c.json({ ok: true as const, ...report }) + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + log.error("rules sync failed", { error }) + return c.json({ ok: false, error }, 500) + } + }) + // altimate_change end // altimate_change start — GET /altimate/skill/publishable, POST /altimate/skill/publish // The CLI's `skill publish ` for the IDE extension. Only `serve` holds the extension's // pin, so publishing here targets the workspace selected in the panel — through the same diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f6e2c0c0f8..a0831ddffa 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -768,6 +768,10 @@ export namespace SessionPrompt { // An unused project pays only an existence check; do not load stores, migrate, // create state, or even schedule async learning work on that default-off path. const learnRoot = Instance.worktree !== "/" ? Instance.worktree : Instance.directory + // Workspace rules: a pull at session start, bounded, so the first turn delivers newly approved rules. + await import("../altimate/rules/sync") + .then((m) => m.sessionStart(learnRoot, Instance.directory, altCfg.learn)) + .catch((error) => log.warn("rules session sync failed", { error })) const lessonState = existsSync(path.join(learnRoot, ".altimate-code", "learn")) ? new LessonDelivery(learnRoot, altCfg.learn, Instance.directory) : undefined diff --git a/packages/opencode/test/altimate/rules/rules.test.ts b/packages/opencode/test/altimate/rules/rules.test.ts new file mode 100644 index 0000000000..178f1deb13 --- /dev/null +++ b/packages/opencode/test/altimate/rules/rules.test.ts @@ -0,0 +1,157 @@ +// altimate_change - new file +import { describe, expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { RuleClassify } from "../../../src/altimate/rules/classify" +import { RuleProposal } from "../../../src/altimate/rules/proposal" +import { RulesStore } from "../../../src/altimate/rules/store" +import { RulesSummary } from "../../../src/altimate/rules/summary" +import { appliesTo, learningEnabled } from "../../../src/altimate/rules/trigger" +import { parse } from "../../../src/altimate/learn/lesson" +import * as Store from "../../../src/altimate/learn/store" + +const tmp = () => fs.mkdtemp(path.join(os.tmpdir(), "rules-")) + +const rule = (text: string, extra: Partial = {}) => ({ + public_id: "11111111-1111-1111-1111-111111111111", + rule_key: "R-0123456789ab", + repo_identity: "https://github.com/acme/demo", + text, + applies_to: [], + tags: [], + pinned: false, + version: 1, + updated_at: "2026-10-27T00:00:00Z", + ...extra, +}) + +const linked = (settings: Partial = {}): RulesStore.State => + RulesStore.State.parse({ + version: 1, + scope: { apiUrl: "http://x", tenant: "t", account: "a", datamateId: 1, repoRemote: "https://github.com/acme/demo" }, + workspace: { name: "Team", owner_name: "Olive", is_owner: false }, + settings: { learning_enabled: true, include_quotes: true, share_across_repos: false, ...settings }, + }) + +describe("rule or memory", () => { + test.each([ + ["We always store money columns as integer cents", "rule"], + ["Never use FLOAT for money", "rule"], + ["Use cents_to_dollars instead of dividing by 100", "rule"], + ["Remember this as a team rule: skip nothing", "rule"], + ["I prefer CTEs over subqueries", "memory"], + ["Remember that our warehouse is Snowflake in us-east-1", "memory"], + ["ARR means annual recurring revenue", "memory"], + ["Staging models live in models/staging", "ambiguous"], + ])("%s → %s", (text, kind) => { + expect(RuleClassify.classify(text).kind).toBe(kind as RuleClassify.Kind) + }) + + test("a preference is personal memory, a fact belongs to the project", () => { + expect(RuleClassify.memoryScope("I prefer CTEs")).toBe("global") + expect(RuleClassify.memoryScope("Our warehouse is Snowflake")).toBe("project") + }) +}) + +describe("applies to", () => { + test("directories of mentioned paths, not slashes in prose", () => { + expect(appliesTo(["put it in models/staging/stg_x.sql", "see `macros/cents.sql`"])).toEqual(["models/staging/**", "macros/**"]) + expect(appliesTo(["never FLOAT/DOUBLE", "and/or", "https://example.com/a/b"])).toEqual([]) + }) +}) + +describe("learning from sessions (the person's setting, then the workspace's)", () => { + test("unlinked projects learn only when the person turned it on", () => { + const unlinked = RulesStore.State.parse({ version: 1 }) + expect(learningEnabled(undefined, unlinked, {})).toBe(false) + expect(learningEnabled({ capture: true }, unlinked, {})).toBe(true) + }) + + test("a linked project follows the workspace unless the person decided", () => { + expect(learningEnabled(undefined, linked(), {})).toBe(true) + expect(learningEnabled(undefined, linked({ learning_enabled: false }), {})).toBe(false) + expect(learningEnabled({ capture: false }, linked(), {})).toBe(false) + expect(learningEnabled({ capture: true }, linked({ learning_enabled: false }), {})).toBe(true) + }) + + test("environment overrides and the learning kill switch win", () => { + expect(learningEnabled(undefined, linked(), { ALTIMATE_LEARN_CAPTURE: "0" })).toBe(false) + expect(learningEnabled(undefined, linked({ learning_enabled: false }), { ALTIMATE_LEARN_CAPTURE: "1" })).toBe(true) + expect(learningEnabled({ enabled: false, capture: true }, linked(), {})).toBe(false) + expect(learningEnabled(undefined, linked(), { ALTIMATE_LEARN: "0" })).toBe(false) + }) +}) + +describe("team rule mirror", () => { + test("pulled rules become a read-only team store that delivery reads", async () => { + const root = await tmp() + await RulesStore.update(root, (state) => { + state.rules = [rule("Name staging models `stg___`.", { applies_to: ["models/staging/**"], pinned: true })] + }) + const lessons = parse(await fs.readFile(Store.paths(root, RulesStore.TEAM_STORE).approved, "utf8")) + expect(lessons).toEqual([ + expect.objectContaining({ id: "L-0123456789ab", pinned: true, trigger: { paths: ["models/staging/**"] } }), + ]) + expect(await fs.readFile(path.join(RulesStore.dir(root), ".gitignore"), "utf8")).toBe("*\n") + await RulesStore.reset(root) + expect(parse(await fs.readFile(Store.paths(root, RulesStore.TEAM_STORE).approved, "utf8"))).toEqual([]) + }) + + test("expired dismissals are dropped on write", async () => { + const root = await tmp() + const state = await RulesStore.update(root, (s) => { + s.dismissed = [{ topic: "old", until: "2000-01-01T00:00:00Z" }, { topic: "new", until: "2999-01-01T00:00:00Z" }] + }) + expect(state.dismissed.map((d) => d.topic)).toEqual(["new"]) + expect(RulesStore.dismissed(state, "NEW")).toBe(true) + }) +}) + +describe("summary", () => { + test("sidebar line leaves out zero counts after the first", () => { + expect(RulesSummary.sidebarLine({ team_rules: 1, pending_review: 0, queued: 0 })).toBe("1 team rule") + expect(RulesSummary.sidebarLine({ team_rules: 6, pending_review: 3, queued: 1 })).toBe("6 team rules · 3 pending review · 1 queued") + }) + + test("reports who decided learning", async () => { + const root = await tmp() + await RulesStore.update(root, (s) => Object.assign(s, linked())) + expect((await RulesSummary.summary(root, undefined, {})).learning_source).toBe("workspace") + expect((await RulesSummary.summary(root, { capture: false }, {})).learning_source).toBe("you") + }) +}) + +describe("proposal card", () => { + const draft = { text: "Name timestamp columns `_at`, in UTC.", applies_to: [], change: "add" as const, origin: "session" as const, evidence: { sessions: 2 }, topics: ["ordered_at"] } + + test("actions follow the project: member, owner, unlinked, and the memory choice", async () => { + const root = await tmp() + const ask = (d = draft) => RuleProposal.ask({ sessionID: "s1", root, directory: root, learn: undefined, draft: { ...d, text: `${d.text} ${Math.random()}`.slice(0, 120) } }) + expect((await ask())!.actions).toEqual(["save_local", "edit", "dismiss"]) + await RulesStore.update(root, (s) => Object.assign(s, linked())) + expect((await ask())!.actions).toEqual(["send", "edit", "dismiss"]) + await RulesStore.update(root, (s) => { s.workspace = { name: "Team", owner_name: "Olive", is_owner: true } }) + expect((await ask())!.actions).toEqual(["approve", "send", "edit", "dismiss"]) + expect((await ask({ ...draft, choose: true } as typeof draft))!.actions).toEqual(["save_memory", "approve", "send", "dismiss"]) + }) + + test("an edit is linted like any rule and flags verification bypasses", async () => { + const root = await tmp() + const card = (await RuleProposal.ask({ sessionID: "s2", root, directory: root, learn: undefined, draft }))! + const bad = await RuleProposal.reply(card.id, { action: "edit", text: "line one\nline two" }) + expect(bad).toMatchObject({ ok: false }) + const flagged = await RuleProposal.reply(card.id, { action: "edit", text: "Skip dbt tests on staging models." }) + expect(flagged).toMatchObject({ ok: true, closed: false }) + expect(flagged.ok && flagged.card?.weakens_verification).toBe("mentions skipping or disabling verification") + }) + + test("Not a rule suppresses the topic for 30 days", async () => { + const root = await tmp() + const card = (await RuleProposal.ask({ sessionID: "s3", root, directory: root, learn: undefined, draft }))! + expect(await RuleProposal.reply(card.id, { action: "dismiss" })).toMatchObject({ ok: true, closed: true }) + expect(RulesStore.dismissed(await RulesStore.read(root), "ordered_at")).toBe(true) + expect(await RuleProposal.ask({ sessionID: "s3", root, directory: root, learn: undefined, draft: { ...draft, text: "Another wording." } })).toBeUndefined() + expect(await RuleProposal.reply(card.id, { action: "send" })).toMatchObject({ ok: false }) + }) +}) From 51047e7cb36a4f5738514b90097bf97ca4614309 Mon Sep 17 00:00:00 2001 From: Sarav Date: Wed, 28 Oct 2026 12:39:34 +0530 Subject: [PATCH 02/20] =?UTF-8?q?fix:=20workspace=20rules=20=E2=80=94=20ad?= =?UTF-8?q?dress=20review=20findings=20on=20sync,=20proposals,=20capture?= =?UTF-8?q?=20and=20the=20TUI=20card=20queue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync (`rules/sync.ts`): - Check the saved scope before `push`: an outbox queued for another account, workspace or remote is dropped, never sent with the new credentials (`adopt`). - A batch the server refuses is retried one item at a time; a refused item leaves the outbox with its error, so it cannot block later proposals. Transient failures and items with no result stay queued. - Usage: a rule reports at most 50 sessions per run and the rest wait for the next run; the "corrected again" baseline follows the recurrence window down and only advances by what was sent. - Key in-flight runs by the real path, so the service and the session loop share one run. `runFresh` waits out an in-flight run before sending a newly queued proposal. - A failed or skipped run is not waited on again by session start within `REFRESH_MS`. Proposals (`rules/proposal.ts`, `server.ts`): - Honor the workspace's `include_quotes`: with it off, correction excerpts are not uploaded. - Keep a card open until its action succeeds; a second reply while one runs is refused. - `POST /rule-proposal/:id/reply` answers only cards of the request's project. - Memory and training names: a readable prefix plus a text hash; no trailing hyphen, no collisions. - Re-publish an already open identical card so the person actually sees it; "Dismissed" wording for cards without a topic. Store (`rules/store.ts`): - Only a missing `rules.json` reads as empty; other read errors propagate, and a file this version cannot parse is kept as `rules.json.unreadable-*` before a write. - Create the directory and `.gitignore` under the learn lock with `SafeFS`, and rewrite a team `.gitignore` that is not `*`. - Cap only settled sent proposals, never pending ones. Post-turn check (`rules/trigger.ts`): - Mark corrections processed only after the check completes; a failed check is retried. Processed ids are pruned to signals still on disk. - A HELPFUL verdict on the shown team rule ends the check (no second drafting call). The recurrence path only takes an ADD. Dismissed edit topics are skipped before drafting. Service and capture (`rules/service.ts`, `learn/capture.ts`): - Read the learn config (with the project file as on disk) on each refresh and check, so `learn disable` takes effect without a restart. - `startCapture` returns a stop function; capture the service started stops when learning turns off, retries after a failed start, and is not started after disposal. Elsewhere: - `session/prompt.ts`: only a project with rules state awaits the session-start pull. - `learn sync` / `bootstrap` / `import-reviews` use the instance directory, not `process.cwd()`. Bootstrap and import send only the lessons this run staged, and keep a candidate that existed before; the epilogs describe linked projects. - TUI card queue: programmatic dialog changes no longer advance the queue, a reply acts only if its card is still showing, open proposals load at startup, edits refresh queued copies, and a card queued behind another dialog shows when it closes. Tests: outbox retry and scope drop against a local stand-in server, `include_quotes`, failed actions keeping the card, cross-project replies, slugs, unreadable state, `.gitignore`, and the sent cap. Co-Authored-By: Claude Opus 5.5 --- .../opencode/src/altimate/learn/capture.ts | 20 ++- .../src/altimate/rules/from-history.ts | 33 +++- .../opencode/src/altimate/rules/proposal.ts | 65 +++++-- .../opencode/src/altimate/rules/service.ts | 64 +++++-- packages/opencode/src/altimate/rules/store.ts | 57 ++++-- packages/opencode/src/altimate/rules/sync.ts | 168 ++++++++++++------ .../opencode/src/altimate/rules/trigger.ts | 38 ++-- packages/opencode/src/cli/cmd/learn.ts | 25 ++- .../src/plugin/tui/altimate/rules.tsx | 54 +++++- packages/opencode/src/server/server.ts | 6 +- packages/opencode/src/session/prompt.ts | 8 +- .../test/altimate/rules/rules.test.ts | 139 ++++++++++++++- 12 files changed, 542 insertions(+), 135 deletions(-) diff --git a/packages/opencode/src/altimate/learn/capture.ts b/packages/opencode/src/altimate/learn/capture.ts index 971f0b5b1c..e9c568bbc3 100644 --- a/packages/opencode/src/altimate/learn/capture.ts +++ b/packages/opencode/src/altimate/learn/capture.ts @@ -272,7 +272,12 @@ async function subscribeWhenReady(subscribe: () => T): Promise { * Starts capture for one instance when enabled. Called once from instance bootstrap inside the instance * context. Never throws. */ -export async function startCapture(ctx: { directory: string; worktree: string }, options: { force?: boolean } = {}): Promise { +// altimate_change start — returns a stop function once capture runs (workspace rules stop it when learning turns off) +export async function startCapture( + ctx: { directory: string; worktree: string }, + options: { force?: boolean } = {}, +): Promise<(() => Promise) | undefined> { + // altimate_change end try { const root = ctx.worktree !== "/" ? ctx.worktree : ctx.directory const { Config } = await import("@/config/config") @@ -297,14 +302,21 @@ export async function startCapture(ctx: { directory: string; worktree: string }, }) active.add(capture) const subscriptions: Array<() => void> = [] - const unregister = registerDisposer(async (directory) => { - if (directory !== ctx.directory) return + // altimate_change start — one stop path for disposal and for workspace rules + let stopped = false + const stopCapture = async () => { + if (stopped) return + stopped = true for (const stop of subscriptions) stop() await capture.flush() await flushWrites() active.delete(capture) unregister() + } + const unregister = registerDisposer(async (directory) => { + if (directory === ctx.directory) await stopCapture() }) + // altimate_change end subscriptions.push( await subscribeWhenReady(() => Bus.subscribe(MessageV2.Event.Updated, (evt) => capture.onMessage(evt.properties.info))), ) @@ -314,7 +326,9 @@ export async function startCapture(ctx: { directory: string; worktree: string }, ), ) log.info("learn capture started", { root }) + return stopCapture } catch (e) { log.warn("learn capture not started", { error: e instanceof Error ? e.message : String(e) }) + return undefined } } diff --git a/packages/opencode/src/altimate/rules/from-history.ts b/packages/opencode/src/altimate/rules/from-history.ts index f8e143e7d1..b172f386fc 100644 --- a/packages/opencode/src/altimate/rules/from-history.ts +++ b/packages/opencode/src/altimate/rules/from-history.ts @@ -1,18 +1,28 @@ // altimate_change - new file // // `learn bootstrap` and `learn import-reviews` in a linked project: the lessons they staged from history become -// proposals in the owner's review queue (origin `bootstrap` / `import_reviews`), sent without a card per rule, -// and the local candidate is discarded. One summary line reports how many were sent. +// proposals in the owner's review queue (origin `bootstrap` / `import_reviews`), sent without a card per rule. +// Only what this run staged is sent: the caller snapshots the candidate first (`candidateTexts`). A candidate +// that this run created is discarded; one that existed before is kept for `learn show` / `learn promote`. +// One summary line reports how many were sent. import * as Store from "../learn/store" import { RulesStore } from "./store" import { RulesSync } from "./sync" +/** The candidate's lesson texts before a run, or undefined when there is no candidate. */ +export async function candidateTexts(root: string, name: string): Promise | undefined> { + const candidate = await Store.loadCandidateLessons(root, name) + return candidate ? new Set(candidate.map((lesson) => lesson.text)) : undefined +} + export async function proposeStaged(input: { root: string directory: string learn: Parameters[2] name: string origin: "bootstrap" | "import_reviews" + /** `candidateTexts` before the run. */ + before: Set | undefined }): Promise<{ status: "not_linked" } | { status: "none" } | { status: "sent"; proposed: number; sent: number; queued: number; message: string }> { const state = await RulesStore.read(input.root) if (!state.scope) return { status: "not_linked" } @@ -21,9 +31,13 @@ export async function proposeStaged(input: { Store.loadCandidateLessons(input.root, input.name), ]) if (!candidate) return { status: "none" } - const before = new Set(approved.map((lesson) => lesson.text)) - const added = candidate.filter((lesson) => !before.has(lesson.text)) - if (!added.length) return { status: "none" } + const known = new Set([...approved.map((lesson) => lesson.text), ...(input.before ?? [])]) + const added = candidate.filter((lesson) => !known.has(lesson.text)) + if (!added.length) { + // Nothing new beyond the approved lessons: a candidate this run created only duplicates them. + if (!input.before) await Store.reject(input.root, input.name) + return { status: "none" } + } for (const lesson of added) await RulesSync.enqueue(input.root, { change: "add", @@ -34,9 +48,9 @@ export async function proposeStaged(input: { evidence_sessions: 1, corrections: [], }) - // The workspace's review queue replaces the local candidate. - await Store.reject(input.root, input.name) - const report = await RulesSync.run(input.root, input.directory, input.learn) + // The workspace's review queue replaces a candidate this run created. + if (!input.before) await Store.reject(input.root, input.name) + const report = await RulesSync.runFresh(input.root, input.directory, input.learn) const sent = report.push?.sent.filter((s) => s.status === "pending").length ?? 0 const queued = (await RulesStore.read(input.root)).outbox.length const owner = state.workspace?.owner_name ?? "the workspace owner" @@ -46,7 +60,8 @@ export async function proposeStaged(input: { sent, queued, message: `${sent} ${sent === 1 ? "rule" : "rules"} sent for ${owner}'s review in ${state.workspace?.name ?? "the workspace"}` + - (queued ? `; ${queued} queued until the workspace is reachable` : "") + ".", + (queued ? `; ${queued} queued until the workspace is reachable` : "") + "." + + (input.before ? " Your earlier local candidate was kept: `learn show`." : ""), } } diff --git a/packages/opencode/src/altimate/rules/proposal.ts b/packages/opencode/src/altimate/rules/proposal.ts index 9179067938..1df327ad59 100644 --- a/packages/opencode/src/altimate/rules/proposal.ts +++ b/packages/opencode/src/altimate/rules/proposal.ts @@ -38,6 +38,8 @@ interface Pending { root: string directory: string learn: { enabled?: boolean } | undefined + /** A reply is being carried out: a second one is refused. */ + busy?: boolean } // Process-global like `question`'s registry: the server routes and the session loop may load this module @@ -85,7 +87,11 @@ export async function ask(input: { sessionID: string; root: string; directory: s const state = await RulesStore.read(input.root) if (input.draft.origin === "session" && input.draft.topics.some((topic) => RulesStore.dismissed(state, topic))) return undefined for (const existing of pending.values()) - if (existing.root === input.root && existing.card.text.toLowerCase() === input.draft.text.toLowerCase()) return existing.card + if (existing.root === input.root && existing.card.text.toLowerCase() === input.draft.text.toLowerCase()) { + // Shown again: a client that missed it (or closed it) gets it back; one already showing it ignores it. + await publish(RuleEvents.Event.Asked, existing.card) + return existing.card + } const card = build(randomUUID(), input.sessionID, input.draft, state) pending.set(card.id, { card, root: input.root, directory: input.directory, learn: input.learn }) await publish(RuleEvents.Event.Asked, card) @@ -108,9 +114,20 @@ function editProblem(text: string): string | undefined { return normalizeText(text).trim() !== text ? "contains hidden or non-normalized characters" : undefined } -export async function reply(id: string, input: { action: ReplyAction; text?: string }): Promise { +/** A memory or training name for a rule: a readable prefix and a hash of the whole text, so two rules that + * start alike do not overwrite each other. */ +export function slug(text: string): string { + const prefix = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 40).replace(/^-+|-+$/g, "") + const hash = new Bun.CryptoHasher("sha256").update(text).digest("hex").slice(0, 8) + return prefix ? `${prefix}-${hash}` : hash +} + +/** Answer card `id`. With `directory`, only a card of that project is answered. The card stays open until the + * chosen action succeeds. */ +export async function reply(id: string, input: { action: ReplyAction; text?: string }, directory?: string): Promise { const entry = pending.get(id) - if (!entry) return { ok: false, error: "This proposal is no longer open." } + if (!entry || (directory !== undefined && entry.directory !== directory)) return { ok: false, error: "This proposal is no longer open." } + if (entry.busy) return { ok: false, error: "This proposal is already being answered.", card: entry.card } const { card } = entry if (!card.actions.includes(input.action)) return { ok: false, error: `"${input.action}" is not available for this proposal.`, card } @@ -126,28 +143,46 @@ export async function reply(id: string, input: { action: ReplyAction; text?: str return { ok: true, message: "Wording updated.", card: next, closed: false } } + entry.busy = true + let message: string + try { + message = await act(entry, input.action) + } catch (error) { + log.warn("rule proposal reply failed", { action: input.action, error }) + return { ok: false, error: `Not done: ${error instanceof Error ? error.message : String(error)}`, card } + } finally { + entry.busy = false + } pending.delete(id) + await publish(RuleEvents.Event.Replied, { id, sessionID: card.sessionID, action: input.action, message }) + await publish(RuleEvents.Event.Changed, { directory: entry.directory }) + return { ok: true, message, closed: true } +} + +async function act(entry: Pending, action: Exclude): Promise { + const { card } = entry let message: string - if (input.action === "dismiss") { + if (action === "dismiss") { const until = new Date(Date.now() + RulesStore.DISMISS_MS).toISOString() await RulesStore.update(entry.root, (state) => { for (const topic of card.topics) state.dismissed.push({ topic, until }) }) - message = "Not a rule: it won't be proposed again for 30 days." - } else if (input.action === "save_memory") { + message = card.topics.length ? "Not a rule: it won't be proposed again for 30 days." : "Dismissed: nothing was saved." + } else if (action === "save_memory") { const [{ MemoryStore }, { RuleClassify }] = await Promise.all([import("../../memory/store"), import("./classify")]) const scope = RuleClassify.memoryScope(card.text) - const id = `notes/${card.text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "note"}` + const id = `notes/${slug(card.text)}` const now = new Date().toISOString() await MemoryStore.write({ id, scope, tags: ["remembered"], created: now, updated: now, content: card.text }) message = scope === "global" ? "Saved to your memory (all your projects)." : "Saved to this project's memory." - } else if (input.action === "save_local") { + } else if (action === "save_local") { const { TrainingStore } = await import("../training") - const name = card.text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "rule" - await TrainingStore.save({ kind: "rule", name, scope: "project", content: card.text, source: "proposed rule" }) + await TrainingStore.save({ kind: "rule", name: slug(card.text), scope: "project", content: card.text, source: "proposed rule" }) message = "Saved as a rule for this project." } else { - const approve = input.action === "approve" ? (card.weakens_verification ? "approve_anyway" : "approve") : undefined + const approve = action === "approve" ? (card.weakens_verification ? "approve_anyway" : "approve") : undefined + // The workspace decides whether the person's words travel with a proposal. + const quotes = (await RulesStore.read(entry.root)).settings?.include_quotes !== false await RulesSync.enqueue(entry.root, { change: card.change, text: card.text, @@ -156,11 +191,11 @@ export async function reply(id: string, input: { action: ReplyAction; text?: str origin: card.origin, ...(card.replaces ? { replaces: card.replaces.public_id, replaces_version: card.replaces.version } : {}), evidence_sessions: card.evidence.sessions, - corrections: card.evidence.correction + corrections: card.evidence.correction && quotes ? [{ excerpt: card.evidence.correction.excerpt, corrected_at: card.evidence.correction.at, session_ref: hashRef(card.sessionID) }] : [], }, approve) - const report = await RulesSync.runShared(entry.root, entry.directory, entry.learn).catch(() => undefined) + const report = await RulesSync.runFresh(entry.root, entry.directory, entry.learn).catch(() => undefined) const sent = report?.push?.sent.find((s) => s.text === card.text) const owner = card.workspace?.owner_name ?? "the workspace owner" if (!sent) message = `Queued: it will be sent to ${owner}'s review queue when the workspace is reachable.` @@ -169,9 +204,7 @@ export async function reply(id: string, input: { action: ReplyAction; text?: str else if (sent.approved) message = `Approved: now a team rule in ${card.workspace?.name ?? "the workspace"}.` else message = `Sent to ${owner}'s review queue in ${card.workspace?.name ?? "the workspace"}.` } - await publish(RuleEvents.Event.Replied, { id, sessionID: card.sessionID, action: input.action, message }) - await publish(RuleEvents.Event.Changed, { directory: entry.directory }) - return { ok: true, message, closed: true } + return message } /** A session reference the server can count without learning the session id. */ diff --git a/packages/opencode/src/altimate/rules/service.ts b/packages/opencode/src/altimate/rules/service.ts index 122ee18e36..4f2fe0f393 100644 --- a/packages/opencode/src/altimate/rules/service.ts +++ b/packages/opencode/src/altimate/rules/service.ts @@ -4,7 +4,9 @@ // // - Sync in the background at start, then every `REFRESH_MS` (approved rules reach sessions within minutes). // - Capture corrections when learning is on for this project: the person's own setting, else the -// workspace's "Learn from sessions" (see `learningEnabled`). Capture enabled in config starts on its own. +// workspace's "Learn from sessions" (see `learningEnabled`). Capture enabled in config starts on its own; +// capture started here stops when learning turns off (setting changed, or the project unlinked). +// - The learn config is read on each refresh and each check, so `learn disable` takes effect without a restart. // - After each turn (session idle), check the session's new corrections (`RuleTrigger.afterTurn`). // - Publish review notices from a pull as `rule.proposal.reviewed`. import fs from "node:fs/promises" @@ -21,9 +23,16 @@ import { RuleTrigger } from "./trigger" const log = Log.create({ service: "rules.service" }) -async function learnConfig(ctx: InstanceContext) { - const { Config } = await import("@/config/config") - return Config.peek(ctx)?.learn as ({ enabled?: boolean; capture?: boolean; model?: string } | undefined) +type LearnConfig = { enabled?: boolean; capture?: boolean; model?: string } | undefined + +/** The loaded config's `learn`, with the project file's `learn` as it is on disk now (`learn enable/disable` + * and `/rules` write it while the session runs). */ +async function learnConfig(ctx: InstanceContext): Promise { + const [{ Config }, { readProjectLearning }] = await Promise.all([import("@/config/config"), import("../learn/project-config")]) + const loaded = Config.peek(ctx)?.learn as LearnConfig + const root = ctx.worktree !== "/" ? ctx.worktree : ctx.directory + const onDisk = (await readProjectLearning(root, ctx.directory).catch(() => undefined)) as LearnConfig + return loaded || onDisk ? { ...loaded, ...onDisk } : undefined } function deps(ctx: InstanceContext, model?: string): RuleTrigger.TriggerDeps { @@ -66,29 +75,52 @@ export async function syncNow(root: string, directory: string, learn: Parameters export async function start(ctx: InstanceContext): Promise { try { - const learn = await learnConfig(ctx) - if (!RulesSync.rulesEnabled(learn)) return + if (!RulesSync.rulesEnabled(await learnConfig(ctx))) return const root = await fs.realpath(ctx.worktree !== "/" ? ctx.worktree : ctx.directory) const { Instance } = await import("@/project/instance") - let captureStarted = captureEnabled(learn) - const maybeCapture = async () => { - if (captureStarted) return - if (!RuleTrigger.learningEnabled(learn, await RulesStore.read(root))) return - captureStarted = true - await import("../learn/capture").then((m) => m.startCapture(ctx, { force: true })) + let disposed = false + // Capture this service started because the workspace turned learning on. Capture the person enabled in + // config is started (and owned) by bootstrap. + let stopCapture: (() => Promise) | undefined + let starting = false + // Follow the learning setting each time: the person's config can change, and a sync can change the + // workspace's setting or unlink the project. + const followLearning = async (learn: LearnConfig) => { + if (disposed || starting || captureEnabled(learn)) return + const on = RuleTrigger.learningEnabled(learn, await RulesStore.read(root)) + if (on && !stopCapture) { + starting = true + try { + const stop = await import("../learn/capture").then((m) => m.startCapture(ctx, { force: true })) + if (disposed) await stop?.() + else stopCapture = stop + } finally { + starting = false + } + } else if (!on && stopCapture) { + const stop = stopCapture + stopCapture = undefined + await stop() + } } const refresh = Instance.bind(async () => { + if (disposed) return + const learn = await learnConfig(ctx) + if (!RulesSync.rulesEnabled(learn)) return void (await followLearning(learn)) const report = await syncNow(root, ctx.directory, learn).catch((error) => { log.warn("rules sync failed", { error: error instanceof Error ? error.message : String(error) }) return undefined }) - if (report) await maybeCapture() + if (report) await followLearning(learn) }) - await maybeCapture() + await followLearning(await learnConfig(ctx)) void refresh() const timer = setInterval(() => void refresh(), RulesSync.REFRESH_MS) timer.unref?.() const onIdle = Instance.bind(async (sessionID: string) => { + if (disposed) return + const learn = await learnConfig(ctx) + if (!RulesSync.rulesEnabled(learn)) return const { flushCapture } = await import("../learn/capture") await flushCapture() await RuleTrigger.afterTurn({ sessionID, root, directory: ctx.directory, learn, deps: deps(ctx, learn?.model) }) @@ -98,9 +130,13 @@ export async function start(ctx: InstanceContext): Promise { }) const unregister = registerDisposer(async (directory) => { if (directory !== ctx.directory) return + disposed = true clearInterval(timer) stop() unregister() + // The capture's own disposer stops it too; this covers a stop handle taken before disposal. + await stopCapture?.() + stopCapture = undefined }) log.info("rules service started", { root }) } catch (error) { diff --git a/packages/opencode/src/altimate/rules/store.ts b/packages/opencode/src/altimate/rules/store.ts index 44b5027eab..db77c2095a 100644 --- a/packages/opencode/src/altimate/rules/store.ts +++ b/packages/opencode/src/altimate/rules/store.ts @@ -11,9 +11,11 @@ // proposals and review notices, dismissed topics, and what usage has been reported. // // Writes take the learn lock (delivery reads the same directory) and are atomic. +import { constants } from "node:fs" import fs from "node:fs/promises" import path from "node:path" import z from "zod" +import * as SafeFS from "../learn/safe-fs" import { canonical, type Lesson } from "../learn/lesson" import { withLearnLock } from "../learn/lock" import * as Store from "../learn/store" @@ -24,6 +26,8 @@ export const TEAM_STORE = "team" const LOCK = { timeoutMs: 5_000 } const MAX_NOTICES = 50 const MAX_SENT = 200 +/** Safety cap for the session and signal id lists; `processed_signals` is also pruned to live signals. */ +const MAX_TRACKED = 5000 /** "Not a rule" keeps a topic from being proposed again in this repository for this long. */ export const DISMISS_MS = 30 * 24 * 60 * 60_000 @@ -118,16 +122,28 @@ export function dir(root: string) { } const stateFile = (root: string) => path.join(dir(root), "rules.json") -export async function read(root: string): Promise { - const raw = await fs.readFile(stateFile(root), "utf8").catch(() => undefined) +/** The state file's text, or undefined when there is none. Other read errors propagate. */ +async function readRaw(root: string): Promise { + return fs.readFile(stateFile(root), "utf8").catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return undefined + throw error + }) +} + +function parse(raw: string | undefined): State | undefined { if (!raw) return structuredClone(EMPTY) try { return State.parse(JSON.parse(raw)) } catch { - return structuredClone(EMPTY) + return undefined } } +/** The client state. A file this version cannot parse reads as empty; `update` sets it aside before writing. */ +export async function read(root: string): Promise { + return parse(await readRaw(root)) ?? structuredClone(EMPTY) +} + /** The lesson id a team rule is delivered and attributed under. */ export const lessonId = (ruleKey: string) => `L-${ruleKey.slice(2)}` @@ -148,23 +164,42 @@ export function toLesson(rule: RuleOut): Lesson { } } +const IGNORE = "*\n" + +/** Under the learn lock. The parent learn ignore shares each store's `approved.json`; this one keeps the team + * mirror and the client state (per person and per account) out of Git, whatever an older file said. */ async function ensureDir(root: string) { - await fs.mkdir(dir(root), { recursive: true }) + await SafeFS.mkdir(root, dir(root)) const ignore = path.join(dir(root), ".gitignore") - // The mirror and the client state are per person and per account: never commit them. - await fs.writeFile(ignore, "*\n", { flag: "wx" }).catch(() => {}) + const current = await SafeFS.open(root, ignore, constants.O_RDONLY) + .then(async (handle) => { try { return await handle.readFile("utf8") } finally { await handle.close() } }) + .catch(() => undefined) + if (current !== IGNORE) await Store.writeAtomic(root, ignore, IGNORE) +} + +/** Keep every pending proposal; cap only the ones the owner already settled. */ +function capSent(sent: Sent[]): Sent[] { + const settled = sent.filter((s) => s.status !== "pending") + const drop = new Set(settled.slice(0, Math.max(0, settled.length - MAX_SENT))) + return sent.filter((s) => !drop.has(s)) } /** Read-modify-write under the learn lock. `change` may return a new state or mutate the one given. */ export async function update(root: string, change: (state: State) => State | void): Promise { - await ensureDir(root) return withLearnLock(root, async () => { - const current = await read(root) + await ensureDir(root) + const raw = await readRaw(root) + let current = parse(raw) + if (!current) { + // Written by another version or damaged: keep it for inspection rather than overwrite it. + await Store.writeAtomic(root, `${stateFile(root)}.unreadable-${Date.now()}`, raw!, 0o600) + current = structuredClone(EMPTY) + } const next = change(current) ?? current next.notices = next.notices.slice(-MAX_NOTICES) - next.sent = next.sent.slice(-MAX_SENT) - next.usage_sessions = next.usage_sessions.slice(-1000) - next.processed_signals = next.processed_signals.slice(-1000) + next.sent = capSent(next.sent) + next.usage_sessions = next.usage_sessions.slice(-MAX_TRACKED) + next.processed_signals = next.processed_signals.slice(-MAX_TRACKED) const now = Date.now() next.dismissed = next.dismissed.filter((d) => Date.parse(d.until) > now) await Store.writeAtomic(root, stateFile(root), JSON.stringify(next, null, 2) + "\n") diff --git a/packages/opencode/src/altimate/rules/sync.ts b/packages/opencode/src/altimate/rules/sync.ts index a14cc6a7c6..139dd54203 100644 --- a/packages/opencode/src/altimate/rules/sync.ts +++ b/packages/opencode/src/altimate/rules/sync.ts @@ -13,10 +13,11 @@ // Timing: a pull at session start (bounded wait), then every `REFRESH_MS` in the background, plus // "Sync now". Never throws to an automatic caller: failures are recorded and reported. import { randomUUID } from "node:crypto" +import fs from "node:fs/promises" import { Log } from "@/util/log" import { learnEnabled } from "../learn/config" import type { ActAs } from "../workspace/api-client" -import { RulesApi, RulesApiError, type ProposalItem } from "./api" +import { RulesApi, RulesApiError, type ProposalItem, type ProposalResult } from "./api" import { RulesStore, type Scope, type State } from "./store" const log = Log.create({ service: "rules.sync" }) @@ -73,10 +74,19 @@ export interface PullResult { const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error)) +/** State pulled for another account, workspace or remote is dropped, outbox included: it never reaches this scope. */ +export async function adopt(root: string, ctx: Ready): Promise<{ state: State; same: boolean }> { + const state = await RulesStore.read(root) + const same = RulesStore.sameScope(state.scope, ctx.scope) + if (state.scope && !same) { + await RulesStore.reset(root) + return { state: await RulesStore.read(root), same } + } + return { state, same } +} + export async function pull(root: string, ctx: Ready): Promise { - const before = await RulesStore.read(root) - const same = RulesStore.sameScope(before.scope, ctx.scope) - if (before.scope && !same) await RulesStore.reset(root) + const { state: before, same } = await adopt(root, ctx) try { const response = await RulesApi.sync(ctx.actAs, ctx.scope.datamateId, { repo_remote: ctx.scope.repoRemote, @@ -129,31 +139,56 @@ export interface PushResult { error?: string } -/** Send the outbox. An item is dropped only once the server answered it; a transient failure keeps it. */ -export async function push(root: string, ctx: Ready): Promise { - const state = await RulesStore.read(root) - const batch = state.outbox.slice(0, MAX_PROPOSALS) - if (!batch.length) return { sent: [], queued: 0 } - let results +type OutboxEntry = State["outbox"][number] +type Outcome = { entry: OutboxEntry; result?: ProposalResult; error?: RulesApiError } + +/** Send `entries`. A batch the server refused outright is retried one item at a time, so one bad item + * cannot hold back the rest. */ +async function send(ctx: Ready, entries: OutboxEntry[]): Promise { try { - results = await RulesApi.propose(ctx.actAs, ctx.scope.datamateId, { + const results = await RulesApi.propose(ctx.actAs, ctx.scope.datamateId, { repo_remote: ctx.scope.repoRemote, - items: batch.map((entry) => entry.item as ProposalItem), + items: entries.map((entry) => entry.item as ProposalItem), }) + const byIndex = new Map(results.map((result) => [result.index, result])) + return entries.map((entry, index) => ({ entry, result: byIndex.get(index) })) } catch (error) { - await RulesStore.update(root, (s) => { - for (const entry of s.outbox) if (batch.some((b) => b.id === entry.id)) { - entry.attempts += 1 - entry.last_error = errorText(error) - } - }) - return { sent: [], queued: state.outbox.length, error: errorText(error) } + const failure = error instanceof RulesApiError ? error : RulesApi.classify(error) + if (failure.kind === "rejected" && entries.length > 1) { + const outcomes: Outcome[] = [] + for (const entry of entries) outcomes.push(...(await send(ctx, [entry]))) + return outcomes + } + return entries.map((entry) => ({ entry, error: failure })) } +} + +/** + * Send the outbox. An item leaves it once the server answered for it, or refused it (`rejected`, reported + * as an error); a transient failure, or no answer for that item, keeps it queued. + */ +export async function push(root: string, ctx: Ready): Promise { + const state = await RulesStore.read(root) + const batch = state.outbox.slice(0, MAX_PROPOSALS) + if (!batch.length) return { sent: [], queued: 0 } + const outcomes = await send(ctx, batch) const sent: PushResult["sent"] = [] - const approvals: { entry: (typeof batch)[number]; publicId: string; version: number }[] = [] - for (const result of results) { - const entry = batch[result.index] - if (!entry) continue + const approvals: { entry: OutboxEntry; publicId: string; version: number }[] = [] + const done = new Set() + const failed = new Map() + for (const { entry, result, error } of outcomes) { + if (error) { + if (error.kind === "rejected") { + done.add(entry.id) + sent.push({ text: entry.item.text, status: "rejected", error: error.message }) + } else failed.set(entry.id, error.message) + continue + } + if (!result) { + failed.set(entry.id, "no result from the server") + continue + } + done.add(entry.id) const ok = result.status === "pending" || result.status === "exists" || result.duplicate sent.push({ text: entry.item.text, @@ -178,8 +213,14 @@ export async function push(root: string, ctx: Ready): Promise { } } const now = new Date().toISOString() - await RulesStore.update(root, (s) => { - s.outbox = s.outbox.filter((entry) => !batch.some((b) => b.id === entry.id)) + const after = await RulesStore.update(root, (s) => { + s.outbox = s.outbox.filter((entry) => !done.has(entry.id)) + for (const entry of s.outbox) { + const error = failed.get(entry.id) + if (error === undefined) continue + entry.attempts += 1 + entry.last_error = error + } for (const row of sent) { if (!row.public_id || row.status === "exists") continue const existing = s.sent.find((x) => x.public_id === row.public_id) @@ -189,7 +230,8 @@ export async function push(root: string, ctx: Ready): Promise { if (row.status === "pending") s.pending_count += 1 } }) - return { sent, queued: Math.max(0, state.outbox.length - batch.length) } + const error = failed.size ? [...failed.values()][0] : undefined + return { sent, queued: after.outbox.length, ...(error ? { error } : {}) } } /** Queue a proposal; it is sent by the next push. */ @@ -209,40 +251,41 @@ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: nu const reported = new Set(state.usage_sessions) const counts = new Map() const sessions: string[] = [] + // A rule counts at most `MAX_USAGE_DELTA` sessions per report: later sessions wait for the next one. for (const snapshot of snapshots) { if (reported.has(snapshot.session)) continue - const shown = new Set(snapshot.shown.filter((entry) => entry.name === RulesStore.TEAM_STORE).map((entry) => entry.id)) - if (!shown.size) continue + const shown = [...new Set(snapshot.shown.filter((entry) => entry.name === RulesStore.TEAM_STORE).map((entry) => entry.id))] + .flatMap((id) => byLesson.get(id)?.public_id ?? []) + if (!shown.length) continue + if (shown.some((publicId) => (counts.get(publicId)?.applied ?? 0) >= MAX_USAGE_DELTA)) break sessions.push(snapshot.session) - for (const id of shown) { - const rule = byLesson.get(id) - if (!rule) continue - const row = counts.get(rule.public_id) ?? { applied: 0, corrected_again: 0 } + for (const publicId of shown) { + const row = counts.get(publicId) ?? { applied: 0, corrected_again: 0 } row.applied += 1 - counts.set(rule.public_id, row) + counts.set(publicId, row) } } + // `recurred` is a window over recent snapshots and can fall as old ones age out. The reported baseline + // follows it down, so a later rise is reported, and only what was sent is added to it. const corrected: Record = {} const recurrence = await analyzeRecurrence(root).catch(() => undefined) for (const row of recurrence?.lessons ?? []) { if (row.name !== RulesStore.TEAM_STORE) continue const rule = byLesson.get(row.id) if (!rule) continue - corrected[rule.public_id] = row.recurred - const delta = row.recurred - (state.corrected_reported[rule.public_id] ?? 0) + const baseline = Math.min(state.corrected_reported[rule.public_id] ?? 0, row.recurred) + const delta = Math.min(row.recurred - baseline, MAX_USAGE_DELTA) + corrected[rule.public_id] = baseline + Math.max(0, delta) if (delta <= 0) continue const entry = counts.get(rule.public_id) ?? { applied: 0, corrected_again: 0 } entry.corrected_again += delta counts.set(rule.public_id, entry) } - if (!counts.size) return { items: 0 } - const items = [...counts].map(([public_id, c]) => ({ - public_id, - applied: Math.min(c.applied, MAX_USAGE_DELTA), - helpful: 0, - harmful: 0, - corrected_again: Math.min(c.corrected_again, MAX_USAGE_DELTA), - })) + if (!counts.size) { + if (Object.keys(corrected).length) await RulesStore.update(root, (s) => { s.corrected_reported = { ...s.corrected_reported, ...corrected } }) + return { items: 0 } + } + const items = [...counts].map(([public_id, c]) => ({ public_id, applied: c.applied, helpful: 0, harmful: 0, corrected_again: c.corrected_again })) try { await RulesApi.usage(ctx.actAs, ctx.scope.datamateId, { batch_id: randomUUID(), items }) } catch (error) { @@ -264,42 +307,65 @@ export interface RunReport { /** Push, pull, then report usage: "Sync now", `learn sync`, and the background refresh. */ export async function run(root: string, directory: string, learn: LearnConfig): Promise { + const key = await canonicalRoot(root) const ctx = await context(directory, learn) if (ctx.status !== "ready") { if (ctx.status === "skipped" && ctx.unlinked) { const state = await RulesStore.read(root) if (state.scope) await RulesStore.reset(root) } + failedAt.set(key, Date.now()) return { context: ctx } } + await adopt(root, ctx) const pushed = await push(root, ctx) const pulled = await pull(root, ctx) + if (pulled.ok) failedAt.delete(key) + else failedAt.set(key, Date.now()) const usage = pulled.ok ? await reportUsage(root, ctx).catch((error) => ({ items: 0, error: errorText(error) })) : undefined return { context: ctx, push: pushed, pull: pulled, ...(usage ? { usage } : {}) } } +// Keyed by the real path: the service and the session loop may name one project through different paths. const inflight = new Map>() +/** When the last run for a project failed or was skipped; a session start does not wait on it again. */ +const failedAt = new Map() + +const canonicalRoot = (root: string) => fs.realpath(root).catch(() => root) /** One run per project at a time; concurrent callers share it. */ -export function runShared(root: string, directory: string, learn: LearnConfig): Promise { - const existing = inflight.get(root) +export async function runShared(root: string, directory: string, learn: LearnConfig): Promise { + const key = await canonicalRoot(root) + const existing = inflight.get(key) if (existing) return existing - const started = run(root, directory, learn).finally(() => inflight.delete(root)) - inflight.set(root, started) + const started = run(root, directory, learn).finally(() => inflight.delete(key)) + inflight.set(key, started) return started } -/** A pull is due when none succeeded for this project within `REFRESH_MS`. */ +/** A run that starts after anything already queued: waits out an in-flight run (which may have pushed + * already) and starts a new one. */ +export async function runFresh(root: string, directory: string, learn: LearnConfig): Promise { + await inflight.get(await canonicalRoot(root))?.catch(() => undefined) + return runShared(root, directory, learn) +} + +/** A pull is due when none succeeded within `REFRESH_MS`, and no attempt failed within it. */ export async function due(root: string): Promise { + const failed = failedAt.get(await canonicalRoot(root)) + if (failed !== undefined && Date.now() - failed < REFRESH_MS) return false const state = await RulesStore.read(root) const at = state.pulled_at ? Date.parse(state.pulled_at) : 0 return !Number.isFinite(at) || Date.now() - at >= REFRESH_MS } -/** Session start: refresh when due, waiting at most `waitMs` so the first turn sees fresh rules. */ +/** Session start: wait (at most `waitMs`) for a run in flight, or start one when due, so the first turn sees + * fresh rules. */ export async function sessionStart(root: string, directory: string, learn: LearnConfig, waitMs = SESSION_WAIT_MS): Promise { - if (!rulesEnabled(learn) || !(await due(root))) return - const running = runShared(root, directory, learn).catch((error) => log.warn("rules session sync failed", { error: errorText(error) })) + if (!rulesEnabled(learn)) return + const existing = inflight.get(await canonicalRoot(root)) + if (!existing && !(await due(root))) return + const running = (existing ?? runShared(root, directory, learn)).catch((error) => log.warn("rules session sync failed", { error: errorText(error) })) await Promise.race([running, new Promise((resolve) => setTimeout(resolve, waitMs).unref?.())]) } diff --git a/packages/opencode/src/altimate/rules/trigger.ts b/packages/opencode/src/altimate/rules/trigger.ts index b116ebb901..8db26ede63 100644 --- a/packages/opencode/src/altimate/rules/trigger.ts +++ b/packages/opencode/src/altimate/rules/trigger.ts @@ -81,9 +81,11 @@ async function draft(input: { signals: readonly Signal[] state: RulesStore.State target?: string + /** Only an ADD is usable (the recurrence path): other deltas are skipped. */ + addOnly?: boolean /** Why no proposal came out, for the check record. */ note?: (outcome: string) => void -}): Promise<{ text: string; change: Draft["change"]; replaces?: Draft["replaces"]; reason: string } | undefined> { +}): Promise<{ text: string; change: Draft["change"]; replaces?: Draft["replaces"]; reason: string } | "covered" | undefined> { const source = await input.deps.source(input.sessionID) const generate = await input.deps.generate(source) const bullets = teamBullets(input.state) @@ -97,9 +99,10 @@ async function draft(input: { const rules = new Map(input.state.rules.map((rule) => [RulesStore.lessonId(rule.rule_key), rule])) for (const delta of deltas) { const target = delta.id ? rules.get(delta.id) : undefined + if (input.addOnly && delta.op !== "ADD") continue if (delta.op === "HELPFUL" && target) { input.note?.(`the team rule already covers it (${delta.reason})`) - return undefined + return "covered" } if (delta.op === "EDIT" && target && delta.text && !lint(delta.text)) return { text: delta.text.trim(), change: "edit", replaces: { public_id: target.public_id, text: target.text, version: target.version }, reason: delta.reason } @@ -127,6 +130,7 @@ export async function afterTurn(input: { if (running.has(input.sessionID)) return running.add(input.sessionID) let outcome = "no new corrections" + let checked: string[] = [] const note = (text: string) => { outcome = text } try { const state = await RulesStore.read(input.root) @@ -137,7 +141,8 @@ export async function afterTurn(input: { if (!fresh.length) return // One card at a time per session: an explicit "remember" may already have shown one for this turn. if (RuleProposal.list().some((card) => card.sessionID === input.sessionID)) return - await RulesStore.update(input.root, (s) => { s.processed_signals.push(...fresh.map((signal) => signal.id)) }) + // Marked processed once the check completes; a failed check is retried after the next turn. + checked = fresh.map((signal) => signal.id) const newest = fresh[fresh.length - 1] const correction = { excerpt: excerpt(newest.text), at: newest.correctedAt ?? newest.at } @@ -151,13 +156,15 @@ export async function afterTurn(input: { const said = words(fresh.map((signal) => signal.text).join(" ")) const hit = state.rules .map((rule) => ({ id: RulesStore.lessonId(rule.rule_key), overlap: [...words(rule.text)].filter((w) => said.has(w)).length })) - .filter((rule) => shownIds.has(rule.id) && rule.overlap >= EDIT_OVERLAP) + .filter((rule) => shownIds.has(rule.id) && rule.overlap >= EDIT_OVERLAP && !RulesStore.dismissed(state, rule.id)) .sort((a, b) => b.overlap - a.overlap)[0] log.info("rule edit candidate", { session: input.sessionID, hit: hit?.id, overlap: hit?.overlap, shown: shownIds.size }) if (hit) { const drafted = await draft({ deps: input.deps, sessionID: input.sessionID, signals: fresh, state, target: hit.id, note }) + // The shown rule already says it: no card, and no second drafting call for recurrence. + if (drafted === "covered") return if (drafted) { - await RuleProposal.ask({ + const card = await RuleProposal.ask({ sessionID: input.sessionID, root: input.root, directory: input.directory, learn: input.learn, draft: { text: drafted.text, @@ -170,7 +177,7 @@ export async function afterTurn(input: { origin: "session", evidence: { sessions: 1, correction }, topics: [hit.id], }, }) - outcome = `showed a ${drafted.change} card for a shown team rule` + outcome = card ? `showed a ${drafted.change} card for a shown team rule` : "the proposal for this team rule was dismissed" return } } @@ -186,9 +193,9 @@ export async function afterTurn(input: { } const all = await readSignals(input.root, Playbook.DEFAULT_NAME) const backing = all.filter((signal) => topic.signal_ids.includes(signal.id)) - const drafted = await draft({ deps: input.deps, sessionID: input.sessionID, signals: backing, state, note }) - if (!drafted || drafted.change !== "add") return - await RuleProposal.ask({ + const drafted = await draft({ deps: input.deps, sessionID: input.sessionID, signals: backing, state, addOnly: true, note }) + if (!drafted || drafted === "covered" || drafted.change !== "add") return + const card = await RuleProposal.ask({ sessionID: input.sessionID, root: input.root, directory: input.directory, learn: input.learn, draft: { text: drafted.text, @@ -199,14 +206,21 @@ export async function afterTurn(input: { topics: [topic.identifier], }, }) - outcome = `showed an add card for topic ${topic.identifier}` + outcome = card ? `showed an add card for topic ${topic.identifier}` : `topic ${topic.identifier} was dismissed` } catch (error) { + checked = [] outcome = `failed: ${error instanceof Error ? error.message : String(error)}` log.warn("rule proposal check failed", { error: outcome }) } finally { running.delete(input.sessionID) - if (outcome !== "no new corrections") - await RulesStore.update(input.root, (s) => { s.last_check = { at: new Date().toISOString(), session: input.sessionID, outcome } }).catch(() => {}) + if (outcome !== "no new corrections" || checked.length) { + // Keep only ids of signals still on disk, so the list cannot grow past them or forget a live one. + const live = checked.length ? new Set((await readSignals(input.root, Playbook.DEFAULT_NAME).catch(() => [] as Signal[])).map((signal) => signal.id)) : undefined + await RulesStore.update(input.root, (s) => { + s.last_check = { at: new Date().toISOString(), session: input.sessionID, outcome } + if (live) s.processed_signals = [...s.processed_signals.filter((id) => live.has(id)), ...checked] + }).catch(() => {}) + } } } diff --git a/packages/opencode/src/cli/cmd/learn.ts b/packages/opencode/src/cli/cmd/learn.ts index 3731f2adc5..67fca41a78 100644 --- a/packages/opencode/src/cli/cmd/learn.ts +++ b/packages/opencode/src/cli/cmd/learn.ts @@ -449,7 +449,8 @@ const SyncCommand = effectCmd({ const root = await projectRoot() const { Config } = await import("@/config/config") const { RulesSync } = await import("../../altimate/rules/sync") - const report = await RulesSync.run(root, process.cwd(), (await Config.get()).learn) + const { Instance } = await import("@/project/instance") + const report = await RulesSync.runFresh(root, Instance.directory, (await Config.get()).learn) if (args.json) return out(JSON.stringify(report, (key, value) => (key === "actAs" ? undefined : value), 2)) if (report.context.status !== "ready") return out(`Rules sync skipped: ${report.context.reason}.`) for (const sent of report.push?.sent ?? []) out(`Sent "${sent.text}": ${sent.error ?? sent.status}`) @@ -475,8 +476,12 @@ const BootstrapCommand = effectCmd({ .option("dry-run", { type: "boolean", default: false, describe: "print scope and redacted signals; send nothing and leave bootstrap state unchanged" }) .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. Lessons are candidates only: learn show, then learn promote. 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. Lessons are candidates only: learn show, then learn promote; in a project linked to a workspace they go to the workspace owner's review instead. 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 = yield* run("", async () => + (await import("../../altimate/rules/from-history")).RulesFromHistory.candidateTexts(await projectRoot(), args.name as string)) + // altimate_change end const result = yield* run("", async () => { const [{ Config }, { Provider }, { Instance }, { InstanceRef }, { AppRuntime }] = await Promise.all([ import("@/config/config"), import("@/provider/provider"), import("@/project/instance"), @@ -515,10 +520,11 @@ const BootstrapCommand = effectCmd({ if (result?.failures) return yield* fail("Bootstrap reflection failed; signals remain queued. Rerun `learn bootstrap` to continue.") // altimate_change start — linked project: the staged lessons go to the workspace owner's review as rules if (!args["dry-run"]) yield* run("", async () => { - const { Config } = await import("@/config/config") + const [{ Config }, { Instance }] = await Promise.all([import("@/config/config"), import("@/project/instance")]) const { RulesFromHistory } = await import("../../altimate/rules/from-history") const sent = await RulesFromHistory.proposeStaged({ - root: await projectRoot(), directory: process.cwd(), learn: (await Config.get()).learn, name: args.name as string, origin: "bootstrap", + root: await projectRoot(), directory: Instance.directory, learn: (await Config.get()).learn, name: args.name as string, origin: "bootstrap", + before: staged, }) if (sent.status === "sent") out(sent.message) }) @@ -542,8 +548,12 @@ 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" }) .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. Lessons are candidates only: learn show, then learn promote. 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. Lessons are candidates only: learn show, then learn promote; in a project linked to a workspace they go to the workspace owner's review instead. 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 = yield* run("", async () => + (await import("../../altimate/rules/from-history")).RulesFromHistory.candidateTexts(await projectRoot(), args.name as string)) + // altimate_change end const result = yield* run("", async () => { const [{ Config }, { Provider }, { Instance }, { InstanceRef }, { AppRuntime }] = await Promise.all([ import("@/config/config"), import("@/provider/provider"), import("@/project/instance"), @@ -584,10 +594,11 @@ const ImportReviewsCommand = effectCmd({ if (result?.failures) return yield* fail("Review reflection failed; signals remain queued. Rerun `learn import-reviews` to continue.") // altimate_change start — linked project: the staged lessons go to the workspace owner's review as rules if (!args["dry-run"]) yield* run("", async () => { - const { Config } = await import("@/config/config") + const [{ Config }, { Instance }] = await Promise.all([import("@/config/config"), import("@/project/instance")]) const { RulesFromHistory } = await import("../../altimate/rules/from-history") const sent = await RulesFromHistory.proposeStaged({ - root: await projectRoot(), directory: process.cwd(), learn: (await Config.get()).learn, name: args.name as string, origin: "import_reviews", + root: await projectRoot(), directory: Instance.directory, learn: (await Config.get()).learn, name: args.name as string, origin: "import_reviews", + before: staged, }) if (sent.status === "sent") out(sent.message) }) diff --git a/packages/opencode/src/plugin/tui/altimate/rules.tsx b/packages/opencode/src/plugin/tui/altimate/rules.tsx index 91f6325aad..d96d19bf69 100644 --- a/packages/opencode/src/plugin/tui/altimate/rules.tsx +++ b/packages/opencode/src/plugin/tui/altimate/rules.tsx @@ -5,7 +5,9 @@ // - The proposed-rule card: renders `rule.proposal.asked` (and `.updated` after an edit) and answers with // `POST /rule-proposal/:id/reply`. Enter sends for review; `a` approves (owner); `e` edits the wording; // `n` is "Not a rule". When the wording did not say rule or memory, the card asks -// "Save as: Memory · Team rule · Skip". Escape closes it, which counts as skip. +// "Save as: Memory · Team rule · Skip". Escape closes the card without answering: the proposal stays open in +// the session server, and the next queued card shows. Open proposals are loaded at startup, so a card asked +// before this TUI subscribed still shows. // - Review notices: `rule.proposal.reviewed` becomes a toast ("approved", or "rejected" with the reason). // - `/rules`: the team rules, your proposals with their status and reasons, Sync now, and learning on/off. // @@ -21,7 +23,10 @@ import { writeProjectLearning } from "@/altimate/learn/project-config" const id = "altimate:rules" -type Raw = { post(input: { url: string; body?: unknown; headers?: Record }): Promise<{ data?: unknown; error?: unknown }> } +type Raw = { + post(input: { url: string; body?: unknown; headers?: Record }): Promise<{ data?: unknown; error?: unknown }> + get(input: { url: string }): Promise<{ data?: unknown; error?: unknown }> +} type ReplyResult = { ok: boolean; message?: string; error?: string; card?: ProposalCard } function raw(api: TuiPluginApi): Raw | undefined { @@ -125,6 +130,8 @@ function CardView(props: { api: TuiPluginApi; card: ProposalCard; onAction: (act function installCards(api: TuiPluginApi) { const queue: ProposalCard[] = [] + // The card whose dialog (the card, or its edit prompt) is up. Cleared before any programmatic replace or + // clear, whose close callback would otherwise advance the queue a second time. let showing: string | undefined const next = () => { @@ -133,21 +140,31 @@ function installCards(api: TuiPluginApi) { if (card) show(card) } + const close = () => { + showing = undefined + api.ui.dialog.clear() + } + const reply = async (card: ProposalCard, action: string, text?: string) => { const result = await post(api, `/rule-proposal/${card.id}/reply`, { action, ...(text !== undefined ? { text } : {}) }) + .catch(() => undefined) + // Closed with Escape while the request ran: the queue already moved on, so only report the outcome. + const current = showing === card.id if (!result) return api.ui.toast({ variant: "error", message: "Could not reach the session server.", duration: 6000 }) if (!result.ok) { api.ui.toast({ variant: "error", message: result.error ?? "Not done.", duration: 8000 }) - if (result.card) show(result.card) + if (result.card && current) show(result.card) return } - if (action === "edit" && result.card) return show(result.card) + if (action === "edit" && result.card) return current ? show(result.card) : undefined api.ui.toast({ variant: "success", message: result.message ?? "Done.", duration: 6000 }) - api.ui.dialog.clear() + if (!current) return + close() next() } const edit = (card: ProposalCard) => { + showing = undefined api.ui.dialog.replace(() => ( show(card)} /> )) + showing = card.id } const show = (card: ProposalCard) => { - showing = card.id + showing = undefined api.ui.dialog.replace( () => ( { + const index = queue.findIndex((c) => c.id === card.id) + if (index >= 0) queue[index] = card + } + + // A card queued behind another dialog shows once that dialog closes. + const drain = setInterval(() => { + if (!showing && queue.length && !api.ui.dialog.open) next() + }, 1_000) + drain.unref?.() + + const load = async () => { + const result = await raw(api)?.get({ url: "/rule-proposal" }).catch(() => undefined) + if (Array.isArray(result?.data)) for (const card of result.data as ProposalCard[]) offer(card) + } + on(api)("rule.proposal.asked", (event) => offer(event.properties as ProposalCard)) + on(api)("rule.proposal.updated", (event) => updated(event.properties as ProposalCard)) // Each review is shown once per project, whether it arrived as an event or was pulled before this TUI // subscribed (the startup sync): remembered by id and status. const seenKey = () => `altimate.rules.notices.${root(api)}` @@ -196,7 +233,10 @@ function installCards(api: TuiPluginApi) { on(api)("rule.proposal.reviewed", (event) => notify(event.properties)) const replay = () => void RulesSummary.summary(root(api), learnConfig(api)).then((s) => [...s.notices].reverse().forEach(notify)).catch(() => {}) on(api)("altimate.rules.changed", replay) - setTimeout(replay, 4_000) + setTimeout(() => { + replay() + void load() + }, 4_000) } function reviewToast(api: TuiPluginApi, p: { text: string; status: string; reason?: string; reviewer?: string }) { diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index c1abf6cb6e..274d227ab3 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1279,7 +1279,11 @@ export namespace Server { if (text !== undefined && typeof text !== "string") return c.json({ ok: false, error: "text must be a string." }, 400) try { const { RuleProposal } = await import("../altimate/rules/proposal") - const result = await RuleProposal.reply(c.req.param("id"), { action: action as (typeof actions)[number], ...(text !== undefined ? { text } : {}) }) + const result = await RuleProposal.reply( + c.req.param("id"), + { action: action as (typeof actions)[number], ...(text !== undefined ? { text } : {}) }, + Instance.directory, + ) return c.json(result, result.ok ? 200 : 409) } catch (err) { const error = err instanceof Error ? err.message : String(err) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index a0831ddffa..e271265cfb 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -769,9 +769,11 @@ export namespace SessionPrompt { // create state, or even schedule async learning work on that default-off path. const learnRoot = Instance.worktree !== "/" ? Instance.worktree : Instance.directory // Workspace rules: a pull at session start, bounded, so the first turn delivers newly approved rules. - await import("../altimate/rules/sync") - .then((m) => m.sessionStart(learnRoot, Instance.directory, altCfg.learn)) - .catch((error) => log.warn("rules session sync failed", { error })) + // Only a project with rules state awaits anything; every other prompt stays on the synchronous path. + if (existsSync(path.join(learnRoot, ".altimate-code", "learn", "team", "rules.json"))) + await import("../altimate/rules/sync") + .then((m) => m.sessionStart(learnRoot, Instance.directory, altCfg.learn)) + .catch((error) => log.warn("rules session sync failed", { error })) const lessonState = existsSync(path.join(learnRoot, ".altimate-code", "learn")) ? new LessonDelivery(learnRoot, altCfg.learn, Instance.directory) : undefined diff --git a/packages/opencode/test/altimate/rules/rules.test.ts b/packages/opencode/test/altimate/rules/rules.test.ts index 178f1deb13..220e8a08a3 100644 --- a/packages/opencode/test/altimate/rules/rules.test.ts +++ b/packages/opencode/test/altimate/rules/rules.test.ts @@ -1,5 +1,5 @@ // altimate_change - new file -import { describe, expect, test } from "bun:test" +import { afterAll, beforeAll, describe, expect, test } from "bun:test" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" @@ -7,6 +7,7 @@ import { RuleClassify } from "../../../src/altimate/rules/classify" import { RuleProposal } from "../../../src/altimate/rules/proposal" import { RulesStore } from "../../../src/altimate/rules/store" import { RulesSummary } from "../../../src/altimate/rules/summary" +import { RulesSync } from "../../../src/altimate/rules/sync" import { appliesTo, learningEnabled } from "../../../src/altimate/rules/trigger" import { parse } from "../../../src/altimate/learn/lesson" import * as Store from "../../../src/altimate/learn/store" @@ -155,3 +156,139 @@ describe("proposal card", () => { expect(await RuleProposal.reply(card.id, { action: "send" })).toMatchObject({ ok: false }) }) }) + +describe("names for saved rules", () => { + test("no trailing hyphen, and rules that start alike stay apart", () => { + const long = "Always store money columns as integer cents in every staging and mart model" + const a = RuleProposal.slug(long) + const b = RuleProposal.slug(`${long} too`) + expect(a).toMatch(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/) + expect(a).not.toBe(b) + expect(RuleProposal.slug("!!!")).toMatch(/^[0-9a-f]{8}$/) + }) +}) + +describe("client state", () => { + test("an unreadable rules.json is set aside, not overwritten", async () => { + const root = await tmp() + await RulesStore.update(root, () => {}) + const file = path.join(RulesStore.dir(root), "rules.json") + await fs.writeFile(file, '{"version": 2, "outbox": ["from a newer client"]}') + await RulesStore.update(root, (s) => { s.pending_count = 1 }) + const kept = (await fs.readdir(RulesStore.dir(root))).filter((name) => name.startsWith("rules.json.unreadable-")) + expect(kept).toHaveLength(1) + expect(await fs.readFile(path.join(RulesStore.dir(root), kept[0]), "utf8")).toContain("from a newer client") + }) + + test("an existing team .gitignore that would share the mirror is rewritten", async () => { + const root = await tmp() + await fs.mkdir(RulesStore.dir(root), { recursive: true }) + await fs.writeFile(path.join(RulesStore.dir(root), ".gitignore"), "!approved.json\n") + await RulesStore.update(root, () => {}) + expect(await fs.readFile(path.join(RulesStore.dir(root), ".gitignore"), "utf8")).toBe("*\n") + }) + + test("the sent list keeps every pending proposal", async () => { + const root = await tmp() + const at = "2026-10-27T00:00:00Z" + const state = await RulesStore.update(root, (s) => { + s.sent = [ + { public_id: "p-old", text: "old", change: "add", status: "pending", sent_at: at, updated_at: at }, + ...Array.from({ length: 250 }, (_, i) => ({ public_id: `s-${i}`, text: `t${i}`, change: "add", status: "active", sent_at: at, updated_at: at })), + ] + }) + expect(state.sent.some((s) => s.public_id === "p-old")).toBe(true) + expect(state.sent.filter((s) => s.status !== "pending")).toHaveLength(200) + }) +}) + +describe("proposal replies", () => { + const draft = { text: "Name date columns `_date`.", applies_to: [], change: "add" as const, origin: "session" as const, evidence: { sessions: 2, correction: { excerpt: "no, call it ordered_date", at: "2026-10-27T00:00:00Z" } }, topics: ["ordered_date"] } + const env = process.env.ALTIMATE_DISABLE_WORKSPACE + beforeAll(() => { process.env.ALTIMATE_DISABLE_WORKSPACE = "1" }) + afterAll(() => { + if (env === undefined) delete process.env.ALTIMATE_DISABLE_WORKSPACE + else process.env.ALTIMATE_DISABLE_WORKSPACE = env + }) + + test("the workspace's include_quotes off keeps the person's words out of the proposal", async () => { + const root = await tmp() + await RulesStore.update(root, (s) => Object.assign(s, linked({ include_quotes: false }))) + const card = (await RuleProposal.ask({ sessionID: "q1", root, directory: root, learn: undefined, draft }))! + expect(await RuleProposal.reply(card.id, { action: "send" })).toMatchObject({ ok: true, closed: true }) + const [queued] = (await RulesStore.read(root)).outbox + expect(queued.item.text).toBe(draft.text) + expect(queued.item.corrections).toEqual([]) + }) + + test("a failed action leaves the card open", async () => { + const root = await tmp() + const card = (await RuleProposal.ask({ sessionID: "f1", root, directory: root, learn: undefined, draft: { ...draft, text: "Prefix seeds with `seed_`." } }))! + // rules.json as a directory: the dismissal cannot be written. + await fs.mkdir(path.join(RulesStore.dir(root), "rules.json"), { recursive: true }) + expect(await RuleProposal.reply(card.id, { action: "dismiss" })).toMatchObject({ ok: false }) + expect(RuleProposal.list(root).map((c) => c.id)).toContain(card.id) + }) + + test("a reply from another project does not reach the card", async () => { + const root = await tmp() + const card = (await RuleProposal.ask({ sessionID: "d1", root, directory: root, learn: undefined, draft: { ...draft, text: "Quote identifiers only when needed." } }))! + expect(await RuleProposal.reply(card.id, { action: "dismiss" }, "/somewhere/else")).toMatchObject({ ok: false }) + expect(RuleProposal.list(root).map((c) => c.id)).toContain(card.id) + }) +}) + +describe("sending the outbox", () => { + // A stand-in for `POST /datamates/:id/rules/proposals`: answers by the texts in each request. + let server: ReturnType + const requests: string[][] = [] + let respond: (texts: string[]) => Response = () => new Response("{}") + beforeAll(() => { + server = Bun.serve({ + port: 0, + async fetch(request) { + const body = (await request.json()) as { items: { text: string }[] } + const texts = body.items.map((item) => item.text) + requests.push(texts) + return respond(texts) + }, + }) + }) + afterAll(() => server.stop(true)) + + const scope = { apiUrl: "", tenant: "t", account: "a", datamateId: 1, repoRemote: "https://github.com/acme/demo" } + const ready = () => ({ status: "ready" as const, actAs: { url: `http://127.0.0.1:${server.port}`, instance: "t", apiKey: "k" }, scope: { ...scope, apiUrl: `http://127.0.0.1:${server.port}` } }) + const item = (text: string) => ({ change: "add" as const, text, applies_to: [], origin: "session" as const, evidence_sessions: 1, corrections: [] }) + const ok = (texts: string[]) => Response.json({ results: texts.map((text, index) => ({ index, status: "pending", public_id: `p-${text}`, version: 1 })) }) + + test("one refused item does not hold back the rest", async () => { + const root = await tmp() + for (const text of ["good one", "bad one", "good two"]) await RulesSync.enqueue(root, item(text)) + requests.length = 0 + respond = (texts) => (texts.includes("bad one") ? Response.json({ detail: { code: "invalid", message: "bad item" } }, { status: 422 }) : ok(texts)) + const result = await RulesSync.push(root, ready()) + expect(requests[0]).toHaveLength(3) + expect(result.sent.map((s) => [s.text, s.status])).toEqual([["good one", "pending"], ["bad one", "rejected"], ["good two", "pending"]]) + expect((await RulesStore.read(root)).outbox).toEqual([]) + }) + + test("a transient failure, or no answer for an item, keeps it queued", async () => { + const root = await tmp() + for (const text of ["first", "second"]) await RulesSync.enqueue(root, item(text)) + respond = () => new Response("unavailable", { status: 503 }) + expect((await RulesSync.push(root, ready())).queued).toBe(2) + expect((await RulesStore.read(root)).outbox.map((e) => e.attempts)).toEqual([1, 1]) + respond = (texts) => Response.json({ results: [{ index: 0, status: "pending", public_id: `p-${texts[0]}`, version: 1 }] }) + const result = await RulesSync.push(root, ready()) + expect(result.queued).toBe(1) + expect((await RulesStore.read(root)).outbox.map((e) => e.item.text)).toEqual(["second"]) + }) + + test("an outbox from another account or workspace is dropped before sending", async () => { + const root = await tmp() + await RulesStore.update(root, (s) => { s.scope = { ...scope, account: "someone-else" } }) + await RulesSync.enqueue(root, item("from the other account")) + await RulesSync.adopt(root, ready()) + expect((await RulesStore.read(root)).outbox).toEqual([]) + }) +}) From f3c71874c3c27500149ec093704c5af8b06cdb93 Mon Sep 17 00:00:00 2001 From: Sarav Date: Wed, 28 Oct 2026 13:01:15 +0530 Subject: [PATCH 03/20] =?UTF-8?q?fix:=20workspace=20rules=20=E2=80=94=20th?= =?UTF-8?q?e=20card=20offers=20one=20team=20action:=20send=20(member)=20or?= =?UTF-8?q?=20approve=20now=20(owner)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An owner saw both "Send for review" and "Approve now". Sending their own proposal to their own review queue adds nothing, so the owner's card offers "Approve now" ("Approve anyway" when flagged) on Enter, and a member's card "Send for review". The `a` shortcut is gone: Enter is the team action. Docs updated. Co-Authored-By: Claude Opus 5.5 --- docs/docs/configure/learn.md | 2 +- packages/opencode/src/altimate/rules/proposal.ts | 5 +++-- packages/opencode/src/plugin/tui/altimate/rules.tsx | 9 ++++----- packages/opencode/test/altimate/rules/rules.test.ts | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/docs/configure/learn.md b/docs/docs/configure/learn.md index c8b1addc35..d4caf8ef83 100644 --- a/docs/docs/configure/learn.md +++ b/docs/docs/configure/learn.md @@ -195,7 +195,7 @@ In a project linked to an Altimate workspace (`altimate-code link`), what you te - you correct a team rule the session was shown (the card proposes changing it, with the current text struck through); - you ask the agent to remember something as a rule. -The card shows the proposed wording, what it applies to, why it was proposed and your correction. **Enter** sends it to the owner's review queue (the workspace's **Rules** tab), **e** edits the wording, **n** ("Not a rule") stops that topic from being proposed for 30 days, and the owner also sees **a** to approve it at once. A rule that tells the agent to skip or disable verification (tests, CI, hooks, `--no-verify`) is flagged and needs "approve anyway". Nothing reaches a teammate until it is approved. +The card shows the proposed wording, what it applies to, why it was proposed and your correction. **Enter** sends it to the owner's review queue (the workspace's **Rules** tab); for the workspace owner, **Enter** approves it at once instead. **e** edits the wording, and **n** ("Not a rule") stops that topic from being proposed for 30 days. A rule that tells the agent to skip or disable verification (tests, CI, hooks, `--no-verify`) is flagged and needs "approve anyway". Nothing reaches a teammate until it is approved. **Rule or memory.** A personal preference ("I prefer CTEs") or a fact ("our warehouse is Snowflake") is saved to memory without review. When the wording does not say which, the card asks: Memory, Team rule, or Skip. diff --git a/packages/opencode/src/altimate/rules/proposal.ts b/packages/opencode/src/altimate/rules/proposal.ts index 1df327ad59..84587cd148 100644 --- a/packages/opencode/src/altimate/rules/proposal.ts +++ b/packages/opencode/src/altimate/rules/proposal.ts @@ -6,7 +6,8 @@ // // 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, owner: approve too (send and approve in one step; "approve anyway" when flagged) +// - 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 import { randomUUID } from "node:crypto" import { Bus } from "@/bus" @@ -60,7 +61,7 @@ function build(id: string, sessionID: string, draft: Draft, state: RulesStore.St const linked = !!state.scope && !!state.workspace const flag = verificationWarning(draft.text) const team: ProposalCard["actions"] = linked - ? [...(state.workspace!.is_owner ? (["approve"] as const) : []), "send"] + ? [state.workspace!.is_owner ? "approve" : "send"] : ["save_local"] const actions: ProposalCard["actions"] = draft.choose ? ["save_memory", ...team, "dismiss"] : [...team, "edit", "dismiss"] return { diff --git a/packages/opencode/src/plugin/tui/altimate/rules.tsx b/packages/opencode/src/plugin/tui/altimate/rules.tsx index d96d19bf69..b2aca97d65 100644 --- a/packages/opencode/src/plugin/tui/altimate/rules.tsx +++ b/packages/opencode/src/plugin/tui/altimate/rules.tsx @@ -3,7 +3,7 @@ // Workspace rules in the TUI: a thin renderer of the session server's rule events. // // - The proposed-rule card: renders `rule.proposal.asked` (and `.updated` after an edit) and answers with -// `POST /rule-proposal/:id/reply`. Enter sends for review; `a` approves (owner); `e` edits the wording; +// `POST /rule-proposal/:id/reply`. Enter sends for review (member) or approves now (owner); `e` edits the wording; // `n` is "Not a rule". When the wording did not say rule or memory, the card asks // "Save as: Memory · Team rule · Skip". Escape closes the card without answering: the proposal stays open in // the session server, and the next queued card shows. Open proposals are loaded at startup, so a card asked @@ -69,7 +69,7 @@ function options(card: ProposalCard): TuiDialogSelectOption[] { description: `Goes to ${owner}'s queue in ${ws} → Rules. Nothing changes for the team until approved.`, }, approve: { - title: card.weakens_verification ? "Approve anyway (a)" : "Approve now (a)", + title: card.choose ? "Team rule — approve now" : card.weakens_verification ? "Approve anyway" : "Approve now", value: "approve", description: `You own ${ws}: it reaches every teammate's sessions within a few minutes.`, }, @@ -80,7 +80,7 @@ function options(card: ProposalCard): TuiDialogSelectOption[] { save_local: { title: "Save for this project", value: "save_local", description: "Not linked to a workspace: saved as project training, no review" }, save_memory: { title: "Memory — just for you", value: "save_memory", description: "A fact or preference: saved now, no review, not a team rule" }, } - // Enter on the first row sends: "Send for review" leads, then "Approve now" for the owner. + // Enter on the first row takes the team action: "Send for review" (member) or "Approve now" (owner). const order = ["save_memory", "send", "save_local", "approve", "edit", "dismiss"] return [...card.actions].sort((a, b) => order.indexOf(a) - order.indexOf(b)).map((action) => all[action]) } @@ -117,9 +117,8 @@ function CardView(props: { api: TuiPluginApi; card: ProposalCard; onAction: (act renderFilter={false} skipFilter onSelect={(option) => props.onAction(option.value)} - actions={[...shortcut("approve", "a", "approve"), ...shortcut("edit", "e", "edit"), ...shortcut("dismiss", "n", card.choose ? "skip" : "not a rule")]} + actions={[...shortcut("edit", "e", "edit"), ...shortcut("dismiss", "n", card.choose ? "skip" : "not a rule")]} bindings={[ - ...(card.actions.includes("approve") ? [{ key: "a", cmd: "rules.card.approve" }] : []), ...(card.actions.includes("edit") ? [{ key: "e", cmd: "rules.card.edit" }] : []), { key: "n", cmd: "rules.card.dismiss" }, ]} diff --git a/packages/opencode/test/altimate/rules/rules.test.ts b/packages/opencode/test/altimate/rules/rules.test.ts index 220e8a08a3..e081c4d87d 100644 --- a/packages/opencode/test/altimate/rules/rules.test.ts +++ b/packages/opencode/test/altimate/rules/rules.test.ts @@ -133,8 +133,8 @@ describe("proposal card", () => { await RulesStore.update(root, (s) => Object.assign(s, linked())) expect((await ask())!.actions).toEqual(["send", "edit", "dismiss"]) await RulesStore.update(root, (s) => { s.workspace = { name: "Team", owner_name: "Olive", is_owner: true } }) - expect((await ask())!.actions).toEqual(["approve", "send", "edit", "dismiss"]) - expect((await ask({ ...draft, choose: true } as typeof draft))!.actions).toEqual(["save_memory", "approve", "send", "dismiss"]) + expect((await ask())!.actions).toEqual(["approve", "edit", "dismiss"]) + expect((await ask({ ...draft, choose: true } as typeof draft))!.actions).toEqual(["save_memory", "approve", "dismiss"]) }) test("an edit is linted like any rule and flags verification bypasses", async () => { From 0b70df0dce82a6e74e1627ad51fd69d58009c484 Mon Sep 17 00:00:00 2001 From: Sarav Date: Wed, 28 Oct 2026 13:21:00 +0530 Subject: [PATCH 04/20] =?UTF-8?q?feat:=20workspace=20rules=20=E2=80=94=20s?= =?UTF-8?q?how=20the=20proposed-rule=20card=20inline=20above=20the=20input?= =?UTF-8?q?,=20without=20blocking=20typing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card was a modal dialog. It now sits above the session's input (the `session_prompt` slot, which renders the regular input below it), like the permission prompt's place but without taking the input away: - Typing and sending messages continue while a card waits. - `p` (ctrl+x p) or a click focuses the card: ←/→ (or tab) select an action, Enter confirms, `e` edits the wording, `n` is "Not a rule", Escape goes back to typing and leaves the card. While focused, a high-priority key layer takes those keys and the input is blurred. - One card per session; "+N more" when others wait. Permission and question prompts hide it, as they hide the input. - A card answered in another client (`rule.proposal.replied`) disappears; an edit (`rule.proposal.updated`) updates it in place. - Editing the wording still opens a one-line prompt dialog. Co-Authored-By: Claude Opus 5.5 --- .../src/plugin/tui/altimate/rules.tsx | 346 ++++++++++++------ 1 file changed, 231 insertions(+), 115 deletions(-) diff --git a/packages/opencode/src/plugin/tui/altimate/rules.tsx b/packages/opencode/src/plugin/tui/altimate/rules.tsx index b2aca97d65..9decc4cbad 100644 --- a/packages/opencode/src/plugin/tui/altimate/rules.tsx +++ b/packages/opencode/src/plugin/tui/altimate/rules.tsx @@ -2,20 +2,21 @@ // // Workspace rules in the TUI: a thin renderer of the session server's rule events. // -// - The proposed-rule card: renders `rule.proposal.asked` (and `.updated` after an edit) and answers with -// `POST /rule-proposal/:id/reply`. Enter sends for review (member) or approves now (owner); `e` edits the wording; -// `n` is "Not a rule". When the wording did not say rule or memory, the card asks -// "Save as: Memory · Team rule · Skip". Escape closes the card without answering: the proposal stays open in -// the session server, and the next queued card shows. Open proposals are loaded at startup, so a card asked -// before this TUI subscribed still shows. +// - 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 +// 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. // - Review notices: `rule.proposal.reviewed` becomes a toast ("approved", or "rejected" with the reason). // - `/rules`: the team rules, your proposals with their status and reasons, Sync now, and learning on/off. // // No rule logic lives here; the IDE extension renders the same events and calls the same routes. import { TextAttributes } from "@opentui/core" -import type { TuiDialogSelectOption, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { TuiDialogSelectOption, TuiPlugin, TuiPluginApi, TuiPromptRef } from "@opencode-ai/plugin/tui" import type { BuiltinTuiPlugin } from "@opencode-ai/tui/builtins" -import { Show } from "solid-js" +import { createSignal, Show } from "solid-js" import type { ProposalCard } from "@/altimate/rules/events" import { RulesSummary } from "@/altimate/rules/summary" import { describeAge } from "@/altimate/workspace/attach-snapshot" @@ -59,158 +60,272 @@ function meta(card: ProposalCard) { return parts.join(" · ") } -function options(card: ProposalCard): TuiDialogSelectOption[] { +/** Button labels, in the order shown: the team action first, so Enter on a fresh card takes it. */ +function buttons(card: ProposalCard): { action: ProposalCard["actions"][number]; label: string }[] { + const label: Record = { + send: card.choose ? "Team rule — send for review" : "Send for review", + approve: card.choose ? "Team rule — approve now" : card.weakens_verification ? "Approve anyway" : "Approve now", + save_local: "Save for this project", + save_memory: "Memory — just for you", + edit: "Edit wording", + dismiss: card.choose ? "Skip" : "Not a rule", + } + const order = ["save_memory", "send", "approve", "save_local", "edit", "dismiss"] + return [...card.actions].sort((a, b) => order.indexOf(a) - order.indexOf(b)).map((action) => ({ action, label: label[action] })) +} + +/** What the team action does, shown under the buttons while the card is focused. */ +function explain(card: ProposalCard, action: string): string { const owner = card.workspace?.owner_name ?? "the workspace owner" const ws = card.workspace?.name ?? "the workspace" - const all: Record> = { - send: { - title: card.choose ? "Team rule — send for review" : "Send for review", - value: "send", - description: `Goes to ${owner}'s queue in ${ws} → Rules. Nothing changes for the team until approved.`, - }, - approve: { - title: card.choose ? "Team rule — approve now" : card.weakens_verification ? "Approve anyway" : "Approve now", - value: "approve", - description: `You own ${ws}: it reaches every teammate's sessions within a few minutes.`, - }, - edit: { title: "Edit wording (e)", value: "edit", description: "Change the text before deciding" }, - dismiss: card.choose - ? { title: "Skip (n)", value: "dismiss", description: "Don't save anything" } - : { title: "Not a rule (n)", value: "dismiss", description: "Won't propose this topic again for 30 days" }, - save_local: { title: "Save for this project", value: "save_local", description: "Not linked to a workspace: saved as project training, no review" }, - save_memory: { title: "Memory — just for you", value: "save_memory", description: "A fact or preference: saved now, no review, not a team rule" }, - } - // Enter on the first row takes the team action: "Send for review" (member) or "Approve now" (owner). - const order = ["save_memory", "send", "save_local", "approve", "edit", "dismiss"] - return [...card.actions].sort((a, b) => order.indexOf(a) - order.indexOf(b)).map((action) => all[action]) + 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 === "save_local") return "Not linked to a workspace: saved as project training, no review." + if (action === "save_memory") return "A fact or preference: saved now, no review, not a team rule." + if (action === "edit") return "Change the text before deciding." + return card.choose || !card.topics.length ? "Don't save anything." : "Won't propose this topic again for 30 days." } -function CardView(props: { api: TuiPluginApi; card: ProposalCard; onAction: (action: string) => void }) { +const FOCUS_KEY = "p" +const FOCUS_COMMAND = "altimate.rules.card.focus" + +function InlineCard(props: { + api: TuiPluginApi + card: ProposalCard + more: number + focused: boolean + selected: number + onPick: (index: number) => void + onFocus: () => void +}) { const theme = () => props.api.theme.current - const card = props.card - const shortcut = (action: string, key: string, label: string) => - card.actions.includes(action as ProposalCard["actions"][number]) - ? [{ command: `rules.card.${action}`, title: label, onTrigger: () => props.onAction(action) }] - : [] + const card = () => props.card + const options = () => buttons(card()) + // The key as configured (the leader can be rebound). + const focusHint = () => + props.api.keys.formatSequence( + props.api.keymap.getCommandBindings({ visibility: "registered", commands: [FOCUS_COMMAND] }).get(FOCUS_COMMAND)?.[0]?.sequence, + ) || "ctrl+x p" return ( - + props.onFocus()} + > - {title(card)} - - {card.replaces!.text} + + {title(card())} + 0}> + {`+${props.more} more`} + + + + {card().replaces!.text} - {card.text} - {meta(card)} - - {`Why: ${card.why}`} + {card().text} + {meta(card())} + + {`Why: ${card().why}`} - - {`Your correction: "${card.evidence.correction!.excerpt}"`} + + {`Your correction: "${card().evidence.correction!.excerpt}"`} - - {`⚠ ${card.weakens_verification} — the owner must "approve anyway"`} + + {`⚠ ${card().weakens_verification} — it needs "approve anyway"`} - props.onAction(option.value)} - actions={[...shortcut("edit", "e", "edit"), ...shortcut("dismiss", "n", card.choose ? "skip" : "not a rule")]} - bindings={[ - ...(card.actions.includes("edit") ? [{ key: "e", cmd: "rules.card.edit" }] : []), - { key: "n", cmd: "rules.card.dismiss" }, - ]} - /> + + {options().map((option, index) => ( + props.onPick(index)} + > + {option.label} + + ))} + + + {focusHint()} review + + + + + + {explain(card(), options()[props.selected]?.action ?? "")} + + {"⇆"} select enter confirm + {" "}esc back to typing + + + ) } +/** + * Proposed-rule cards, inline above the session's input. Typing is never blocked: the card waits until the + * person focuses it (`p` or a click), picks an action, or goes back to typing with Escape. One card + * shows per session; the rest wait behind "+N more". Permission and question prompts hide it, as they hide + * the input. + */ function installCards(api: TuiPluginApi) { - const queue: ProposalCard[] = [] - // The card whose dialog (the card, or its edit prompt) is up. Cleared before any programmatic replace or - // clear, whose close callback would otherwise advance the queue a second time. - let showing: string | undefined + const [cards, setCards] = createSignal([]) + const [focused, setFocused] = createSignal(false) + const [selected, setSelected] = createSignal(0) + const [busy, setBusy] = createSignal(false) + let prompt: TuiPromptRef | undefined + let sessionID: string | undefined + let layer: (() => void) | undefined - const next = () => { - showing = undefined - const card = queue.shift() - if (card) show(card) - } + const current = () => cards().find((card) => card.sessionID === sessionID) + const remove = (id: string) => setCards((list) => list.filter((card) => card.id !== id)) - const close = () => { - showing = undefined - api.ui.dialog.clear() + const unfocus = () => { + layer?.() + layer = undefined + setFocused(false) + prompt?.focus() } const reply = async (card: ProposalCard, action: string, text?: string) => { + if (busy()) return + setBusy(true) const result = await post(api, `/rule-proposal/${card.id}/reply`, { action, ...(text !== undefined ? { text } : {}) }) .catch(() => undefined) - // Closed with Escape while the request ran: the queue already moved on, so only report the outcome. - const current = showing === card.id + .finally(() => setBusy(false)) if (!result) return api.ui.toast({ variant: "error", message: "Could not reach the session server.", duration: 6000 }) - if (!result.ok) { - api.ui.toast({ variant: "error", message: result.error ?? "Not done.", duration: 8000 }) - if (result.card && current) show(result.card) + if (!result.ok) return api.ui.toast({ variant: "error", message: result.error ?? "Not done.", duration: 8000 }) + if (action === "edit") { + if (result.card) setCards((list) => list.map((c) => (c.id === card.id ? result.card! : c))) return } - if (action === "edit" && result.card) return current ? show(result.card) : undefined api.ui.toast({ variant: "success", message: result.message ?? "Done.", duration: 6000 }) - if (!current) return - close() - next() + remove(card.id) + unfocus() } const edit = (card: ProposalCard) => { - showing = undefined api.ui.dialog.replace(() => ( One line, at most 140 characters.} value={card.text} - onConfirm={(text) => void reply(card, "edit", text)} - onCancel={() => show(card)} + onConfirm={(text) => { + api.ui.dialog.clear() + void reply(card, "edit", text) + }} + onCancel={() => api.ui.dialog.clear()} /> )) - showing = card.id } - const show = (card: ProposalCard) => { - showing = undefined - api.ui.dialog.replace( - () => ( - (action === "edit" ? edit(card) : void reply(card, action))} - /> - ), - () => { - if (showing === card.id) next() - }, - ) - showing = card.id - api.ui.dialog.setSize("large") + const pick = (index: number) => { + const card = current() + const option = card && buttons(card)[index] + if (!card || !option) return + setSelected(index) + if (option.action === "edit") return edit(card) + void reply(card, option.action) } - const offer = (card: ProposalCard) => { - if (showing === card.id || queue.some((c) => c.id === card.id)) return - if (showing || api.ui.dialog.open) queue.push(card) - else show(card) + const shortcut = (action: string) => { + const card = current() + const index = card ? buttons(card).findIndex((b) => b.action === action) : -1 + if (index >= 0) pick(index) } - // Wording edited in another client: a queued copy shows the new wording. - const updated = (card: ProposalCard) => { - const index = queue.findIndex((c) => c.id === card.id) - if (index >= 0) queue[index] = card + const move = (step: number) => { + const card = current() + if (!card) return + const count = buttons(card).length + setSelected((index) => (index + step + count) % count) } - // A card queued behind another dialog shows once that dialog closes. - const drain = setInterval(() => { - if (!showing && queue.length && !api.ui.dialog.open) next() - }, 1_000) - drain.unref?.() + const focus = () => { + if (!current() || focused()) return + setSelected(0) + setFocused(true) + prompt?.blur() + layer = api.keymap.registerLayer({ + priority: 100, + bindings: [ + { key: "left", cmd: () => move(-1) }, + { key: "h", cmd: () => move(-1) }, + { key: "right", cmd: () => move(1) }, + { key: "l", cmd: () => move(1) }, + { key: "tab", cmd: () => move(1) }, + { key: "return", cmd: () => pick(selected()) }, + { key: "e", cmd: () => shortcut("edit") }, + { key: "n", cmd: () => shortcut("dismiss") }, + { key: "escape", cmd: () => unfocus() }, + ], + }) + } + + const offer = (card: ProposalCard) => + setCards((list) => (list.some((c) => c.id === card.id) ? list.map((c) => (c.id === card.id ? card : c)) : [...list, card])) + + // A card gone elsewhere (answered in another client): drop it here too. + const closed = (id: string) => { + const wasShown = current()?.id === id + remove(id) + if (wasShown && focused()) unfocus() + } + + api.slots.register({ + order: 100, + slots: { + session_prompt(_ctx, props) { + sessionID = props.session_id + return ( + + card.sessionID === props.session_id)}> + {(card) => ( + c.sessionID === props.session_id).length - 1} + focused={focused()} + selected={selected()} + onPick={(index) => (focused() ? pick(index) : focus())} + onFocus={focus} + /> + )} + + { + prompt = ref + props.ref?.(ref) + }} + right={} + /> + + ) + }, + }, + }) + + api.keymap.registerLayer({ + commands: [ + { + name: FOCUS_COMMAND, + title: "Review the proposed rule", + desc: "Move to the proposed-rule card above the input", + category: "Altimate", + namespace: "palette", + run: focus, + }, + ], + bindings: [{ key: FOCUS_KEY, cmd: FOCUS_COMMAND }], + }) const load = async () => { const result = await raw(api)?.get({ url: "/rule-proposal" }).catch(() => undefined) @@ -218,7 +333,8 @@ function installCards(api: TuiPluginApi) { } on(api)("rule.proposal.asked", (event) => offer(event.properties as ProposalCard)) - on(api)("rule.proposal.updated", (event) => updated(event.properties as ProposalCard)) + on(api)("rule.proposal.updated", (event) => offer(event.properties as ProposalCard)) + on(api)("rule.proposal.replied", (event) => closed((event.properties as { id: string }).id)) // Each review is shown once per project, whether it arrived as an event or was pulled before this TUI // subscribed (the startup sync): remembered by id and status. const seenKey = () => `altimate.rules.notices.${root(api)}` From 4a01e486628eec31ea3fa00e0c91b323ca49a90c Mon Sep 17 00:00:00 2001 From: Sarav Date: Wed, 28 Oct 2026 14:13:30 +0530 Subject: [PATCH 05/20] =?UTF-8?q?fix:=20workspace=20rules=20=E2=80=94=20th?= =?UTF-8?q?e=20card's=20keys=20no=20longer=20take=20a=20dialog's=20keys=20?= =?UTF-8?q?(Enter=20in=20"Edit=20the=20rule"=20kept=20the=20old=20text)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While the card was focused, its key layer (priority 100: enter, e, n, h, l, tab, arrows, escape) stayed active under any dialog. In "Edit the rule", Enter re-ran the card's selected action — "Edit wording" — which reopened the dialog with the old text, and typed e/n/h/l never reached the field. The same happened in the palette. The layer is now released whenever a dialog is open (explicitly when the edit dialog opens, and by following the dialog's open state for any other dialog) and bound again when it closes while the card is still focused. Co-Authored-By: Claude Opus 5.5 --- .../src/plugin/tui/altimate/rules.tsx | 53 ++++++++++++++----- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/plugin/tui/altimate/rules.tsx b/packages/opencode/src/plugin/tui/altimate/rules.tsx index 9decc4cbad..d1e3be8b05 100644 --- a/packages/opencode/src/plugin/tui/altimate/rules.tsx +++ b/packages/opencode/src/plugin/tui/altimate/rules.tsx @@ -16,7 +16,7 @@ import { TextAttributes } from "@opentui/core" import type { TuiDialogSelectOption, TuiPlugin, TuiPluginApi, TuiPromptRef } from "@opencode-ai/plugin/tui" import type { BuiltinTuiPlugin } from "@opencode-ai/tui/builtins" -import { createSignal, Show } from "solid-js" +import { createEffect, createRoot, createSignal, Show } from "solid-js" import type { ProposalCard } from "@/altimate/rules/events" import { RulesSummary } from "@/altimate/rules/summary" import { describeAge } from "@/altimate/workspace/attach-snapshot" @@ -184,9 +184,15 @@ function installCards(api: TuiPluginApi) { const current = () => cards().find((card) => card.sessionID === sessionID) const remove = (id: string) => setCards((list) => list.filter((card) => card.id !== id)) - const unfocus = () => { + // The card's keys (enter, e, n, h, l, …) only while it is focused and no dialog is open: under a dialog they + // would take the dialog's own keys (Enter in "Edit the rule" would re-run the card's selected action). + const releaseKeys = () => { layer?.() layer = undefined + } + + const unfocus = () => { + releaseKeys() setFocused(false) prompt?.focus() } @@ -209,18 +215,24 @@ function installCards(api: TuiPluginApi) { } const edit = (card: ProposalCard) => { - api.ui.dialog.replace(() => ( - One line, at most 140 characters.} - value={card.text} - onConfirm={(text) => { - api.ui.dialog.clear() - void reply(card, "edit", text) - }} - onCancel={() => api.ui.dialog.clear()} - /> - )) + releaseKeys() + api.ui.dialog.replace( + () => ( + One line, at most 140 characters.} + value={card.text} + onConfirm={(text) => { + api.ui.dialog.clear() + void reply(card, "edit", text) + }} + onCancel={() => api.ui.dialog.clear()} + /> + ), + () => { + if (focused()) bindKeys() + }, + ) } const pick = (index: number) => { @@ -250,6 +262,11 @@ function installCards(api: TuiPluginApi) { setSelected(0) setFocused(true) prompt?.blur() + bindKeys() + } + + function bindKeys() { + if (layer || api.ui.dialog.open) return layer = api.keymap.registerLayer({ priority: 100, bindings: [ @@ -266,6 +283,14 @@ function installCards(api: TuiPluginApi) { }) } + // Any other dialog opened while the card is focused (the palette, `/rules`) gets its keys back too. + createRoot(() => + createEffect(() => { + if (api.ui.dialog.open) releaseKeys() + else if (focused()) bindKeys() + }), + ) + const offer = (card: ProposalCard) => setCards((list) => (list.some((c) => c.id === card.id) ? list.map((c) => (c.id === card.id ? card : c)) : [...list, card])) From 5fd1b684f3a7f5f0688d837f47fead3095d33378 Mon Sep 17 00:00:00 2001 From: Sarav Date: Wed, 28 Oct 2026 14:37:35 +0530 Subject: [PATCH 06/20] =?UTF-8?q?fix:=20workspace=20rules=20=E2=80=94=20`/?= =?UTF-8?q?rules`=20Sync=20now=20and=20the=20learning=20toggle=20close=20t?= =?UTF-8?q?he=20menu;=20the=20toggle=20reads=20the=20setting=20from=20disk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sync now and "Turn learning on/off for me" are actions: the menu closes and a toast reports the result (Sync now used to reopen the menu). A failed sync request or config write is reported instead of thrown. - The menu took "learning on/off" from the config loaded at startup, so after a toggle it kept offering the same direction and wrote the same value again. It now reads the project config's `learn` as on disk, like the rules service does. Co-Authored-By: Claude Opus 5.5 --- .../src/plugin/tui/altimate/rules.tsx | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/plugin/tui/altimate/rules.tsx b/packages/opencode/src/plugin/tui/altimate/rules.tsx index d1e3be8b05..0ad45db3b2 100644 --- a/packages/opencode/src/plugin/tui/altimate/rules.tsx +++ b/packages/opencode/src/plugin/tui/altimate/rules.tsx @@ -20,7 +20,7 @@ import { createEffect, createRoot, createSignal, Show } from "solid-js" import type { ProposalCard } from "@/altimate/rules/events" import { RulesSummary } from "@/altimate/rules/summary" import { describeAge } from "@/altimate/workspace/attach-snapshot" -import { writeProjectLearning } from "@/altimate/learn/project-config" +import { readProjectLearning, writeProjectLearning } from "@/altimate/learn/project-config" const id = "altimate:rules" @@ -43,7 +43,14 @@ async function post(api: TuiPluginApi, url: string, body?: unknown): Promise< const on = (api: TuiPluginApi) => api.event.on as unknown as (type: string, handler: (event: { properties: any }) => void) => () => void const root = (api: TuiPluginApi) => (api.state.path.worktree && api.state.path.worktree !== "/" ? api.state.path.worktree : api.state.path.directory) -const learnConfig = (api: TuiPluginApi) => (api.state.config as { learn?: { enabled?: boolean; capture?: boolean } }).learn +type LearnConfig = { enabled?: boolean; capture?: boolean } | undefined +/** The loaded config's `learn`, with the project file's `learn` as on disk now: the `/rules` toggle writes that + * file, and the loaded config does not change until the next session. */ +const learnConfig = async (api: TuiPluginApi): Promise => { + const loaded = (api.state.config as { learn?: LearnConfig }).learn + const onDisk = (await readProjectLearning(root(api), api.state.path.directory).catch(() => undefined)) as LearnConfig + return loaded || onDisk ? { ...loaded, ...onDisk } : undefined +} function title(card: ProposalCard) { if (card.choose) return "SAVE THIS?" @@ -371,7 +378,7 @@ function installCards(api: TuiPluginApi) { reviewToast(api, p) } on(api)("rule.proposal.reviewed", (event) => notify(event.properties)) - const replay = () => void RulesSummary.summary(root(api), learnConfig(api)).then((s) => [...s.notices].reverse().forEach(notify)).catch(() => {}) + const replay = () => void learnConfig(api).then((learn) => RulesSummary.summary(root(api), learn)).then((s) => [...s.notices].reverse().forEach(notify)).catch(() => {}) on(api)("altimate.rules.changed", replay) setTimeout(() => { replay() @@ -388,7 +395,7 @@ function reviewToast(api: TuiPluginApi, p: { text: string; status: string; reaso } async function showMenu(api: TuiPluginApi) { - const summary = await RulesSummary.summary(root(api), learnConfig(api)) + const summary = await RulesSummary.summary(root(api), await learnConfig(api)) const rows = await RulesSummary.rows(root(api)) const team = rows.filter((r) => r.source === "team") const mine = rows.filter((r) => r.source === "mine") @@ -423,9 +430,11 @@ async function showMenu(api: TuiPluginApi) { title={`Rules — ${status}`} options={options} onSelect={async (option) => { + // Sync and the learning toggle are actions, not navigation: close the menu, report with a toast. + if (option.value === "sync" || option.value === "toggle") api.ui.dialog.clear() if (option.value === "sync") { api.ui.toast({ variant: "info", message: "Syncing rules…", duration: 3000 }) - const report = await post<{ ok: boolean; pull?: { ok: boolean; rules: number; pending: number; error?: string; notices: unknown[] }; push?: { sent: unknown[] }; context?: { status: string; reason?: string } }>(api, "/altimate/rules/sync") + const report = await post<{ ok: boolean; pull?: { ok: boolean; rules: number; pending: number; error?: string; notices: unknown[] }; push?: { sent: unknown[] }; context?: { status: string; reason?: string } }>(api, "/altimate/rules/sync").catch(() => undefined) // A review notice from this pull is its own toast; do not cover it with the summary. if (report?.pull?.notices.length) return if (report?.context && report.context.status !== "ready") @@ -433,11 +442,14 @@ async function showMenu(api: TuiPluginApi) { else if (report?.pull?.ok) api.ui.toast({ variant: "success", message: `Synced: ${report.pull.rules} team ${report.pull.rules === 1 ? "rule" : "rules"}, ${report.pull.pending} pending review${report.push?.sent.length ? `, ${report.push.sent.length} sent` : ""}.`, duration: 6000 }) else api.ui.toast({ variant: "error", message: `Rules sync failed: ${report?.pull?.error ?? "unknown error"}`, duration: 8000 }) - return showMenu(api) + return } if (option.value === "toggle") { - const file = await writeProjectLearning(root(api), api.state.path.directory, !summary.learning) - api.ui.toast({ variant: "success", message: `Learning ${summary.learning ? "off" : "on"} for you in this project (${file}). Applies from the next session.`, duration: 8000 }) + const file = await writeProjectLearning(root(api), api.state.path.directory, !summary.learning).catch((error) => { + api.ui.toast({ variant: "error", message: `Learning not changed: ${error instanceof Error ? error.message : String(error)}`, duration: 8000 }) + return undefined + }) + if (file) api.ui.toast({ variant: "success", message: `Learning ${summary.learning ? "off" : "on"} for you in this project (${file}). Applies from the next session.`, duration: 8000 }) return } }} From 6d12db3aed820e54bbbc207c55a23686cd8fd919 Mon Sep 17 00:00:00 2001 From: Sarav Date: Wed, 28 Oct 2026 15:03:37 +0530 Subject: [PATCH 07/20] =?UTF-8?q?docs:=20learn=20produces=20rules=20?= =?UTF-8?q?=E2=80=94=20call=20the=20learned=20entries=20"rules"=20in=20lea?= =?UTF-8?q?rn's=20messages=20and=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Learn" is the feature (learning from sessions, the `learn` commands, the learning on/off setting); what it produces are rules, as in the workspace's Rules tab. The learn engine still called them "lessons", so in a linked project `learn status` said "Lessons: 2 approved" right under "Workspace rules: 2 team rules". - `learn` command help, epilogs and output, bootstrap / import-reviews summaries, automatic-promotion outcome lines and hold-back reasons, store errors, the recurrence report and the generated learn `.gitignore` comment: lesson(s) → rule(s). User-facing text only: identifiers, store paths, JSON keys and config keys (`learn.core_lessons`, …) are unchanged. - `learn status`: one "Learning from sessions: on/off (your setting / workspace setting / default)" line for every project, replacing "Capture: …" (your setting only) and the linked-only "Learning from sessions" line, which showed the same switch twice and could disagree. `--json` keeps `capture`. - `docs/configure/learn.md`: lesson(s) → rule(s) throughout; the status section describes the new line. - Tests: expected strings updated. Co-Authored-By: Claude Opus 5.5 --- docs/docs/configure/learn.md | 280 +++++++++--------- .../src/altimate/learn/auto-promote.ts | 14 +- packages/opencode/src/altimate/learn/auto.ts | 2 +- .../opencode/src/altimate/learn/bootstrap.ts | 4 +- .../src/altimate/learn/import-reviews.ts | 4 +- packages/opencode/src/altimate/learn/lock.ts | 2 +- .../opencode/src/altimate/learn/recurrence.ts | 4 +- .../src/altimate/learn/session-reflect.ts | 2 +- packages/opencode/src/altimate/learn/store.ts | 14 +- packages/opencode/src/cli/cmd/learn.ts | 66 +++-- .../test/altimate/learn/auto-promote.test.ts | 54 ++-- .../test/altimate/learn/bootstrap-cli.test.ts | 2 +- .../test/altimate/learn/bootstrap.test.ts | 2 +- .../opencode/test/altimate/learn/cli.test.ts | 18 +- .../test/altimate/learn/gitignore.test.ts | 4 +- .../altimate/learn/import-reviews.test.ts | 2 +- .../test/altimate/learn/migration.test.ts | 2 +- .../test/altimate/learn/recurrence.test.ts | 6 +- .../altimate/learn/schedule-state.test.ts | 4 +- .../test/altimate/learn/store.test.ts | 6 +- 20 files changed, 247 insertions(+), 245 deletions(-) diff --git a/docs/docs/configure/learn.md b/docs/docs/configure/learn.md index d4caf8ef83..3c52b81c37 100644 --- a/docs/docs/configure/learn.md +++ b/docs/docs/configure/learn.md @@ -1,15 +1,15 @@ --- title: Learn — Team Conventions from Corrections — Altimate Code -description: "Turn your corrections, repeated tool failures, and PR review comments into short, human-approved lessons that Altimate Code loads into every relevant session." +description: "Turn your corrections, repeated tool failures, and PR review comments into short, human-approved rules that Altimate Code loads into every relevant session." --- # Learn -`altimate-code learn` turns feedback you already give into short rules ("lessons") and puts the relevant ones in front of the agent at the start of later sessions. The feedback comes from your corrections in chat, tools that keep failing, and review comments on merged GitHub pull requests. A lesson looks like this: ``Convert `*_cents` columns to dollars in staging models.`` +`altimate-code learn` turns feedback you already give into short rules and puts the relevant ones in front of the agent at the start of later sessions. The feedback comes from your corrections in chat, tools that keep failing, and review comments on merged GitHub pull requests. A rule looks like this: ``Convert `*_cents` columns to dollars in staging models.`` -By default nothing becomes a lesson until a person approves it, and the agent never has to decide to look a lesson up. The harness selects the lessons and adds them to the prompt. If you opt in to [fully automatic mode](#fully-automatic-mode), small candidates that pass every safety gate go live without review, and `learn rollback` undoes them. +By default nothing becomes a rule until a person approves it, and the agent never has to decide to look a rule up. The harness selects the rules and adds them to the prompt. If you opt in to [fully automatic mode](#fully-automatic-mode), small candidates that pass every safety gate go live without review, and `learn rollback` undoes them. -Capture and reflection are off by default: nothing is recorded or sent to a model until you run `learn enable`. Lessons that a project already has approved are delivered, and a one-time reminder can suggest trying learn; `learn.enabled: false` (or `ALTIMATE_LEARN=0`) turns all of it off. +Capture and reflection are off by default: nothing is recorded or sent to a model until you run `learn enable`. Rules that a project already has approved are delivered, and a one-time reminder can suggest trying learn; `learn.enabled: false` (or `ALTIMATE_LEARN=0`) turns all of it off. ## Quick start @@ -18,12 +18,12 @@ Capture and reflection are off by default: nothing is recorded or sent to a mode altimate-code learn enable # 2. Work normally. When you correct the agent, a signal is recorded locally. -# Corrections are turned into candidate lessons automatically (see "How it works"). +# Corrections are turned into candidate rules automatically (see "How it works"). # 3. See what has been captured and what is waiting altimate-code learn status -# 4. Review the candidate lessons and the diff against the approved set +# 4. Review the candidate rules and the diff against the approved set altimate-code learn show # 5. Approve them @@ -32,7 +32,7 @@ altimate-code learn promote To skip step 5 for small, safe candidates, run `altimate-code learn enable --auto-promote` instead of step 1. See [Fully automatic mode](#fully-automatic-mode). -Start the next session and the approved lessons that match your request are in the prompt. +Start the next session and the approved rules that match your request are in the prompt. To start from history instead of waiting for new corrections, run `learn bootstrap` (past sessions in this project) or `learn import-reviews` (merged GitHub PRs). Both show what they will send and ask first. See [Commands](#commands). @@ -40,9 +40,9 @@ To start from history instead of waiting for new corrections, run `learn bootstr ``` capture ──► reflect ──► curate ──► review & promote ──► delivery - signals one model lint, you read the diff core + retrieved lessons + signals one model lint, you read the diff core + retrieved rules on disk call per redact, and approve at session start, extra - batch dedupe, lessons per request and + batch dedupe, rules per request and resolve per file, frozen per session conflicts ``` @@ -58,13 +58,13 @@ With `learn.capture` on, four kinds of signal are written to `.altimate-code/lea | `review` / `ci` | You record it with `learn signal add`, or `import-reviews` records it from PR review comments. | | `user` | You record it with `learn signal add --kind user`. Feedback passed to `learn reflect --feedback` is used for that reflection only and is not stored as a signal, so a failed reflection does not keep it. | -The correction classifier is a fixed list of text patterns ("that's wrong", "we always...", "use X instead of Y", "you forgot..."). It does not call a model. It favours precision over recall: a missed correction costs one lesson, a false one adds noise. Thanks, "LGTM", and ordinary questions are not corrections. +The correction classifier is a fixed list of text patterns ("that's wrong", "we always...", "use X instead of Y", "you forgot..."). It does not call a model. It favours precision over recall: a missed correction costs one rule, a false one adds noise. Thanks, "LGTM", and ordinary questions are not corrections. Every signal is redacted for secrets and clipped to 2,000 characters before it is stored. Signals are stored only on your machine; reflection sends them to the model provider you use. ### 2. Reflect -Reflection is one model call that reads a digest of the session plus the open signals and proposes edits to the lesson list. It runs when `learn.auto_reflect` is on, or when you run `learn reflect`. +Reflection is one model call that reads a digest of the session plus the open signals and proposes edits to the rule list. It runs when `learn.auto_reflect` is on, or when you run `learn reflect`. | Trigger | Behaviour | |---|---| @@ -78,7 +78,7 @@ Exiting the TUI saves signals but never waits on a model. A failed reflection is **What the model sees**, all redacted: -- the current lessons, with ids and helpful/harmful counters; +- the current rules, with ids and helpful/harmful counters; - a digest of the session, capped at 24,000 characters (your prompts, tool calls with clipped inputs and outputs, the files written, the final message); - the feedback, capped at 12,000 characters. @@ -88,15 +88,15 @@ The reflector is told to treat the digest and feedback as untrusted data, to lea The model's proposals are not applied directly. A deterministic curator checks each one: -- **Lint.** A lesson must be one line, at most 140 characters. It is rejected, not repaired, if it contains a shell command, a URL, a markdown link, an email address, an absolute path or `..`, a session or message id, a comment marker, text that looks like prompt injection, or something that looks like a secret. -- **Redaction.** Lesson text echoed back in reports is redacted again. Other model output in reports and `history.jsonl` (rejected proposals, removal reasons) is not filtered a second time; the model only saw redacted input. -- **Dedupe.** A new lesson that is at least 60% similar (word overlap) to an existing one is counted as "helpful" on the existing lesson instead of being added. -- **Contradictions.** If a new or edited lesson shares a code identifier in backticks (for example `` `amount_cents` ``) with an existing lesson, it must say that it supersedes it or coexists with it. Two silent versions of the same rule are not allowed. A lesson the reflector marks harmful and replaces in the same reflection is superseded implicitly. Superseded lessons move to a retired list. -- **Replacement step.** If a lesson is removed because it was wrong and no replacement was proposed, one narrow model call writes the corrected rule, or answers "none". Failures are queued for the next reflection. -- **Counters and removal.** Each lesson has helpful and harmful counters. A lesson is removed automatically only when it has at least two harmful marks, more harmful than helpful, from at least two different pieces of feedback. -- **Limits per reflection.** At most 3 added, 3 edited, 3 removed lessons. Lessons superseded implicitly by a replacement are not counted against the removal limit. -- **Store cap.** When the store passes `learn.max_stored`, the lowest-scoring lessons are evicted. Pinned lessons are not. -- **Verification flags.** A lesson that mentions skipping, ignoring, or disabling tests, checks, CI, or review is not rejected. It is flagged, and flagged lessons need explicit approval at promote. +- **Lint.** A rule must be one line, at most 140 characters. It is rejected, not repaired, if it contains a shell command, a URL, a markdown link, an email address, an absolute path or `..`, a session or message id, a comment marker, text that looks like prompt injection, or something that looks like a secret. +- **Redaction.** Rule text echoed back in reports is redacted again. Other model output in reports and `history.jsonl` (rejected proposals, removal reasons) is not filtered a second time; the model only saw redacted input. +- **Dedupe.** A new rule that is at least 60% similar (word overlap) to an existing one is counted as "helpful" on the existing rule instead of being added. +- **Contradictions.** If a new or edited rule shares a code identifier in backticks (for example `` `amount_cents` ``) with an existing rule, it must say that it supersedes it or coexists with it. Two silent versions of the same rule are not allowed. A rule the reflector marks harmful and replaces in the same reflection is superseded implicitly. Superseded rules move to a retired list. +- **Replacement step.** If a rule is removed because it was wrong and no replacement was proposed, one narrow model call writes the corrected rule, or answers "none". Failures are queued for the next reflection. +- **Counters and removal.** Each rule has helpful and harmful counters. A rule is removed automatically only when it has at least two harmful marks, more harmful than helpful, from at least two different pieces of feedback. +- **Limits per reflection.** At most 3 added, 3 edited, 3 removed rules. Rules superseded implicitly by a replacement are not counted against the removal limit. +- **Store cap.** When the store passes `learn.max_stored`, the lowest-scoring rules are evicted. Pinned rules are not. +- **Verification flags.** A rule that mentions skipping, ignoring, or disabling tests, checks, CI, or review is not rejected. It is flagged, and flagged rules need explicit approval at promote. The result is saved as a **candidate** (`candidate.json`). It has no effect on any session yet. @@ -108,17 +108,17 @@ The result is saved as a **candidate** (`candidate.json`). It has no effect on a You are the gate. ```bash -altimate-code learn show # approved lessons, candidate, flags, and a diff +altimate-code learn show # approved rules, candidate, flags, and a diff altimate-code learn promote # shows the diff again, asks to confirm ``` -`promote` re-checks the candidate before it goes live: every lesson is linted again, overlaps are checked again, and the candidate must be the same one whose diff you were shown. If the candidate changed in the meantime, promote stops and asks you to review again. The previous approved set is archived, so `learn rollback` can restore it. +`promote` re-checks the candidate before it goes live: every rule is linted again, overlaps are checked again, and the candidate must be the same one whose diff you were shown. If the candidate changed in the meantime, promote stops and asks you to review again. The previous approved set is archived, so `learn rollback` can restore it. -Flagged lessons (see verification flags above) appear as `WARNING` lines in the diff. Interactively, confirming the prompt approves them. In scripts, `promote --yes` refuses a candidate with flagged lessons unless you also pass `--allow-flagged`. Outside a terminal, `promote` without `--yes` refuses to run. +Flagged rules (see verification flags above) appear as `WARNING` lines in the diff. Interactively, confirming the prompt approves them. In scripts, `promote --yes` refuses a candidate with flagged rules unless you also pass `--allow-flagged`. Outside a terminal, `promote` without `--yes` refuses to run. ### Fully automatic mode -Off by default. With `learn.auto_promote` on, a candidate that automatic reflection has just staged goes live without `learn promote` when every gate below passes. Learning then runs on its own: you correct the agent, reflection turns the correction into a lesson, the lesson is promoted, and the next session gets it. +Off by default. With `learn.auto_promote` on, a candidate that automatic reflection has just staged goes live without `learn promote` when every gate below passes. Learning then runs on its own: you correct the agent, reflection turns the correction into a rule, the rule is promoted, and the next session gets it. ```bash altimate-code learn enable --auto-promote @@ -135,55 +135,55 @@ It runs only after automatic reflection (end of `run`, threshold, idle, startup | Gate | Holds the candidate back when | |---|---| | Same candidate | The candidate is not exactly the one this reflection staged (it was edited, replaced, promoted, or rejected in the meantime). | -| Only this reflection | The candidate already had lesson changes waiting for review before this reflection: from `learn reflect`, `bootstrap`, `import-reviews`, or an earlier candidate that was held back. | +| Only this reflection | The candidate already had rule changes waiting for review before this reflection: from `learn reflect`, `bootstrap`, `import-reviews`, or an earlier candidate that was held back. | | Validation | It fails the checks `learn promote` runs: lint, hidden characters, coexistence, and overlaps. | | Feedback | No signal from a person or an external check is behind the reflection: it needs at least one correction (`user_correction`, including `learn signal add --kind user`), `review` or `ci` signal. Repeated tool failures (`tool_retry`) are captured automatically, so on their own they only stage a candidate. Also held when the feedback was flagged as an instruction to the model. | -| Verification flags | A lesson it adds or changes mentions skipping or disabling verification. Those always need a person. | -| Your approvals | It would edit or remove a lesson a person approved. A lesson counts as automatic only while the approved set is exactly what the last automatic promotion published and the lesson is not pinned. Any `learn promote`, `rollback`, `pin` or `unpin` makes every approved lesson person-approved for good, even if the set later returns to the same contents; if that cannot be recorded, the command fails and changes nothing. A change to `approved.json` from outside learn, such as a pull, also makes them person-approved. Lessons from another checkout count as person-approved. | -| Size | It adds, edits, or removes more than `learn.auto_promote_max_changes` lessons (default 3), or changes only counters. | +| Verification flags | A rule it adds or changes mentions skipping or disabling verification. Those always need a person. | +| Your approvals | It would edit or remove a rule a person approved. A rule counts as automatic only while the approved set is exactly what the last automatic promotion published and the rule is not pinned. Any `learn promote`, `rollback`, `pin` or `unpin` makes every approved rule person-approved for good, even if the set later returns to the same contents; if that cannot be recorded, the command fails and changes nothing. A change to `approved.json` from outside learn, such as a pull, also makes them person-approved. Rules from another checkout count as person-approved. | +| Size | It adds, edits, or removes more than `learn.auto_promote_max_changes` rules (default 3), or changes only counters. | | Rate | The store already had `learn.auto_promote_daily` automatic promotions (default 5) in the last 24 hours. | A held-back candidate stays held: later reflections add to the same candidate, and it waits for you until you promote or reject it. Automatic promotion resumes after that. -Helpful and harmful counters on lessons a person approved do not change with an automatic promotion, because they decide which lessons fill the core tier. Those counter updates stay in the candidate for your next review. `learn show` lists them in the diff, and the next `learn promote` applies them. They do not block later automatic promotions. +Helpful and harmful counters on rules a person approved do not change with an automatic promotion, because they decide which rules fill the core tier. Those counter updates stay in the candidate for your next review. `learn show` lists them in the diff, and the next `learn promote` applies them. They do not block later automatic promotions. **Seeing what happened.** At the end of `run`, the outcome line says which way it went: ``` -learn: 1 signal -> +1 added; auto-promoted (previous lessons archived as v3). Undo with `altimate-code learn rollback` +learn: 1 signal -> +1 added; auto-promoted (previous rules archived as v3). Undo with `altimate-code learn rollback` learn: 1 signal -> +1 added; staged .altimate-code/learn/team-playbook/candidate.json for review (not auto-promoted: daily limit reached (5 automatic promotions in 24 hours; learn.auto_promote_daily=5)), review with `altimate-code learn show` ``` -The TUI has no outcome line. `learn status` shows whether automatic promotion is on, the last automatic promotion with the lessons it added or changed and the ones it removed, and the last reason a candidate was held back (cleared by the next successful automatic promotion). A promotion that could not finish is not listed. When another process held the learn lock for the whole wait, the reason is not recorded; the outcome line still has it. `learn show` marks automatically promoted lessons `(auto-promoted)`. History records each one as an `auto-promote` entry with the lesson ids, removed ids, and the number of signals. +The TUI has no outcome line. `learn status` shows whether automatic promotion is on, the last automatic promotion with the rules it added or changed and the ones it removed, and the last reason a candidate was held back (cleared by the next successful automatic promotion). A promotion that could not finish is not listed. When another process held the learn lock for the whole wait, the reason is not recorded; the outcome line still has it. `learn show` marks automatically promoted rules `(auto-promoted)`. History records each one as an `auto-promote` entry with the rule ids, removed ids, and the number of signals. -The state behind this is `.altimate-code/learn//auto-promote.json`. If it cannot be read, automatic promotion stops, `learn status` says so, and `learn promote`, `rollback`, `pin` and `unpin` refuse to run until it is fixed or deleted. Deleting the file restarts it, but it also resets the daily limit and makes every lesson count as person-approved. +The state behind this is `.altimate-code/learn//auto-promote.json`. If it cannot be read, automatic promotion stops, `learn status` says so, and `learn promote`, `rollback`, `pin` and `unpin` refuse to run until it is fixed or deleted. Deleting the file restarts it, but it also resets the daily limit and makes every rule count as person-approved. **Undo.** Each automatic promotion archives the previous approved set like `promote` does, including an empty set the first time, so `altimate-code learn rollback` restores the set from before the last promotion. Run it again to go back further. Like any rollback, it also discards the staged candidate (including counter updates waiting for review), the harmful marks, and the pending replacements; the signals they came from were already consumed. -**Why manual review stays the default.** In our benchmark, stale or contradicting lessons were the one thing that hurt: 4 real lessons plus 4 contradicting ones passed 1/9, and outdated lessons left in place passed 0/9 (see [Benchmarks](#benchmarks)). A wrong lesson is delivered to every later session until someone removes it. The gates make a bad automatic promotion small, rare, and easy to undo, but they cannot judge whether a lesson is right. Approved lessons live in `approved.json`, which is shared through Git, so read the diff before you commit it. +**Why manual review stays the default.** In our benchmark, stale or contradicting rules were the one thing that hurt: 4 real rules plus 4 contradicting ones passed 1/9, and outdated rules left in place passed 0/9 (see [Benchmarks](#benchmarks)). A wrong rule is delivered to every later session until someone removes it. The gates make a bad automatic promotion small, rare, and easy to undo, but they cannot judge whether a rule is right. Approved rules live in `approved.json`, which is shared through Git, so read the diff before you commit it. Automatic promotion runs no project command of its own (such as a test suite) before promoting. ### 5. Delivery -Only approved lessons are ever delivered. Delivery is part of the prompt loop, not a tool the agent calls. +Only approved rules are ever delivered. Delivery is part of the prompt loop, not a tool the agent calls. | Tier | What | Default | How it is chosen | |---|---|---|---| -| Core | Lessons added to every session | 15 (`learn.core_lessons`) | Pinned lessons first, then highest helpful minus harmful. | -| Retrieved | Lessons relevant to the first request | 15 (`learn.retrieved_lessons`) | Keyword search (BM25) over lesson text, tags, and path triggers, against your first message plus any project files it names. | -| Per request | New lessons relevant to a later message | 5 (`learn.request_lessons`) | Same search on each new message. Lessons whose path triggers match a file the session has touched or the message names rank first. Added to that message as "Team rules for this request". | -| File hook | Lessons that match a file the agent reads or edits | 5 per event (`learn.file_lessons`) | The lesson's path trigger matches the file, or an identifier in the lesson matches the file name. The most specific trigger wins: `models/staging/*.sql` ranks above `models/**`, so broad lessons cannot crowd out narrow ones. Added to that tool result. | +| Core | Rules added to every session | 15 (`learn.core_lessons`) | Pinned rules first, then highest helpful minus harmful. | +| Retrieved | Rules relevant to the first request | 15 (`learn.retrieved_lessons`) | Keyword search (BM25) over rule text, tags, and path triggers, against your first message plus any project files it names. | +| Per request | New rules relevant to a later message | 5 (`learn.request_lessons`) | Same search on each new message. Rules whose path triggers match a file the session has touched or the message names rank first. Added to that message as "Team rules for this request". | +| File hook | Rules that match a file the agent reads or edits | 5 per event (`learn.file_lessons`) | The rule's path trigger matches the file, or an identifier in the rule matches the file name. The most specific trigger wins: `models/staging/*.sql` ranks above `models/**`, so broad rules cannot crowd out narrow ones. Added to that tool result. | -The core and retrieved lessons are rendered once, as a "Team rules" section, and **frozen for the session**: the text does not change on later turns, so the start of the prompt stays identical and can be cached by the provider. Later additions are appended to messages and tool results, never inserted into the earlier prompt. A lesson is shown at most once per session, and no more than `learn.session_max_lessons` (40) are shown in total. After a compaction, the section is rebuilt from every lesson shown so far. +The core and retrieved rules are rendered once, as a "Team rules" section, and **frozen for the session**: the text does not change on later turns, so the start of the prompt stays identical and can be cached by the provider. Later additions are appended to messages and tool results, never inserted into the earlier prompt. A rule is shown at most once per session, and no more than `learn.session_max_lessons` (40) are shown in total. After a compaction, the section is rebuilt from every rule shown so far. -A lesson with path triggers is shown with its scope, for example `[applies to: models/staging/**] Convert *_cents columns with cents_to_dollars`, so the agent can tell when a retrieved lesson does not apply to the file in front of it. +A rule with path triggers is shown with its scope, for example `[applies to: models/staging/**] Convert *_cents columns with cents_to_dollars`, so the agent can tell when a retrieved rule does not apply to the file in front of it. -The agent never fetches a lesson. If the lesson is selected, it is in context. If it is not selected, the agent has no way to ask for it. +The agent never fetches a rule. If the rule is selected, it is in context. If it is not selected, the agent has no way to ask for it. -Delivery runs whenever approved lessons exist in the project, even if `learn.capture` is later turned off. A person already approved them. +Delivery runs whenever approved rules exist in the project, even if `learn.capture` is later turned off. A person already approved them. -Delivery waits at most about 5 seconds for the learn lock; if another process still holds it, that request goes without lessons. A store whose `approved.json` cannot be read is skipped and the others are still delivered. Every lesson is checked again at delivery, including lessons committed to the repository by hand: lessons over 140 characters or rejected by the curator's lint are left out, and per-request and per-file notes are limited by `learn.budget_tokens`. +Delivery waits at most about 5 seconds for the learn lock; if another process still holds it, that request goes without rules. A store whose `approved.json` cannot be read is skipped and the others are still delivered. Every rule is checked again at delivery, including rules committed to the repository by hand: rules over 140 characters or rejected by the curator's lint are left out, and per-request and per-file notes are limited by `learn.budget_tokens`. ## Workspace rules @@ -207,22 +207,22 @@ The card shows the proposed wording, what it applies to, why it was proposed and ## Learn vs memory vs a knowledge base -| | Learn (lessons) | [Memory](../data-engineering/tools/memory-tools.md) | Knowledge base | +| | Learn (rules) | [Memory](../data-engineering/tools/memory-tools.md) | Knowledge base | |---|---|---|---| | What it holds | Short behavioural rules: "do X, not Y" | Facts and notes the agent chooses to save (warehouse config, decisions) | Reference material: docs, schemas, runbooks | | Who writes it | The learn pipeline from evidence (corrections, failures, reviews) | The agent, when it decides to | People, or ingestion of existing documents | -| How it reaches the agent | The harness selects relevant lessons and puts them in the prompt | Loaded at session start and read or written through memory tools | Searched or retrieved on demand | +| How it reaches the agent | The harness selects relevant rules and puts them in the prompt | Loaded at session start and read or written through memory tools | Searched or retrieved on demand | | Quality control | Lint, redaction, dedupe, conflict handling, human approval | The agent's judgement | Editorial process | | Changes behaviour? | Yes, that is its purpose | Sometimes, indirectly | No, it informs | | When to use | "Stop making this mistake" | "Remember this fact" | "Look this up" | Use learn for rules your team enforces that the agent keeps getting wrong. Use memory for facts about your setup that you do not want to repeat. Use a knowledge base for material that is too large or too detailed to put in a prompt, where the agent should look things up when needed. -Learn does not depend on the agent calling a tool. Memory tools and skills that are pulled on demand only help when the model decides to read them. In our own test the agent never opened a pull-style playbook skill in 2 of 12 relevant runs, and both of those runs failed. Learn selects lessons before the model runs, so there is no such miss at the delivery step. (Selection can still miss; see [Limitations](#limitations).) +Learn does not depend on the agent calling a tool. Memory tools and skills that are pulled on demand only help when the model decides to read them. In our own test the agent never opened a pull-style playbook skill in 2 of 12 relevant runs, and both of those runs failed. Learn selects rules before the model runs, so there is no such miss at the delivery step. (Selection can still miss; see [Limitations](#limitations).) ## Commands -All subcommands take `--name ` (default `team-playbook`) except `enable`, `disable`, and `nudge off`. Each store is a separate lesson set in `.altimate-code/learn//`. +All subcommands take `--name ` (default `team-playbook`) except `enable`, `disable`, and `nudge off`. Each store is a separate rule set in `.altimate-code/learn//`. ### enable / disable @@ -238,25 +238,25 @@ altimate-code learn disable |---|---| | `--auto-promote` | Also turn on [fully automatic mode](#fully-automatic-mode). Without the flag, `enable` leaves an existing `learn.auto_promote` setting as it is. | -`disable` stops capture, automatic reflection, and automatic promotion. It does not delete anything, and approved lessons keep being delivered. Environment variables (`ALTIMATE_LEARN_CAPTURE`, `ALTIMATE_LEARN_AUTO`, `ALTIMATE_LEARN_AUTO_PROMOTE`) override the written config; `ALTIMATE_LEARN=0` turns all learning off. +`disable` stops capture, automatic reflection, and automatic promotion. It does not delete anything, and approved rules keep being delivered. Environment variables (`ALTIMATE_LEARN_CAPTURE`, `ALTIMATE_LEARN_AUTO`, `ALTIMATE_LEARN_AUTO_PROMOTE`) override the written config; `ALTIMATE_LEARN=0` turns all learning off. ### status -Shows whether learning is on, whether automatic promotion is on with its last promotion and last held-back reason, lesson counts (approved, candidate, retired), open signals, pending recoveries, the last reflection and its summary, the resolved limits, and lesson recurrence. `--json` also includes the last reflection's token use and estimated cost. +Shows whether learning is on, whether it learns from your sessions and who decided that (your setting, the linked workspace's "Learn from sessions", or the default), whether automatic promotion is on with its last promotion and last held-back reason, rule counts (approved, candidate, retired), open signals, pending recoveries, the last reflection and its summary, the resolved limits, and rule recurrence. `--json` also includes the last reflection's token use and estimated cost. -**Recurrence** answers "was this lesson shown, and did the user still correct the same thing?". It reads the newest 200 session snapshots and the recorded corrections. It makes no model calls and writes nothing. For each shown lesson it counts sessions where a matching correction came: +**Recurrence** answers "was this rule shown, and did the user still correct the same thing?". It reads the newest 200 session snapshots and the recorded corrections. It makes no model calls and writes nothing. For each shown rule it counts sessions where a matching correction came: | Count | Meaning | |---|---| -| `recurred` | after the lesson was shown, before the agent started answering the corrected request | -| `during` | while the agent was answering, for example a file-hook lesson added mid-turn; the lesson may have arrived too late | +| `recurred` | after the rule was shown, before the agent started answering the corrected request | +| `during` | while the agent was answering, for example a file-hook rule added mid-turn; the rule may have arrived too late | | `unknown` | in an order that cannot be told, for example older corrections or a request replayed after compaction | -| `ambiguous` | when the correction matched more than one lesson the session was shown in its current text | -| `earlier_version` | when the session was shown only an earlier version of the lesson text; it never counts for the current text | +| `ambiguous` | when the correction matched more than one rule the session was shown in its current text | +| `earlier_version` | when the session was shown only an earlier version of the rule text; it never counts for the current text | -A correction matches a lesson when it names one of the lesson's full backtick identifiers plus another word from the lesson, or contains at least 3 of the lesson's words and 60% of them. A prefix or suffix identifier such as `_at` or `stg_` never matches on its own. Lessons first shown after the correction are ignored for it. The text output lists only lessons with a matching correction and summarizes the rest as "no observed correction". That is not proof the agent followed them. Corrections are recorded only while capture is on. Status also lists up to 5 identifiers that were corrected in 2 or more sessions with no matching current lesson. They are taken from your local corrections and never leave the project. If the analysis fails, status shows `Recurrence unavailable` (`recurrence_error` in JSON), and the rest of the status still prints. +A correction matches a rule when it names one of the rule's full backtick identifiers plus another word from the rule, or contains at least 3 of the rule's words and 60% of them. A prefix or suffix identifier such as `_at` or `stg_` never matches on its own. Rules first shown after the correction are ignored for it. The text output lists only rules with a matching correction and summarizes the rest as "no observed correction". That is not proof the agent followed them. Corrections are recorded only while capture is on. Status also lists up to 5 identifiers that were corrected in 2 or more sessions with no matching current rule. They are taken from your local corrections and never leave the project. If the analysis fails, status shows `Recurrence unavailable` (`recurrence_error` in JSON), and the rest of the status still prints. -When the current text of a lesson has `recurred` in 2 or more sessions, reflection marks it `r:N` in the playbook it sends the model. The model is asked to make the lesson clearer or more specific when the feedback shows the wording is unclear, and to keep it when the agent simply did not follow a correct rule. It may narrow a rule only when the feedback shows the rule is too broad, and must keep the behavior the rule requires. The result is a candidate like any other and goes through the same review or automatic-promotion gates. +When the current text of a rule has `recurred` in 2 or more sessions, reflection marks it `r:N` in the playbook it sends the model. The model is asked to make the rule clearer or more specific when the feedback shows the wording is unclear, and to keep it when the agent simply did not follow a correct rule. It may narrow a rule only when the feedback shows the rule is too broad, and must keep the behavior the rule requires. The result is a candidate like any other and goes through the same review or automatic-promotion gates. ```bash altimate-code learn status @@ -269,7 +269,7 @@ altimate-code learn status --json ### show -Prints approved lessons, the candidate, a diff between them, and pending replacements. Each lesson shows its id, text, and counters (`helpful`, `harmful`, `applied`). Flagged lessons print a `WARNING` line. Lessons over 140 characters from older stores are marked `long`. +Prints approved rules, the candidate, a diff between them, and pending replacements. Each rule shows its id, text, and counters (`helpful`, `harmful`, `applied`). Flagged rules print a `WARNING` line. Rules over 140 characters from older stores are marked `long`. ```bash altimate-code learn show @@ -277,7 +277,7 @@ altimate-code learn show ### search -Finds approved and retired lessons. Every word you give must appear in the lesson id, text, or tags. A lesson id that matches nothing lists the closest ids instead. +Finds approved and retired rules. Every word you give must appear in the rule id, text, or tags. A rule id that matches nothing lists the closest ids instead. ```bash altimate-code learn search "timestamp utc" @@ -305,7 +305,7 @@ altimate-code learn reflect --session --feedback ci.log --feedback-kind ci | `--trajectory ` | A `trajectory export` file, for a session recorded in another project. Needs `--feedback`. | | `--feedback ` | Feedback file, or `-` for stdin. | | `--feedback-kind` | `verifier`, `ci`, `review`, or `user` (default `user`). | -| `--apply-paths ` | Path triggers for lessons in a new store, and for existing lessons in the store that have none. No default. | +| `--apply-paths ` | Path triggers for rules in a new store, and for existing rules in the store that have none. No default. | | `-m, --model ` | Model to use. Overrides `learn.model`. | | `--timeout ` | Seconds to wait for the model (default 120). | | `--json` | Machine-readable output. | @@ -354,20 +354,20 @@ altimate-code learn promote --publish | Flag | Description | |---|---| | `--yes` | Skip the confirmation prompt. Required outside a terminal. | -| `--allow-flagged` | With `--yes`: approve lessons flagged for mentioning skipping or disabling verification. | -| `--publish` | After promoting, export the approved lessons as a skill and publish it to the linked workspace. Not available when `ALTIMATE_DISABLE_WORKSPACE` is set. | +| `--allow-flagged` | With `--yes`: approve rules flagged for mentioning skipping or disabling verification. | +| `--publish` | After promoting, export the approved rules as a skill and publish it to the linked workspace. Not available when `ALTIMATE_DISABLE_WORKSPACE` is set. | | `--replace` | With `--publish`: update your own same-name published playbook even if it was published from another checkout. | ### pin / unpin -Pins an approved lesson. Pinned lessons fill the core tier before any unpinned lesson and are never evicted by `learn.max_stored`. They are still subject to `learn.core_lessons` and `learn.budget_tokens`: pin more than fit and the excess is not shown. Use it for rules that must apply whatever the request says. The change applies to the approved set directly, without `promote`, and to a staged candidate that still contains the lesson. If the candidate removes the lesson, the next promote removes it too; read the diff. +Pins an approved rule. Pinned rules fill the core tier before any unpinned rule and are never evicted by `learn.max_stored`. They are still subject to `learn.core_lessons` and `learn.budget_tokens`: pin more than fit and the excess is not shown. Use it for rules that must apply whatever the request says. The change applies to the approved set directly, without `promote`, and to a staged candidate that still contains the rule. If the candidate removes the rule, the next promote removes it too; read the diff. ```bash altimate-code learn pin L-8201 altimate-code learn unpin L-8201 ``` -Pinned lessons count toward `learn.core_lessons`. Pin only a few. +Pinned rules count toward `learn.core_lessons`. Pin only a few. ### reject @@ -387,7 +387,7 @@ altimate-code learn rollback ### bootstrap -Seeds candidate lessons from this project's past sessions. It looks for the same signals live capture would have recorded (corrections and repeated tool failures), then reflects on them. +Seeds candidate rules from this project's past sessions. It looks for the same signals live capture would have recorded (corrections and repeated tool failures), then reflects on them. ```bash altimate-code learn bootstrap --dry-run @@ -410,7 +410,7 @@ Re-run it to finish pending reflections and continue to older sessions. ### import-reviews -Seeds candidate lessons from human review comments on merged GitHub pull requests. It needs the `gh` CLI, installed and logged in for the repository's host. +Seeds candidate rules from human review comments on merged GitHub pull requests. It needs the `gh` CLI, installed and logged in for the repository's host. ```bash altimate-code learn import-reviews --dry-run @@ -460,21 +460,21 @@ Set these under `learn` in your project or user config. An environment variable | Key | Env var | Default | Effect | |---|---|---|---| -| `enabled` | `ALTIMATE_LEARN` (`1`/`true`, `0`/`false`, case-insensitive) | `true` | Master switch for lesson delivery, capture, scheduling, automatic reflection, startup recovery and the TUI reminder. Explicit `learn` commands still work. | +| `enabled` | `ALTIMATE_LEARN` (`1`/`true`, `0`/`false`, case-insensitive) | `true` | Master switch for rule delivery, capture, scheduling, automatic reflection, startup recovery and the TUI reminder. Explicit `learn` commands still work. | | `capture` | `ALTIMATE_LEARN_CAPTURE` (`1`/`true`, `0`/`false`) | `false` | Record signals. When this is off, nothing is captured, scheduled, or reflected automatically. Commands you run yourself (`reflect`, `signal add`, `bootstrap`) still work. | | `auto_reflect` | `ALTIMATE_LEARN_AUTO` (`1`/`true`, `0`/`false`) | `false` | Reflect on open signals automatically (end of `run`, threshold, idle, startup recovery). Needs `capture`. At the end of `run`, only that session is reflected, for at most 60 seconds; unfinished signals wait for the next run. | | `auto_promote` | `ALTIMATE_LEARN_AUTO_PROMOTE` (`1`/`true`, `0`/`false`) | `false` | Promote a candidate staged by automatic reflection without review when every gate passes. Needs `capture` and `auto_reflect`. See [Fully automatic mode](#fully-automatic-mode). | -| `auto_promote_max_changes` | `ALTIMATE_LEARN_AUTO_PROMOTE_MAX_CHANGES` | `3` | Maximum lessons one automatic promotion may add, edit, or remove. Larger candidates stay staged. | +| `auto_promote_max_changes` | `ALTIMATE_LEARN_AUTO_PROMOTE_MAX_CHANGES` | `3` | Maximum rules one automatic promotion may add, edit, or remove. Larger candidates stay staged. | | `auto_promote_daily` | `ALTIMATE_LEARN_AUTO_PROMOTE_DAILY` | `5` | Maximum automatic promotions per store in any 24 hours. `0` stops them. | | `model` | `ALTIMATE_LEARN_MODEL` | the session's model for reflection; the configured default model for `bootstrap` and `import-reviews` | Model (`provider/model`) for automatic reflection, `bootstrap`, and `import-reviews`. | -| `core_lessons` | `ALTIMATE_LEARN_CORE_LESSONS` | `15` | Maximum core lessons at session start. | -| `retrieved_lessons` | `ALTIMATE_LEARN_RETRIEVED_LESSONS` | `15` | Maximum retrieved lessons at session start. | -| `request_lessons` | `ALTIMATE_LEARN_REQUEST_LESSONS` | `5` | Maximum lessons added per later user message. `0` disables. | -| `file_hook` | `ALTIMATE_LEARN_FILE_HOOK` (`0`/`false` disables, `1`/`true` enables) | `true` | Add lessons that match a file the agent reads or edits. | -| `file_lessons` | `ALTIMATE_LEARN_FILE_LESSONS` | `5` | Maximum lessons added per file event. `0` disables. | -| `budget_tokens` | `ALTIMATE_LEARN_BUDGET_TOKENS` | `1500` | Token budget for the session-start "Team rules" section. Lessons that do not fit are left out. | -| `session_max_lessons` | `ALTIMATE_LEARN_SESSION_MAX_LESSONS` | `40` | Maximum distinct lessons shown in one session, across all tiers. | -| `max_stored` | `ALTIMATE_LEARN_MAX_STORED` | `1000` | Maximum stored lessons. Enforced during curation. Pinned lessons are kept. Must be 1 or more. | +| `core_lessons` | `ALTIMATE_LEARN_CORE_LESSONS` | `15` | Maximum core rules at session start. | +| `retrieved_lessons` | `ALTIMATE_LEARN_RETRIEVED_LESSONS` | `15` | Maximum retrieved rules at session start. | +| `request_lessons` | `ALTIMATE_LEARN_REQUEST_LESSONS` | `5` | Maximum rules added per later user message. `0` disables. | +| `file_hook` | `ALTIMATE_LEARN_FILE_HOOK` (`0`/`false` disables, `1`/`true` enables) | `true` | Add rules that match a file the agent reads or edits. | +| `file_lessons` | `ALTIMATE_LEARN_FILE_LESSONS` | `5` | Maximum rules added per file event. `0` disables. | +| `budget_tokens` | `ALTIMATE_LEARN_BUDGET_TOKENS` | `1500` | Token budget for the session-start "Team rules" section. Rules that do not fit are left out. | +| `session_max_lessons` | `ALTIMATE_LEARN_SESSION_MAX_LESSONS` | `40` | Maximum distinct rules shown in one session, across all tiers. | +| `max_stored` | `ALTIMATE_LEARN_MAX_STORED` | `1000` | Maximum stored rules. Enforced during curation. Pinned rules are kept. Must be 1 or more. | | `recovery_max_reflections` | `ALTIMATE_LEARN_RECOVERY_MAX_REFLECTIONS` | `3` | Reflections a process may run on leftover signals at startup. | | `recovery_max_seconds` | `ALTIMATE_LEARN_RECOVERY_MAX_SECONDS` | `300` | Time budget for startup recovery. | | `review_bots` | none | `[]` | Extra bot logins that `import-reviews` excludes. | @@ -485,28 +485,28 @@ The server config API (`/config`) accepts every `learn` key listed above. Config ## Cost -### What a lesson costs in context +### What a rule costs in context -Lessons are limited to 140 characters, so at most about 35 tokens each. In our benchmark, 300 short lessons added 5,400 tokens to a prompt compared with 15 retrieved ones, about 19 tokens per lesson; full-length one-line rules measured about 56 tokens each. A path scope adds a few tokens to lessons that have one. +Rules are limited to 140 characters, so at most about 35 tokens each. In our benchmark, 300 short rules added 5,400 tokens to a prompt compared with 15 retrieved ones, about 19 tokens per rule; full-length one-line rules measured about 56 tokens each. A path scope adds a few tokens to rules that have one. -At the default limits a session starts with up to 15 core and 15 retrieved lessons, so at most 30 lessons. That is about 1,050 tokens at 35 tokens each, under the 1,500-token section budget. Per-request and file additions can bring the total to 40 lessons (the `session_max_lessons` cap), roughly 1,400 tokens plus the scope shown on lessons with path triggers. The budget applies to the session-start section only; later additions are limited by count, not tokens. +At the default limits a session starts with up to 15 core and 15 retrieved rules, so at most 30 rules. That is about 1,050 tokens at 35 tokens each, under the 1,500-token section budget. Per-request and file additions can bring the total to 40 rules (the `session_max_lessons` cap), roughly 1,400 tokens plus the scope shown on rules with path triggers. The budget applies to the session-start section only; later additions are limited by count, not tokens. ### What a session pays The section is the same on every model call in a session, so providers that cache prompt prefixes charge much less for it after the first call. -The arithmetic below uses an **example price, not a quote**: $3.00 per million input tokens, cache reads at 10% of that ($0.30), and cache writes at 125% ($3.75). Replace them with your model's prices. The session is 50 model calls with 1,050 lesson tokens in each. +The arithmetic below uses an **example price, not a quote**: $3.00 per million input tokens, cache reads at 10% of that ($0.30), and cache writes at 125% ($3.75). Replace them with your model's prices. The session is 50 model calls with 1,050 rule tokens in each. -| | Calculation | Cost for the lessons | +| | Calculation | Cost for the rules | |---|---|---| | No caching | 50 calls × 1,050 tokens × $3.00 / 1M | $0.158 | | With caching | 1 write: 1,050 × $3.75 / 1M = $0.004; 49 reads: 49 × 1,050 × $0.30 / 1M = $0.015 | $0.019 | -Because the lesson section does not change between turns, it does not break the cache. The exception is compaction, which rebuilds the history anyway and folds in lessons added since the start. Lessons that arrive later are appended to messages and tool results, so they extend the history instead of changing the start of the prompt. +Because the rule section does not change between turns, it does not break the cache. The exception is compaction, which rebuilds the history anyway and folds in rules added since the start. Rules that arrive later are appended to messages and tool results, so they extend the history instead of changing the start of the prompt. ### What learning itself costs -Reflection is a model call. Each one sends the current lessons, the session digest (up to 24,000 characters), and the feedback (up to 12,000 characters). The replacement step adds a smaller call when a lesson was removed without a replacement. +Reflection is a model call. Each one sends the current rules, the session digest (up to 24,000 characters), and the feedback (up to 12,000 characters). The replacement step adds a smaller call when a rule was removed without a replacement. - Each reflection reports its token use and estimated cost. `learn reflect` prints them; `learn status --json` shows the last one. - `bootstrap` and `import-reviews` print an input-token estimate before you confirm, and a summary after (tokens, estimated cost, and whether the tokens were estimated because the provider did not report them). @@ -518,12 +518,12 @@ Measured in our benchmark (Gemini 3.1 Pro as the reflector, list prices): | Operation | Tokens | Cost | |---|---|---| | One reflection | about 7,000 input, 1,700 output | about $0.05 | -| `bootstrap` over 8 past sessions (4 reflections, 3 lessons) | 28,000 input, 6,700 output | $0.20, 54 seconds | +| `bootstrap` over 8 past sessions (4 reflections, 3 rules) | 28,000 input, 6,700 output | $0.20, 54 seconds | | `import-reviews --dry-run`, 10 merged PRs | none (GitHub only) | free | A reflection with a smaller model (Gemini 3.1 Flash-Lite) took 7–9 seconds instead of 70. -The lessons themselves did not change the cost of an agent run measurably while retrieval was on: runs cost $0.60–0.73 whether the project had 50, 300, or 1,000 lessons. Loading all 1,000 lessons into the prompt instead raised the first call from 21,000 to 58,000 input tokens and the cost per run by 34%. +The rules themselves did not change the cost of an agent run measurably while retrieval was on: runs cost $0.60–0.73 whether the project had 50, 300, or 1,000 rules. Loading all 1,000 rules into the prompt instead raised the first call from 21,000 to 58,000 input tokens and the cost per run by 34%. ## Privacy and safety @@ -531,17 +531,17 @@ The lessons themselves did not change the cost of an agent run measurably while | File | Contents | |---|---| -| `approved.json` | The live lessons. | -| `candidate.json` | Staged lessons waiting for review. | -| `retired.json` | Lessons that were superseded or removed. | +| `approved.json` | The live rules. | +| `candidate.json` | Staged rules waiting for review. | +| `retired.json` | Rules that were superseded or removed. | | `versions/` | Archived previous approved sets, used by `rollback`. | | `signals.jsonl` | Captured signals (redacted, clipped to 2,000 characters). | | `history.jsonl` | What each reflection and promote changed, with token use. | | `bootstrap.json`, `reviews.json` | Resume state for `bootstrap` and `import-reviews`. | | `schedule.json` | Last reflection and retry backoff. | -| `shown.jsonl` | Which lessons each session was shown. | -| `usage.json` | How often each lesson was delivered. Kept out of `approved.json` so the committed file does not change every session. | -| `harmful.json`, `pending-replacements.jsonl` | Harmful marks waiting for a second source, and removals waiting for a replacement lesson. | +| `shown.jsonl` | Which rules each session was shown. | +| `usage.json` | How often each rule was delivered. Kept out of `approved.json` so the committed file does not change every session. | +| `harmful.json`, `pending-replacements.jsonl` | Harmful marks waiting for a second source, and removals waiting for a replacement rule. | | `migration.json` | Record of a migration from the earlier skill-file format. | `promote --publish` also writes the exported skill to `.altimate-code/skills//SKILL.md`. @@ -556,117 +556,117 @@ Nothing is published to a workspace unless you run `promote --publish`. Reflecti | Operation | Sent | Model | |---|---|---| -| Automatic reflection, `learn reflect` | Redacted session digest, redacted feedback, current lessons | `--model`, `learn.model`, or the session's model | +| Automatic reflection, `learn reflect` | Redacted session digest, redacted feedback, current rules | `--model`, `learn.model`, or the session's model | | `bootstrap` | Redacted excerpts of past sessions in this project | `--model`, `learn.model`, or the default model | | `import-reviews` | Redacted review comments | `--model`, `learn.model`, or the default model | `bootstrap` and `import-reviews` show the scope and ask before sending, and `--dry-run` sends nothing. Automatic reflection does not ask each time. It is what you enabled with `learn enable`. It uses `learn.model` if set, otherwise the session's model. `import-reviews` also calls GitHub through `gh`. -**The human gate.** A candidate does nothing until you run `promote`. Lessons from your own sessions, from imported reviews, and from model output all go through the same step. The reflector is told to ignore instructions inside the data it reads, and the curator rejects lessons containing commands, URLs, paths, or injection phrases, but the promote step is the boundary you should rely on. Read the diff. +**The human gate.** A candidate does nothing until you run `promote`. Rules from your own sessions, from imported reviews, and from model output all go through the same step. The reflector is told to ignore instructions inside the data it reads, and the curator rejects rules containing commands, URLs, paths, or injection phrases, but the promote step is the boundary you should rely on. Read the diff. -**Flagged lessons.** A lesson that mentions skipping, ignoring, or disabling tests, checks, CI, or review is flagged. It is shown with a warning, and it needs interactive confirmation or `--yes --allow-flagged`. The flag looks only at the wording. It also fires on a lesson that says "never skip tests". +**Flagged rules.** A rule that mentions skipping, ignoring, or disabling tests, checks, CI, or review is flagged. It is shown with a warning, and it needs interactive confirmation or `--yes --allow-flagged`. The flag looks only at the wording. It also fires on a rule that says "never skip tests". **When learning is off.** With capture off (`learn.capture=false` or `learn disable`), nothing is captured automatically, no signals are written, and no model is called unless you run a learn command yourself. Learn commands, including `learn status`, create `.altimate-code/learn/` and its `.gitignore`. With the master switch still on, two further exceptions apply: -- If approved lessons already exist in the project, they are still delivered. Delivery writes session state under `.sessions/`, appends to `shown.jsonl`, and updates `usage.json`; it does not change `approved.json`. These files are not pruned automatically; delete `.sessions/` and `shown.jsonl` after active sessions have ended to reclaim space. +- If approved rules already exist in the project, they are still delivered. Delivery writes session state under `.sessions/`, appends to `shown.jsonl`, and updates `usage.json`; it does not change `approved.json`. These files are not pruned automatically; delete `.sessions/` and `shown.jsonl` after active sessions have ended to reclaim space. - The TUI reminder counts your corrections in memory for the current session and shows at most one tip per project and three in total across projects. Its only file is `learn-nudge.json` in the global state directory (normally `~/.local/state/altimate-code`). It holds hashed project ids, a count, and a dismissed flag, and no message text. `learn nudge off` or `learn enable` ends it permanently. -**Turning learn off completely.** Set `learn.enabled=false` in your config or `ALTIMATE_LEARN=0` in your environment. This also stops approved lesson delivery (including `.sessions/`, `shown.jsonl` and `usage.json` writes) and the TUI reminder, even if capture or automatic reflection is configured on. Existing data stays in place. `ALTIMATE_LEARN` overrides config in both directions: `0`/`false` disables and `1`/`true` enables, case-insensitively. Explicit `learn` commands still run; `learn status` reports `enabled: false` and how to re-enable. `learn enable` sets capture and automatic reflection but leaves the master switch unchanged and reports when learning remains disabled. +**Turning learn off completely.** Set `learn.enabled=false` in your config or `ALTIMATE_LEARN=0` in your environment. This also stops approved rule delivery (including `.sessions/`, `shown.jsonl` and `usage.json` writes) and the TUI reminder, even if capture or automatic reflection is configured on. Existing data stays in place. `ALTIMATE_LEARN` overrides config in both directions: `0`/`false` disables and `1`/`true` enables, case-insensitively. Explicit `learn` commands still run; `learn status` reports `enabled: false` and how to re-enable. `learn enable` sets capture and automatic reflection but leaves the master switch unchanged and reports when learning remains disabled. ## Benchmarks -We measured the effect of lessons on a dbt benchmark with held-out tasks: the agent writes models for tasks it has not seen, and checks verify the result. Each arm was 18 runs (9 held-out, 3 repeats of 3 tasks, plus 6 control runs). With 9 runs per arm, small differences are noise. +We measured the effect of rules on a dbt benchmark with held-out tasks: the agent writes models for tasks it has not seen, and checks verify the result. Each arm was 18 runs (9 held-out, 3 repeats of 3 tasks, plus 6 control runs). With 9 runs per arm, small differences are noise. -**Experiment 1: does the number of lessons matter?** Claude Haiku 4.5. The 4 real lessons were mixed with 21, 46, or 96 irrelevant team conventions. +**Experiment 1: does the number of rules matter?** Claude Haiku 4.5. The 4 real rules were mixed with 21, 46, or 96 irrelevant team conventions. -| Lessons loaded | Held-out pass | First-call input tokens | +| Rules loaded | Held-out pass | First-call input tokens | |---|---|---| | 4 (real only) | 8/9 | 33,304 | | 25 | 7/9 | 34,584 | | 50 | 9/9 | 36,051 | | 100 | 8/9 | 38,880 | -Irrelevant lessons up to 100 did not reduce adherence. They added about 56 tokens per rule to every call (+17% at 100). Cost, not quality, is the reason to keep the number small. Separately, when the playbook was a skill the agent had to open, it did not open it in 2 of 12 relevant runs, and both runs failed. +Irrelevant rules up to 100 did not reduce adherence. They added about 56 tokens per rule to every call (+17% at 100). Cost, not quality, is the reason to keep the number small. Separately, when the playbook was a skill the agent had to open, it did not open it in 2 of 12 relevant runs, and both runs failed. -**Experiment 2: lessons that apply, overreach, or conflict.** Same setup. +**Experiment 2: rules that apply, overreach, or conflict.** Same setup. | Arm | Held-out pass | |---|---| -| 4 real lessons | 8/9 | +| 4 real rules | 8/9 | | plus 36 applicable style rules (40 total) | 7/9 | -| the 4 lessons reworded without scope | 8/9 | -| **4 real plus 4 stale, contradicting lessons** | **1/9** | +| the 4 rules reworded without scope | 8/9 | +| **4 real plus 4 stale, contradicting rules** | **1/9** | Contradictions were the one thing that broke the agent. Size, applicability, and scope wording did not. This is why the curator enforces conflict handling. -**Experiment 3: conventions change.** The playbook starts with 4 outdated lessons, a simulated teammate reviews against the new conventions, and the loop runs for 2 iterations. This used Gemini models, so compare only within this experiment. +**Experiment 3: conventions change.** The playbook starts with 4 outdated rules, a simulated teammate reviews against the new conventions, and the loop runs for 2 iterations. This used Gemini models, so compare only within this experiment. | Setup | Held-out pass | |---|---| -| No lessons | 2/9 | -| Outdated lessons left in place | 0/9 | -| Hand-written correct lessons | 9/9 | +| No rules | 2/9 | +| Outdated rules left in place | 0/9 | +| Hand-written correct rules | 9/9 | | Loop before the conflict handling, weak reflector | 3/9 and 4/9 | | Loop with overlap guard, implicit supersede, and replacement step, weak reflector | 9/9 and 9/9 | | Same, strong reflector | 9/9 | -A stale lesson is worse than none. Without the replacement step, a weaker model marked stale lessons harmful but never wrote the correction, so knowledge was lost. The guard, implicit supersede, and replacement step fixed that. +A stale rule is worse than none. Without the replacement step, a weaker model marked stale rules harmful but never wrote the correction, so knowledge was lost. The guard, implicit supersede, and replacement step fixed that. -**Experiments 4–10: the shipped version.** Gemini 3.5 Flash as the agent; 18 runs per arm; a run whose agent turn did not complete counts as a failure. *Recall* is the share of the lessons a task needed that were actually shown to the agent. The 4 real lessons were hidden in pools of realistic lessons for other parts of the project, about 5% of which used the same staging vocabulary. +**Experiments 4–10: the shipped version.** Gemini 3.5 Flash as the agent; 18 runs per arm; a run whose agent turn did not complete counts as a failure. *Recall* is the share of the rules a task needed that were actually shown to the agent. The 4 real rules were hidden in pools of realistic rules for other parts of the project, about 5% of which used the same staging vocabulary. | Question | Arm | Held-out pass | Recall | |---|---|---|---| -| Baseline | No lessons | 2/9 | – | -| Baseline | 4 real lessons, always in the prompt | 9/9 | 100% | -| Scale | 50 lessons, retrieval | 9/9 | 100% | -| Scale | 300 lessons, retrieval | 9/9 | 100% | -| Scale | 1,000 lessons, retrieval | 9/9 | 100% | -| Scale | 1,000 lessons, all in the prompt | 9/9 | 100% | -| Short lessons | 4 real lessons, ≤140 characters vs full length | 8/9 vs 9/9 (the miss is an incomplete run) | 100% | -| Vague requests | 300 lessons, file hook off | 5/9 | 50% | -| Vague requests | 300 lessons, file hook on | 9/9 | 100% | -| Topic switch (second request) | 300 lessons, session start only | 5/9 | 43% | -| Topic switch (second request) | 300 lessons, plus per-request additions | 7/9 | 60% | -| Topic switch (second request) | 4 real lessons, always in the prompt | 7/9 | 100% | -| Outdated lessons | 4 outdated lessons, no learning | 0/9 | – | -| Outdated lessons | after 2 learning iterations, strong or weak reflector | 9/9 | – | -| Bootstrap | 3 lessons learned from 8 past sessions | 9/9 | – | +| Baseline | No rules | 2/9 | – | +| Baseline | 4 real rules, always in the prompt | 9/9 | 100% | +| Scale | 50 rules, retrieval | 9/9 | 100% | +| Scale | 300 rules, retrieval | 9/9 | 100% | +| Scale | 1,000 rules, retrieval | 9/9 | 100% | +| Scale | 1,000 rules, all in the prompt | 9/9 | 100% | +| Short rules | 4 real rules, ≤140 characters vs full length | 8/9 vs 9/9 (the miss is an incomplete run) | 100% | +| Vague requests | 300 rules, file hook off | 5/9 | 50% | +| Vague requests | 300 rules, file hook on | 9/9 | 100% | +| Topic switch (second request) | 300 rules, session start only | 5/9 | 43% | +| Topic switch (second request) | 300 rules, plus per-request additions | 7/9 | 60% | +| Topic switch (second request) | 4 real rules, always in the prompt | 7/9 | 100% | +| Outdated rules | 4 outdated rules, no learning | 0/9 | – | +| Outdated rules | after 2 learning iterations, strong or weak reflector | 9/9 | – | +| Bootstrap | 3 rules learned from 8 past sessions | 9/9 | – | What this shows: -- **Retrieval scales.** With the file hook and path triggers, every lesson a task needed was shown, up to 1,000 stored lessons, while the prompt stayed the same size. An earlier build missed the timestamp lesson at 1,000 lessons (6/9) because broad file-hook lessons filled the per-file limit; ranking by path specificity fixed it. -- **The file hook matters most** when a request does not name the convention it needs ("add a model for disputes"). The lesson arrives when the agent opens the file. +- **Retrieval scales.** With the file hook and path triggers, every rule a task needed was shown, up to 1,000 stored rules, while the prompt stayed the same size. An earlier build missed the timestamp rule at 1,000 rules (6/9) because broad file-hook rules filled the per-file limit; ranking by path specificity fixed it. +- **The file hook matters most** when a request does not name the convention it needs ("add a model for disputes"). The rule arrives when the agent opens the file. - **Topic switches are the weak spot.** A second, unrelated request in the same session gets only part of what it needs. Pin rules that must always apply, or start a new session for unrelated work. -- **Loading everything did not hurt quality on this model.** Retrieval's benefit was cost (26% cheaper per run at 1,000 lessons), not accuracy. We could not run this comparison on Claude. -- **Review import** fetched 291 comments from 10 merged PRs and kept 17 after dropping bots, PR-author replies, duplicates, and trivial comments. We did not score the quality of lessons learned from them. +- **Loading everything did not hurt quality on this model.** Retrieval's benefit was cost (26% cheaper per run at 1,000 rules), not accuracy. We could not run this comparison on Claude. +- **Review import** fetched 291 comments from 10 merged PRs and kept 17 after dropping bots, PR-author replies, duplicates, and trivial comments. We did not score the quality of rules learned from them. Full setup, arms, and caveats are in the research notes in the repository under `research/rsi-workspace-learning-2026-09-30/` (`context-budget.md`, `findings.md`, `learn-v1-results.md`). ## Limitations -- **Retrieval is keyword-based.** Search is BM25 over lesson text, tags, and path triggers. A request that shares no words, identifiers, or files with a lesson will not retrieve it. Pin lessons that must always apply (`learn pin `) so they are in the core tier. -- **Core selection is by score.** The core tier is the top lessons by pin, then helpful minus harmful. It is not a judgement of which lessons are project-wide. -- **Only the first request picks the retrieved tier.** Later messages can add lessons (up to 5 each), but the session-start section does not change after the first turn, except through compaction. +- **Retrieval is keyword-based.** Search is BM25 over rule text, tags, and path triggers. A request that shares no words, identifiers, or files with a rule will not retrieve it. Pin rules that must always apply (`learn pin `) so they are in the core tier. +- **Core selection is by score.** The core tier is the top rules by pin, then helpful minus harmful. It is not a judgement of which rules are project-wide. +- **Only the first request picks the retrieved tier.** Later messages can add rules (up to 5 each), but the session-start section does not change after the first turn, except through compaction. - **Guard rails are pattern-based.** The correction classifier, lint rules, secret redaction, and verification flags are regular expressions and heuristics. They miss some cases and flag some harmless ones. -- **Conflict detection sees backticked identifiers.** Two lessons that contradict each other in prose, with no shared identifier in backticks, depend on the reflector and the replacement step. +- **Conflict detection sees backticked identifiers.** Two rules that contradict each other in prose, with no shared identifier in backticks, depend on the reflector and the replacement step. - **Reflection quality depends on the model.** In our test a weaker reflector needed the replacement step to reach the same result as a stronger one. You still review every candidate. - **Review import is GitHub only.** GitLab, Bitbucket, and other hosts are not supported. It reads merged PRs, not open ones. -- **Sharing with the team is manual for now.** Lessons live in the project's `.altimate-code/learn/` directory. `promote --publish` exports them as a single-file workspace skill. Publishing a name that another checkout already published is refused unless you pass `--replace`, which only updates a skill you own. Per-lesson sharing and review in a workspace needs backend changes to workspace memory that are not shipped. You can also commit the lessons: learn creates `.altimate-code/learn/.gitignore`, which ignores everything except each store's `approved.json`, and delivery reads a committed `approved.json` in any checkout. -- **One lesson set per `--name`.** Delivery reads every store in the project that has an `approved.json`. -- **Keyword retrieval can over-apply a lesson.** A lesson scoped to one kind of file can be retrieved for a request elsewhere that uses the same words, and the agent sometimes follows it there. In our benchmark a staging-model money rule was applied to an analysis that had to keep integer cents in 1 of 6 runs, in three of the retrieval arms. Showing the lesson's scope did not remove it. Write the scope into the lesson's path triggers. +- **Sharing with the team is manual for now.** Rules live in the project's `.altimate-code/learn/` directory. `promote --publish` exports them as a single-file workspace skill. Publishing a name that another checkout already published is refused unless you pass `--replace`, which only updates a skill you own. Per-rule sharing and review in a workspace needs backend changes to workspace memory that are not shipped. You can also commit the rules: learn creates `.altimate-code/learn/.gitignore`, which ignores everything except each store's `approved.json`, and delivery reads a committed `approved.json` in any checkout. +- **One rule set per `--name`.** Delivery reads every store in the project that has an `approved.json`. +- **Keyword retrieval can over-apply a rule.** A rule scoped to one kind of file can be retrieved for a request elsewhere that uses the same words, and the agent sometimes follows it there. In our benchmark a staging-model money rule was applied to an analysis that had to keep integer cents in 1 of 6 runs, in three of the retrieval arms. Showing the rule's scope did not remove it. Write the scope into the rule's path triggers. - **Benchmarks are on dbt tasks, with small samples.** The results show direction, not exact lift for your repository. - **Model availability is not a product limit.** Experiments 3–10 used Gemini models because Claude models were not available to the benchmark environment at that time. Learn works with any provider the CLI supports. ## Troubleshooting -**`learn show` lists no lessons, or the agent does not seem to know them.** -Only approved lessons are delivered. Check `learn status` for `candidate` counts and run `learn show` then `learn promote`. Lessons are selected when a session's first request is processed, so start a new session after promoting. If the lessons exist but a particular one did not appear, it either did not match your request or ranked below the count and budget limits; see [Limitations](#limitations) and consider pinning it. Also check that `core_lessons`, `retrieved_lessons`, and `budget_tokens` are not set to `0`, and that `learn.budget_tokens` is large enough for your lessons. +**`learn show` lists no rules, or the agent does not seem to know them.** +Only approved rules are delivered. Check `learn status` for `candidate` counts and run `learn show` then `learn promote`. Rules are selected when a session's first request is processed, so start a new session after promoting. If the rules exist but a particular one did not appear, it either did not match your request or ranked below the count and budget limits; see [Limitations](#limitations) and consider pinning it. Also check that `core_lessons`, `retrieved_lessons`, and `budget_tokens` are not set to `0`, and that `learn.budget_tokens` is large enough for your rules. **Nothing was learned.** Run `learn status`. If capture is `off`, run `learn enable`. If capture is on, look at `learn signals`: with no open signals there is nothing to reflect on. The classifier only counts a message as a correction after the assistant has replied once, and it is deliberately strict. For a correction it missed, record it with `learn signal add --kind user --text "..."`. If signals exist but no candidate appeared, automatic reflection may not have run yet (the TUI waits for 3 signals or 10 idle minutes); run `learn reflect --pending`. A failed automatic reflection keeps its signals and retries at the next trigger after its backoff; the error shows in `learn status`. A failed `learn reflect` prints its error and may leave an older success under "Last reflection". -**Too many lessons are flagged, or promote refuses.** -Flagged lessons mention skipping, ignoring, or disabling tests, checks, CI, or review, even to forbid it. Read each `WARNING` in `learn show`. If they are fine, confirm interactively or run `learn promote --yes --allow-flagged`. If a lesson is wrong, run `learn reject`, or edit the candidate and promote again. A "refusing to promote" message about overlaps means two lessons share a backticked identifier without declaring how they relate; run `learn reflect` again so the reflector can resolve it, or edit the candidate. +**Too many rules are flagged, or promote refuses.** +Flagged rules mention skipping, ignoring, or disabling tests, checks, CI, or review, even to forbid it. Read each `WARNING` in `learn show`. If they are fine, confirm interactively or run `learn promote --yes --allow-flagged`. If a rule is wrong, run `learn reject`, or edit the candidate and promote again. A "refusing to promote" message about overlaps means two rules share a backticked identifier without declaring how they relate; run `learn reflect` again so the reflector can resolve it, or edit the candidate. **`import-reviews` fails with an auth or access error.** The command checks three things in order. `gh --version` must work (install from `https://cli.github.com/`). `gh auth status --hostname ` must succeed (run `gh auth login --hostname `; for GitHub Enterprise use that host). The account must be able to read the repository (`gh api repos//`). If the remote is not GitHub, pass `--repo owner/name` for a GitHub repository. If you hit GitHub's 1,000-result search limit, use a narrower `--since`. diff --git a/packages/opencode/src/altimate/learn/auto-promote.ts b/packages/opencode/src/altimate/learn/auto-promote.ts index bf499bd8d4..7eee0ab282 100644 --- a/packages/opencode/src/altimate/learn/auto-promote.ts +++ b/packages/opencode/src/altimate/learn/auto-promote.ts @@ -109,7 +109,7 @@ export async function voidAutoOwnership(root: string, name: string): Promise verificationWarning(lesson.text)) - if (flagged.length) return held(`flagged lesson ${ids(flagged)} mentions skipping or weakening verification`) + if (flagged.length) return held(`flagged rule ${ids(flagged)} mentions skipping or weakening verification`) // Gate: a person's approval is never undone automatically. const auto = autoPromotedIds(state, approved) const person = [...edited, ...removed].filter((lesson) => !auto.has(lesson.id)) - if (person.length) return held(`it would edit or remove person-approved lesson ${ids(person)}`) + if (person.length) return held(`it would edit or remove person-approved rule ${ids(person)}`) // Gate: size. const count = changed.length + removed.length if (count > input.limits.auto_promote_max_changes) - return held(`${count} lesson changes exceed learn.auto_promote_max_changes (${input.limits.auto_promote_max_changes})`) + return held(`${count} rule changes exceed learn.auto_promote_max_changes (${input.limits.auto_promote_max_changes})`) // Gate: rate. Reservations count; timestamps in the future count, so a clock change cannot reopen the window. const recent = state.promotions.filter((promotion) => Date.parse(promotion.at) > now - DAY_MS) if (recent.length >= input.limits.auto_promote_daily) diff --git a/packages/opencode/src/altimate/learn/auto.ts b/packages/opencode/src/altimate/learn/auto.ts index cabbc93e83..1f94cc84b9 100644 --- a/packages/opencode/src/altimate/learn/auto.ts +++ b/packages/opencode/src/altimate/learn/auto.ts @@ -56,7 +56,7 @@ export function describeOutcome( ): string { const head = `learn: ${signals} signal${signals === 1 ? "" : "s"} -> ${summary}` if (promotion?.status === "promoted") - return `${head}; auto-promoted${promotion.archived ? ` (previous lessons archived as v${promotion.archived})` : ""}. Undo with \`altimate-code learn rollback\`` + + return `${head}; auto-promoted${promotion.archived ? ` (previous rules archived as v${promotion.archived})` : ""}. Undo with \`altimate-code learn rollback\`` + (promotion.warning ? ` (warning: ${promotion.warning})` : "") if (!candidate) return head if (promotion?.status === "held") diff --git a/packages/opencode/src/altimate/learn/bootstrap.ts b/packages/opencode/src/altimate/learn/bootstrap.ts index ce23c2cd65..d4cf567567 100644 --- a/packages/opencode/src/altimate/learn/bootstrap.ts +++ b/packages/opencode/src/altimate/learn/bootstrap.ts @@ -225,7 +225,7 @@ export async function bootstrap(options: BootstrapOptions, deps: BootstrapDeps): deps.out(`Signals found: ${all.length} (${all.filter((s) => s.kind === "user_correction").length} corrections, ${all.filter((s) => s.kind === "tool_retry").length} tool failures; ${all.length - plans.reduce((n, p) => n + p.signals.length, 0)} pending).`) deps.out(`Model/provider: ${label}. Estimated input tokens: ${estimatedInput} for up to ${estimatedReflections} reflection(s), excluding candidate growth.`) deps.out(`Limits: ${limit} sessions, ${maxReflections} reflections, ${maxSeconds}s total after confirmation.`) - deps.out(model ? "Bootstrap sends redacted excerpts of past sessions to this model and stages candidate lessons only." + deps.out(model ? "Bootstrap sends redacted excerpts of past sessions to this model and stages candidate rules only." : "Bootstrap imports signals locally without model calls.") if (options.dryRun) { for (const signal of all) deps.out(`[${signal.sessionID}] [${signal.kind}] ${Signals.clipSignalText(signal.text)}`) @@ -338,7 +338,7 @@ export async function bootstrap(options: BootstrapOptions, deps: BootstrapDeps): } } } - deps.out(`Bootstrap summary: ${summary.signalsFound} signals found, ${summary.signalsAdded} added; ${summary.reflectionsRun} reflections run; candidate lessons: ${summary.candidatesAdded} added, ${summary.candidatesEdited} edited.`) + deps.out(`Bootstrap summary: ${summary.signalsFound} signals found, ${summary.signalsAdded} added; ${summary.reflectionsRun} reflections run; candidate rules: ${summary.candidatesAdded} added, ${summary.candidatesEdited} edited.`) deps.out(`Tokens${summary.tokensEstimated ? " (estimated)" : ""}: ${summary.inputTokens} input, ${summary.outputTokens} output. Estimated cost: $${summary.estimatedCost.toFixed(6)}.`) if (!ready() || stop || (await Signals.readSignalsSnapshot(options.root, name)).some((s) => s.source === "bootstrap" && s.status === "open")) deps.out("Unfinished signals remain queued; re-run `learn bootstrap` to continue.") diff --git a/packages/opencode/src/altimate/learn/import-reviews.ts b/packages/opencode/src/altimate/learn/import-reviews.ts index 2f094a0451..f1bb31d784 100644 --- a/packages/opencode/src/altimate/learn/import-reviews.ts +++ b/packages/opencode/src/altimate/learn/import-reviews.ts @@ -176,7 +176,7 @@ export async function importReviews(options: ImportReviewsOptions, deps: ImportR deps.out(`Review import scope: ${key}; ${prs.length} PR(s), ${summary.commentsFetched} comment(s) fetched, ${all.length} signal(s) (${all.length - signals.length} pending).`) deps.out(`Merged since ${new Date(fetched?.cursor.since ?? since).toISOString()}; newest updated PRs first; limit ${limit}.`) deps.out(`Model/provider: ${label}. Estimated input tokens: ${estimatedInput} for up to ${estimatedReflections} reflection(s), excluding candidate growth.`) - deps.out(model ? "Review import sends these redacted comments to this model and stages candidate lessons only." + deps.out(model ? "Review import sends these redacted comments to this model and stages candidate rules only." : "No comments will be sent to a model; eligible review signals are stored locally only.") if (fetched?.paused) deps.out(`GitHub rate limit low; fetch paused${fetched.resetAt ? ` until ${fetched.resetAt}` : ""}. Rerun to resume from the last completed PR.`) if (fetched?.truncated) deps.out("GitHub's 1,000-result search ceiling was reached. Use a narrower --since for further imports; the completed comments remain available below.") @@ -285,7 +285,7 @@ export async function importReviews(options: ImportReviewsOptions, deps: ImportR s.messageID && pr.messageIDs.includes(s.messageID))) }, name) deps.out(summaryLine(summary)) - deps.out(`${summary.reflectionsRun} reflections run; candidate lessons: ${summary.candidatesAdded} added, ${summary.candidatesEdited} edited.`) + deps.out(`${summary.reflectionsRun} reflections run; candidate rules: ${summary.candidatesAdded} added, ${summary.candidatesEdited} edited.`) deps.out(`Tokens${summary.tokensEstimated ? " (estimated)" : ""}: ${summary.inputTokens} input, ${summary.outputTokens} output. Estimated cost: $${summary.estimatedCost.toFixed(6)}.`) if (updated.repositories[key].cursor?.truncated) deps.out("GitHub search was truncated; use a narrower --since for further fetching.") diff --git a/packages/opencode/src/altimate/learn/lock.ts b/packages/opencode/src/altimate/learn/lock.ts index f539c162cb..81dff7930a 100644 --- a/packages/opencode/src/altimate/learn/lock.ts +++ b/packages/opencode/src/altimate/learn/lock.ts @@ -61,7 +61,7 @@ export async function withLearnLock(root: string, task: () => Promise, opt // Every learn writer enters here after Flock creates the directory. Share approved // lessons with the team while keeping operational state local; preserve user edits. const file = path.join(dir, ".gitignore") - const rules = "# Share approved lessons; keep signals and other local learning state out of Git.\n*\n!/*/\n!/*/approved.json\n!/.gitignore\n" + const rules = "# Share approved rules; keep signals and other local learning state out of Git.\n*\n!/*/\n!/*/approved.json\n!/.gitignore\n" await assertLearnLock(key) await SafeFS.open(key, file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL).then(async (handle) => { try { await handle.writeFile(rules) } finally { await handle.close() } diff --git a/packages/opencode/src/altimate/learn/recurrence.ts b/packages/opencode/src/altimate/learn/recurrence.ts index 1efe985750..4735949e3c 100644 --- a/packages/opencode/src/altimate/learn/recurrence.ts +++ b/packages/opencode/src/altimate/learn/recurrence.ts @@ -154,8 +154,8 @@ export function formatRecurrence(result: Awaited ` ${lesson.name}/${lesson.id}: ${lesson.sessions_shown} sessions shown; ${lesson.recurred} matching correction after exposure; ${lesson.during} during turn; ${lesson.unknown} unknown order; ${lesson.ambiguous} ambiguous; ${lesson.earlier_version} earlier version`), - ` ${result.lessons.filter((lesson) => !corrected(lesson)).length} other shown lessons: no observed correction`, - " Topics with no matching current lesson:", + ` ${result.lessons.filter((lesson) => !corrected(lesson)).length} other shown rules: no observed correction`, + " Topics with no matching current rule:", ...result.unmatched_topics.map((topic) => ` ${topic.identifier}: ${topic.sessions} sessions (${topic.signal_ids.join(", ")})`), ].join("\n") } diff --git a/packages/opencode/src/altimate/learn/session-reflect.ts b/packages/opencode/src/altimate/learn/session-reflect.ts index 9edd775d2c..f5e9ff0d13 100644 --- a/packages/opencode/src/altimate/learn/session-reflect.ts +++ b/packages/opencode/src/altimate/learn/session-reflect.ts @@ -91,7 +91,7 @@ export async function prepareReflection(root: string, name: string, applyPaths?: const raw = (await Store.readCandidate(root, name)) ?? (await Store.readPromoted(root, name)) if (raw !== undefined) { const bad = Store.validateCandidate(name, raw, { allowOverlap: true, grandfathered: await Store.grandfathered(root, name) }) - if (bad) throw new Error(`Unsafe lesson store: ${bad}. Repair it or run \`learn reject\` before reflecting.`) + if (bad) throw new Error(`Unsafe rule store: ${bad}. Repair it or run \`learn reject\` before reflecting.`) } const pb = await Store.loadCandidate(root, name, { applyPaths }) await Store.readHarmfulFrom(root, name) diff --git a/packages/opencode/src/altimate/learn/store.ts b/packages/opencode/src/altimate/learn/store.ts index 36605a474d..c11b4884ac 100644 --- a/packages/opencode/src/altimate/learn/store.ts +++ b/packages/opencode/src/altimate/learn/store.ts @@ -206,9 +206,9 @@ export async function setPinned(root: string, name: string, id: string, pinned: const lesson = approved.find((lesson) => lesson.id === id) if (!lesson) { const matches = (await search(root, name, id)).slice(0, 5) - throw new StoreError(`Unknown approved lesson "${id}" in "${name}". ` + (matches.length + throw new StoreError(`Unknown approved rule "${id}" in "${name}". ` + (matches.length ? `Close matches from \`learn search\`:\n${matches.map(({ lesson, state }) => ` [${lesson.id}] ${state}: ${lesson.text}`).join("\n")}` - : "No close matches. Use `learn search ` to find approved lessons.")) + : "No close matches. Use `learn search ` to find approved rules.")) } const candidate = await loadCandidateLessons(root, name) const staged = candidate?.find((lesson) => lesson.id === id) @@ -350,7 +350,7 @@ export async function exportSkill(root: string, name: string): Promise { const p = paths(root, name) const existing = await existingExport(root, name) const lessons = await loadApproved(root, name) - if (await read(root, p.approved) === undefined) throw new StoreError(`No approved lessons for "${name}".`) + if (await read(root, p.approved) === undefined) throw new StoreError(`No approved rules for "${name}".`) if (existing !== undefined) await verifyExportHash(root, name, existing) await writeExport(root, name, skillText(name, lessons), existing) return p.skill @@ -568,7 +568,7 @@ export function validateCandidate(name: string, text: string, opts: PromoteOptio if (bad) return bad list = Playbook.bullets(Playbook.parse(text)) } - } catch (error) { return `invalid lesson snapshot: ${error instanceof Error ? error.message : String(error)}` } + } catch (error) { return `invalid rule snapshot: ${error instanceof Error ? error.message : String(error)}` } const ids = new Set(list.map((b) => b.id)) for (const b of list) { const bad = lint(b.text, { grandfathered: opts.grandfathered?.some((old) => old.id === b.id && old.text === b.text) }) ?? (normalizeText(b.text) !== b.text ? "contains hidden or non-normalized characters" : undefined) @@ -642,14 +642,14 @@ export async function promote(root: string, name: string, opts: PromoteOptions = const warnings = verificationWarnings(source) if (warnings.length && !opts.allowFlagged) throw new StoreError( - `Refusing to promote flagged lessons without explicit approval:\n${warnings.join("\n")}\n` + + `Refusing to promote flagged rules without explicit approval:\n${warnings.join("\n")}\n` + "Review with `learn promote` interactively, or pass `--yes --allow-flagged` to approve them.", ) // Publish the canonical serialization of what was validated (LF endings), not the raw file. const publish = Lessons.canonical(Lessons.parse(source)) const current = await readPromoted(root, name) if (current !== undefined && Lessons.canonical(Lessons.parse(current)) === publish) - throw new StoreError(`Candidate is identical to the approved lessons; nothing to promote.`) + throw new StoreError(`Candidate is identical to the approved rules; nothing to promote.`) // A person's promote ends automatic ownership before anything is written; auto-promote's own publish does not. if (opts.history?.action !== "auto-promote") await voidAutoOwnership(root, name) let archived: number | undefined @@ -698,7 +698,7 @@ export async function rollback(root: string, name: string): Promise<{ restored: } catch (error) { if (!(error instanceof StoreError)) throw error existing = undefined - try { process.stderr.write(`learn: left ${p.skill} unchanged; the export needs separate reconciliation. Restoring local approved lessons.\n`) } + try { process.stderr.write(`learn: left ${p.skill} unchanged; the export needs separate reconciliation. Restoring local approved rules.\n`) } catch { /* Diagnostics must not interrupt the local rollback. */ } } } diff --git a/packages/opencode/src/cli/cmd/learn.ts b/packages/opencode/src/cli/cmd/learn.ts index 67fca41a78..66a47de6e2 100644 --- a/packages/opencode/src/cli/cmd/learn.ts +++ b/packages/opencode/src/cli/cmd/learn.ts @@ -58,7 +58,7 @@ const nameOption = (yargs: Argv) => yargs.option("name", { type: "string", default: Playbook.DEFAULT_NAME, - describe: "lesson store name", + describe: "rule store name", }) /** The project root: where `.altimate-code/` lives and what `skill publish` treats as the boundary. */ @@ -301,7 +301,7 @@ const NudgeCommand = cmd({ const StatusCommand = effectCmd({ command: "status", - describe: "show learning settings, lesson counts, signals and reflection status", + describe: "show learning settings, rule counts, signals and reflection status", builder: (yargs: Argv) => nameOption(yargs).option("json", { type: "boolean", default: false, describe: "machine-readable output" }), handler: Effect.fn("Cli.learn.status")(function* (args) { yield* run("", async () => { @@ -401,20 +401,23 @@ const StatusCommand = effectCmd({ // altimate_change start — surface the learning kill switch in human-readable status if (status.note) out(status.note) // altimate_change end - out(`Capture: ${status.capture ? "on" : "off"}; automatic reflection: ${status.auto_reflect ? "on" : "off"}`) + // One line for learning from sessions, effective value and who decided it (your setting, the linked + // workspace's "Learn from sessions", or the default); the JSON keeps `capture` (your setting) too. + const source = { you: "your setting", workspace: "workspace setting", default: "default" }[status.rules.learning_source] + out(`Learning from sessions: ${status.rules.learning ? "on" : "off"} (${source}); automatic reflection: ${status.auto_reflect ? "on" : "off"}`) // altimate_change start — automatic promotion status out(`Automatic promotion: ${status.auto_promote ? "on" : "off"}`) if (status.auto_promote_error) out(`Automatic promotion state unreadable: ${status.auto_promote_error}`) const promotion = status.last_auto_promotion out(promotion - ? `Last auto-promotion: ${promotion.at} - ${[promotion.lessons.join(", "), promotion.removed?.length ? `removed ${promotion.removed.join(", ")}` : ""].filter(Boolean).join("; ") || "no lesson ids"}${promotion.archived ? `; previous lessons archived as v${promotion.archived}` : ""}` + ? `Last auto-promotion: ${promotion.at} - ${[promotion.lessons.join(", "), promotion.removed?.length ? `removed ${promotion.removed.join(", ")}` : ""].filter(Boolean).join("; ") || "no rule ids"}${promotion.archived ? `; previous rules archived as v${promotion.archived}` : ""}` : "Last auto-promotion: never") if (status.last_held_back) out(`Last held back: ${status.last_held_back.at} - ${status.last_held_back.reason}`) // altimate_change end out(`File hook: ${status.file_hook ? "on" : "off"}`) out(`Local data: ${status.data}`) - out(`Lessons: ${status.approved} approved, ${status.candidate} candidate, ${status.retired} retired`) - out(`Pinned lessons: ${status.pinned.length}${status.pinned.length ? ` (${status.pinned.join(", ")})` : ""}`) + out(`Rules: ${status.approved} approved, ${status.candidate} candidate, ${status.retired} retired`) + out(`Pinned rules: ${status.pinned.length}${status.pinned.length ? ` (${status.pinned.join(", ")})` : ""}`) out(`Open signals: ${status.open_signals}`) out("recurrence" in status ? formatRecurrence(status.recurrence) : `Recurrence unavailable: ${status.recurrence_error}`) out(`Pending recoveries: ${status.pending_recoveries} session(s); ${status.backoff_sessions} in backoff`) @@ -429,7 +432,6 @@ const StatusCommand = effectCmd({ else { const { RulesSummary } = await import("../../altimate/rules/summary") out(`Workspace rules: ${rules.workspace?.name ?? "workspace"} — ${RulesSummary.sidebarLine(rules)}${rules.pulled_at ? `; last sync ${rules.pulled_at}` : ""}${rules.last_error ? `; last error: ${rules.last_error}` : ""}`) - out(`Learning from sessions: ${rules.learning ? "on" : "off"} (${rules.learning_source === "you" ? "your setting" : "workspace setting"})`) if (rules.last_check) out(`Last proposal check: ${rules.last_check.at} — ${rules.last_check.outcome}`) for (const notice of rules.notices.slice(0, 5)) out(` ${notice.status === "active" ? "approved" : notice.status}${notice.reviewer ? ` by ${notice.reviewer}` : ""}: "${notice.text}"${notice.reason ? ` — ${notice.reason}` : ""}`) @@ -467,7 +469,7 @@ const SyncCommand = effectCmd({ const BootstrapCommand = effectCmd({ command: "bootstrap", - describe: "seed candidate lessons from this project's past sessions", + describe: "seed candidate rules from this project's past sessions", builder: (yargs: Argv) => nameOption(yargs) .option("since", { type: "string", default: "30d", describe: "session creation boundary: duration (30d, 24h, 4w) or ISO date" }) .option("limit", { type: "number", default: DEFAULT_BOOTSTRAP_LIMIT, describe: "maximum root sessions to inspect" }) @@ -476,7 +478,7 @@ const BootstrapCommand = effectCmd({ .option("dry-run", { type: "boolean", default: false, describe: "print scope and redacted signals; send nothing and leave bootstrap state unchanged" }) .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. Lessons are candidates only: learn show, then learn promote; in a project linked to a workspace they go to the workspace owner's review instead. 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 go to the workspace owner's review instead. 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 = yield* run("", async () => @@ -534,7 +536,7 @@ const BootstrapCommand = effectCmd({ const ImportReviewsCommand = effectCmd({ command: "import-reviews", - describe: "seed candidate lessons from human reviews of merged GitHub pull requests", + describe: "seed candidate rules from human reviews of merged GitHub pull requests", builder: (yargs: Argv) => nameOption(yargs) .option("repo", { type: "string", describe: "owner/name (default: the project's GitHub or GitHub Enterprise remote)" }) .option("since", { type: "string", default: "30d", describe: "merge boundary: duration (30d, 24h, 4w) or ISO date" }) @@ -548,7 +550,7 @@ 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" }) .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. Lessons are candidates only: learn show, then learn promote; in a project linked to a workspace they go to the workspace owner's review instead. 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 go to the workspace owner's review instead. 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 = yield* run("", async () => @@ -610,7 +612,7 @@ const ReflectCommand = effectCmd({ // yargs reads `--feedback -` as an option with no value plus a stray `-` positional; a hidden // optional positional absorbs the `-` so `.strict()` still rejects genuinely unknown arguments. command: "reflect [stdin]", - describe: "stage lesson edits from a session and external feedback", + describe: "stage rule edits from a session and external feedback", builder: (yargs: Argv) => nameOption(yargs) .positional("stdin", { type: "string", hidden: true }) @@ -630,7 +632,7 @@ const ReflectCommand = effectCmd({ .option("apply-paths", { type: "array", string: true, - describe: "path triggers for lessons in a new store (no default)", + describe: "path triggers for rules in a new store (no default)", }) .option("model", { type: "string", alias: ["m"], describe: "model to use in the format of provider/model" }) .option("timeout", { @@ -877,7 +879,7 @@ const SignalCommand = cmd({ const ShowCommand = effectCmd({ command: "show", - describe: "show approved lessons, the candidate, flags and their diff", + describe: "show approved rules, the candidate, flags and their diff", builder: (yargs: Argv) => nameOption(yargs), handler: Effect.fn("Cli.learn.show")(function* (args) { const name = args.name as string @@ -914,7 +916,7 @@ const ShowCommand = effectCmd({ if (diff) out(diff.trimEnd()) out(`\nPending recoveries: ${pending.length}`) for (const recovery of pending) out(` [${recovery.id}] ${recovery.text} (attempts: ${recovery.attempts})`) - if (!hasApproved && candidate === undefined) out(`\nNo lesson store "${name}" yet. ${START_HINT}`) + if (!hasApproved && candidate === undefined) out(`\nNo rule store "${name}" yet. ${START_HINT}`) else if (diff) out("\nRun `altimate-code learn promote` to make the candidate live, or `learn reject` to discard it.") }) }), @@ -922,14 +924,14 @@ const ShowCommand = effectCmd({ const SearchCommand = effectCmd({ command: "search ", - describe: "search approved and retired lessons by text or tags", + describe: "search approved and retired rules by text or tags", builder: (yargs: Argv) => nameOption(yargs).positional("query", { type: "string", demandOption: true }), handler: Effect.fn("Cli.learn.search")(function* (args) { yield* run("", async () => { const root = await projectRoot() const name = args.name as string const matches = await Store.search(root, name, args.query as string) - if (!matches.length) return out("No matching lessons.") + if (!matches.length) return out("No matching rules.") for (const { lesson, state } of matches) out(`[${lesson.id}] ${state}: ${lesson.text}`) }) }), @@ -939,7 +941,7 @@ function pinCommand(pinned: boolean) { const action = pinned ? "pin" : "unpin" return effectCmd({ command: `${action} `, - describe: pinned ? "pin an approved lesson to the core tier and protect it from eviction" : "unpin an approved lesson", + describe: pinned ? "pin an approved rule to the core tier and protect it from eviction" : "unpin an approved rule", builder: (yargs: Argv) => nameOption(yargs).positional("id", { type: "string", demandOption: true }), handler: Effect.fn(`Cli.learn.${action}`)(function* (args) { yield* run("", async () => { @@ -952,14 +954,14 @@ function pinCommand(pinned: boolean) { const PromoteCommand = effectCmd({ command: "promote", - describe: "make the candidate the approved lesson set", + describe: "make the candidate the approved rule set", builder: (yargs: Argv) => nameOption(yargs) .option("yes", { type: "boolean", default: false, describe: "skip the confirmation prompt" }) .option("allow-flagged", { type: "boolean", default: false, - describe: "with --yes: approve lessons flagged for mentioning skipping or disabling verification", + describe: "with --yes: approve rules flagged for mentioning skipping or disabling verification", }) .option("publish", { type: "boolean", default: false, describe: "publish to the bound workspace afterwards" }) .option("replace", { @@ -985,7 +987,7 @@ const PromoteCommand = effectCmd({ (args.publish ? " Publishing through `learn promote --publish` requires a changed candidate." : ""), ) } - out("Review the lessons below before making this candidate the approved set (and sharing it with your team if you publish).") + out("Review the rules below before making this candidate the approved set (and sharing it with your team if you publish).") out(diff.trimEnd()) if (!args.yes) { if (!process.stdin.isTTY || !process.stdout.isTTY) @@ -1071,19 +1073,19 @@ const RejectCommand = effectCmd({ const LEARN_HELP = [ "Concepts:", - " approved the live lesson set in .altimate-code/learn//approved.json", - " candidate staged lessons from `reflect` in candidate.json; review before approval", + " approved the live rule set in .altimate-code/learn//approved.json", + " candidate staged rules from `reflect` in candidate.json; review before approval", " promote make the candidate the approved set (the previous set is archived)", - " publish export approved lessons as a skill and share with the workspace (`promote --publish`)", + " publish export approved rules as a skill and share with the workspace (`promote --publish`)", "", "Workflow: reflect on a session with feedback, review with show, promote.", " altimate-code learn reflect --session --feedback ci.log --feedback-kind ci", " altimate-code learn show", - " altimate-code learn search 'timestamp' # approved and retired lessons", + " altimate-code learn search 'timestamp' # approved and retired rules", " altimate-code learn promote", " altimate-code learn rollback # undo the last promote", - " altimate-code learn pin # keep an approved lesson in the core tier and protect it from eviction", - " altimate-code learn unpin # clear the pin on an approved lesson", + " altimate-code learn pin # keep an approved rule in the core tier and protect it from eviction", + " altimate-code learn unpin # clear the pin on an approved rule", "Find session ids with `altimate-code session list`. Pipe feedback with `--feedback -`.", "", "Automatic capture (local only, opt-in): `altimate-code learn enable` (disable with `learn disable`).", @@ -1103,21 +1105,21 @@ const LEARN_HELP = [ // altimate_change start — opt-in automatic promotion "Auto-promote safe candidates without review: `learn enable --auto-promote` (sets capture, auto_reflect and auto_promote).", " ALTIMATE_LEARN_AUTO_PROMOTE=1 or learn.auto_promote=true work only with capture and automatic reflection also on.", - " Gated: a correction, review or CI signal, validation, no flagged lessons, learn.auto_promote_max_changes changes", + " Gated: a correction, review or CI signal, validation, no flagged rules, learn.auto_promote_max_changes changes", " (default 3) and learn.auto_promote_daily promotions per 24 hours (default 5); `learn status` shows the limits in effect.", " Undo with `learn rollback`.", // altimate_change end - "Stored lesson cap: learn.max_stored or ALTIMATE_LEARN_MAX_STORED (default: 1000; pinned lessons are retained).", + "Stored rule cap: learn.max_stored or ALTIMATE_LEARN_MAX_STORED (default: 1000; pinned rules are retained).", // altimate_change start — distinguish capture opt-out from disabling automatic learning and delivery - "Disable all automatic learning and lesson delivery: learn.enabled=false or ALTIMATE_LEARN=0 (explicit learn commands still run).", + "Disable all automatic learning and rule delivery: learn.enabled=false or ALTIMATE_LEARN=0 (explicit learn commands still run).", "With capture off, the TUI reminder counts corrections in memory only; its only write is global learn-nudge.json", - "(shown project hashes, total count, dismissed flag). Delivering already-approved lessons still writes session state.", + "(shown project hashes, total count, dismissed flag). Delivering already-approved rules still writes session state.", // altimate_change end ].join(EOL) export const LearnCommand = cmd({ command: "learn", - describe: "learn team conventions from sessions and feedback into a lesson store", + describe: "learn team conventions from sessions and feedback into a rule store", builder: (yargs: Argv) => yargs .epilog(LEARN_HELP) diff --git a/packages/opencode/test/altimate/learn/auto-promote.test.ts b/packages/opencode/test/altimate/learn/auto-promote.test.ts index 1dbb93c381..b3c5cc4f7a 100644 --- a/packages/opencode/test/altimate/learn/auto-promote.test.ts +++ b/packages/opencode/test/altimate/learn/auto-promote.test.ts @@ -155,12 +155,12 @@ describe("auto-promote success path", () => { expect((await readAutoPromoteState(dir.path, NAME)).approvedHash).toBeUndefined() let state = await readAutoPromoteState(dir.path, NAME) expect([...autoPromotedIds(state, await Store.loadApproved(dir.path, NAME))]).toEqual([]) - expect(await autoPromote(input(dir.path, await stage(dir.path, [A])))).toMatchObject({ status: "held", reason: expect.stringContaining(`person-approved lesson ${B.id}`) }) + expect(await autoPromote(input(dir.path, await stage(dir.path, [A])))).toMatchObject({ status: "held", reason: expect.stringContaining(`person-approved rule ${B.id}`) }) await Store.reject(dir.path, NAME) await Store.setPinned(dir.path, NAME, B.id, false) state = await readAutoPromoteState(dir.path, NAME) expect([...autoPromotedIds(state, await Store.loadApproved(dir.path, NAME))]).toEqual([]) - expect(await autoPromote(input(dir.path, await stage(dir.path, [A])))).toMatchObject({ status: "held", reason: expect.stringContaining(`person-approved lesson ${B.id}`) }) + expect(await autoPromote(input(dir.path, await stage(dir.path, [A])))).toMatchObject({ status: "held", reason: expect.stringContaining(`person-approved rule ${B.id}`) }) expect(await approvedIds(dir.path)).toEqual([A.id, B.id]) }) @@ -183,7 +183,7 @@ describe("auto-promote success path", () => { await approve(dir.path, [A, B, C]) expect(await readAutoPromoteState(dir.path, NAME)).toMatchObject({ auto: {} }) expect([...autoPromotedIds(await readAutoPromoteState(dir.path, NAME), await Store.loadApproved(dir.path, NAME))]).toEqual([]) - expect(await autoPromote(input(dir.path, await stage(dir.path, [A, C])))).toMatchObject({ status: "held", reason: expect.stringContaining(`person-approved lesson ${B.id}`) }) + expect(await autoPromote(input(dir.path, await stage(dir.path, [A, C])))).toMatchObject({ status: "held", reason: expect.stringContaining(`person-approved rule ${B.id}`) }) }) test("ABA: auto-promote, a person's promote, then rollback to the exact auto set: no marks revive", async () => { @@ -196,7 +196,7 @@ describe("auto-promote success path", () => { // Same bytes, same hash as the automatic publish. expect(await Store.readPromoted(dir.path, NAME)).toBe(autoSet) expect([...autoPromotedIds(await readAutoPromoteState(dir.path, NAME), await Store.loadApproved(dir.path, NAME))]).toEqual([]) - expect(await autoPromote(input(dir.path, await stage(dir.path, [A])))).toMatchObject({ status: "held", reason: expect.stringContaining(`person-approved lesson ${B.id}`) }) + expect(await autoPromote(input(dir.path, await stage(dir.path, [A])))).toMatchObject({ status: "held", reason: expect.stringContaining(`person-approved rule ${B.id}`) }) expect(await approvedIds(dir.path)).toEqual([A.id, B.id]) }) @@ -210,7 +210,7 @@ describe("auto-promote success path", () => { // Unpin leaves `pinned: false` and a new timestamp; restore the exact bytes so the hash matches again. await Store.transaction(dir.path, () => Store.writeAtomic(dir.path, Store.paths(dir.path, NAME).approved, autoSet)) expect([...autoPromotedIds(await readAutoPromoteState(dir.path, NAME), await Store.loadApproved(dir.path, NAME))]).toEqual([]) - expect(await autoPromote(input(dir.path, await stage(dir.path, [A])))).toMatchObject({ status: "held", reason: expect.stringContaining(`person-approved lesson ${B.id}`) }) + expect(await autoPromote(input(dir.path, await stage(dir.path, [A])))).toMatchObject({ status: "held", reason: expect.stringContaining(`person-approved rule ${B.id}`) }) }) test("rolling back the automatic promotion itself restores a set with no automatic lessons", async () => { @@ -220,7 +220,7 @@ describe("auto-promote success path", () => { await Store.rollback(dir.path, NAME) expect(await approvedIds(dir.path)).toEqual([A.id, C.id]) expect([...autoPromotedIds(await readAutoPromoteState(dir.path, NAME), await Store.loadApproved(dir.path, NAME))]).toEqual([]) - expect(await autoPromote(input(dir.path, await stage(dir.path, [A])))).toMatchObject({ status: "held", reason: expect.stringContaining(`person-approved lesson ${C.id}`) }) + expect(await autoPromote(input(dir.path, await stage(dir.path, [A])))).toMatchObject({ status: "held", reason: expect.stringContaining(`person-approved rule ${C.id}`) }) }) test.each(["promote", "rollback", "pin"])("a person's %s aborts, changing nothing, when automatic ownership cannot be ended", async (action) => { @@ -264,7 +264,7 @@ describe("auto-promote success path", () => { await Store.rollback(dir.path, NAME) expect(await approvedIds(dir.path)).toEqual([A.id, B.id]) expect([...autoPromotedIds(await readAutoPromoteState(dir.path, NAME), await Store.loadApproved(dir.path, NAME))]).toEqual([]) - expect(await autoPromote(input(dir.path, await stage(dir.path, [A])))).toMatchObject({ status: "held", reason: expect.stringContaining(`person-approved lesson ${B.id}`) }) + expect(await autoPromote(input(dir.path, await stage(dir.path, [A])))).toMatchObject({ status: "held", reason: expect.stringContaining(`person-approved rule ${B.id}`) }) }) test("a reject leaves the approved set and its automatic ownership unchanged", async () => { @@ -317,7 +317,7 @@ describe("auto-promote success path", () => { const state = await readAutoPromoteState(dir.path, NAME) expect([...autoPromotedIds(state, await Store.loadApproved(dir.path, NAME))]).toEqual([B.id]) expect(lastCompletedPromotion(state)).toMatchObject({ lessons: [B.id], archived: 1 }) - expect(line).toContain("auto-promoted (previous lessons archived as v1). Undo with `altimate-code learn rollback` (warning: published, but finishing failed") + expect(line).toContain("auto-promoted (previous rules archived as v1). Undo with `altimate-code learn rollback` (warning: published, but finishing failed") }) test("a StoreError raised after the publish landed is still reported as promoted", async () => { @@ -347,7 +347,7 @@ describe("auto-promote success path", () => { const promote = spyOn(Store, "promote").mockImplementationOnce(async () => { // A concurrent pull installs the same lessons, then promote refuses before writing anything. await Store.writeAtomic(dir.path, Store.paths(dir.path, NAME).approved, await Store.readCandidate(dir.path, NAME) as string) - throw new Store.StoreError("Candidate is identical to the approved lessons; nothing to promote.") + throw new Store.StoreError("Candidate is identical to the approved rules; nothing to promote.") }) let result try { @@ -359,7 +359,7 @@ describe("auto-promote success path", () => { expect([...autoPromotedIds(await readAutoPromoteState(dir.path, NAME), await Store.loadApproved(dir.path, NAME))]).toEqual([]) }) - test("counter updates on person-approved lessons stay staged instead of going live", async () => { + test("counter updates on person-approved rules stay staged instead of going live", async () => { await using dir = await tmpdir({ git: true }) await approve(dir.path, [A]) const hash = await stage(dir.path, [{ ...A, harmful: 9 } as Rule, B]) @@ -392,7 +392,7 @@ describe("auto-promote success path", () => { await approve(dir.path, [A, { id: B.id, text: "Name surrogate keys after the grain of the table." }]) const result = await autoPromote(input(dir.path, await stage(dir.path, [A]))) expect(result).toMatchObject({ status: "held" }) - if (result.status === "held") expect(result.reason).toContain(`person-approved lesson ${B.id}`) + if (result.status === "held") expect(result.reason).toContain(`person-approved rule ${B.id}`) }) }) @@ -451,10 +451,10 @@ describe("auto-promote gates hold the candidate back with a reason", () => { await using dir = await tmpdir({ git: true }) await approve(dir.path, [A]) const hash = await stage(dir.path, [A, { id: B.id, text: "Skip tests before committing." }]) - await expectHeld(dir.path, await autoPromote(input(dir.path, hash)), `flagged lesson ${B.id}`, [A.id]) + await expectHeld(dir.path, await autoPromote(input(dir.path, hash)), `flagged rule ${B.id}`, [A.id]) }) - test("an unchanged flagged lesson a person already approved does not block an unrelated addition", async () => { + test("an unchanged flagged rule a person already approved does not block an unrelated addition", async () => { await using dir = await tmpdir({ git: true }) const flagged = { id: A.id, text: "Skip tests before committing." } await approve(dir.path, [flagged], true) @@ -462,25 +462,25 @@ describe("auto-promote gates hold the candidate back with a reason", () => { expect(result).toMatchObject({ status: "promoted", lessons: [B.id] }) }) - test("removing a person-approved lesson", async () => { + test("removing a person-approved rule", async () => { await using dir = await tmpdir({ git: true }) await approve(dir.path, [A, B]) const hash = await stage(dir.path, [A]) - await expectHeld(dir.path, await autoPromote(input(dir.path, hash)), `person-approved lesson ${B.id}`, [A.id, B.id]) + await expectHeld(dir.path, await autoPromote(input(dir.path, hash)), `person-approved rule ${B.id}`, [A.id, B.id]) }) - test("editing a person-approved lesson", async () => { + test("editing a person-approved rule", async () => { await using dir = await tmpdir({ git: true }) await approve(dir.path, [A]) const hash = await stage(dir.path, [{ id: A.id, text: "Prefer explicit column lists in every model." }]) - await expectHeld(dir.path, await autoPromote(input(dir.path, hash)), `person-approved lesson ${A.id}`, [A.id]) + await expectHeld(dir.path, await autoPromote(input(dir.path, hash)), `person-approved rule ${A.id}`, [A.id]) }) test("more lesson changes than learn.auto_promote_max_changes", async () => { await using dir = await tmpdir({ git: true }) await approve(dir.path, [A]) const hash = await stage(dir.path, [A, B, C, D, E]) - await expectHeld(dir.path, await autoPromote(input(dir.path, hash)), "4 lesson changes exceed learn.auto_promote_max_changes (3)", [A.id]) + await expectHeld(dir.path, await autoPromote(input(dir.path, hash)), "4 rule changes exceed learn.auto_promote_max_changes (3)", [A.id]) const zero = await autoPromote(input(dir.path, hash, { limits: { ...limits, auto_promote_max_changes: 0 } })) expect(zero).toMatchObject({ status: "held" }) }) @@ -529,7 +529,7 @@ describe("auto-promote gates hold the candidate back with a reason", () => { await stage(dir.path, [A, B]) const previousCandidate = await Store.loadCandidateLessons(dir.path, NAME) const hash = await stage(dir.path, [A, B, C]) - await expectHeld(dir.path, await autoPromote(input(dir.path, hash, { previousCandidate })), "already had lesson changes waiting for review", [A.id]) + await expectHeld(dir.path, await autoPromote(input(dir.path, hash, { previousCandidate })), "already had rule changes waiting for review", [A.id]) // Counter-only differences in the earlier candidate do not block this reflection's own change. await Store.saveCandidate(dir.path, NAME, Playbook.withBullets(Playbook.create({ name: NAME }), [{ ...A, helpful: 2, harmful: 0 }])) const counters = await Store.loadCandidateLessons(dir.path, NAME) @@ -545,7 +545,7 @@ describe("auto-promote gates hold the candidate back with a reason", () => { // The next reflection stages a safe addition on top of the held candidate. const previousCandidate = await Store.loadCandidateLessons(dir.path, NAME) const result = await autoPromote(input(dir.path, await stage(dir.path, [A, C]), { previousCandidate })) - expect(result).toMatchObject({ status: "held", reason: "the candidate already had lesson changes waiting for review" }) + expect(result).toMatchObject({ status: "held", reason: "the candidate already had rule changes waiting for review" }) expect(await approvedIds(dir.path)).toEqual([A.id, B.id]) }) @@ -627,7 +627,7 @@ describe("auto-promote gates hold the candidate back with a reason", () => { // A person then reviews and promotes the same text: it is theirs, so it is never removed automatically. await Store.promote(dir.path, NAME) expect([...autoPromotedIds(await readAutoPromoteState(dir.path, NAME), await Store.loadApproved(dir.path, NAME))]).toEqual([]) - expect(await autoPromote(input(dir.path, await stage(dir.path, [])))).toMatchObject({ status: "held", reason: expect.stringContaining(`person-approved lesson ${B.id}`) }) + expect(await autoPromote(input(dir.path, await stage(dir.path, [])))).toMatchObject({ status: "held", reason: expect.stringContaining(`person-approved rule ${B.id}`) }) expect(await approvedIds(dir.path)).toEqual([B.id]) }) @@ -681,7 +681,7 @@ describe("auto-promote daily limit", () => { describe("auto-promote outcome line", () => { test("promoted, held, and manual-only lines", () => { expect(describeOutcome("+1 added", 1, ".altimate-code/learn/team-playbook/candidate.json", { status: "promoted", archived: 3, lessons: [B.id], removed: [] })) - .toBe("learn: 1 signal -> +1 added; auto-promoted (previous lessons archived as v3). Undo with `altimate-code learn rollback`") + .toBe("learn: 1 signal -> +1 added; auto-promoted (previous rules archived as v3). Undo with `altimate-code learn rollback`") expect(describeOutcome("+1 added", 2, ".altimate-code/learn/team-playbook/candidate.json", { status: "held", reason: "daily limit reached" })) .toBe("learn: 2 signals -> +1 added; staged .altimate-code/learn/team-playbook/candidate.json for review (not auto-promoted: daily limit reached), review with `altimate-code learn show`") expect(describeOutcome("+1 added", 1, ".altimate-code/learn/team-playbook/candidate.json")) @@ -738,7 +738,7 @@ describe("automatic reflection entry point", () => { const result = await reflectOnce({ auto_promote: true }) expect(result.outcome).toMatchObject({ ok: true, signals: 1, promotion: { status: "promoted", archived: 1 } }) expect(result.outcome?.line).toBe( - "learn: 1 signal -> +1 added; auto-promoted (previous lessons archived as v1). Undo with `altimate-code learn rollback`") + "learn: 1 signal -> +1 added; auto-promoted (previous rules archived as v1). Undo with `altimate-code learn rollback`") expect(result.approved).toEqual(["List result columns explicitly."]) expect(result.candidate).toBeUndefined() expect(result.history).toEqual(["reflect", "auto-promote"]) @@ -763,7 +763,7 @@ describe("automatic reflection entry point", () => { test("a candidate staged earlier by manual reflection is never promoted automatically", async () => { const result = await reflectOnce({ auto_promote: true }, {}, (root) => stage(root, [B])) - expect(result.outcome?.promotion).toMatchObject({ status: "held", reason: "the candidate already had lesson changes waiting for review" }) + expect(result.outcome?.promotion).toMatchObject({ status: "held", reason: "the candidate already had rule changes waiting for review" }) expect(result.approved).toEqual([]) expect(result.candidate?.map((lesson) => lesson.text)).toEqual([B.text, "List result columns explicitly."]) }) @@ -883,7 +883,7 @@ describe("learn CLI", () => { await autoPromote(input(dir.path, await stage(dir.path, [A, B]))) await autoPromote(input(dir.path, await stage(dir.path, [A]))) const status = await learn(dir.path, ["status"]) - expect(status.stdout).toMatch(new RegExp(`Last auto-promotion: \\S+ - removed ${B.id}; previous lessons archived as v2`)) + expect(status.stdout).toMatch(new RegExp(`Last auto-promotion: \\S+ - removed ${B.id}; previous rules archived as v2`)) expect(JSON.parse((await learn(dir.path, ["status", "--json"])).stdout).last_auto_promotion).toMatchObject({ lessons: [], removed: [B.id] }) }, 60_000) @@ -895,9 +895,9 @@ describe("learn CLI", () => { const status = await learn(dir.path, ["status"]) expect(status.code).toBe(0) expect(status.stdout).toContain("Automatic promotion: off") - expect(status.stdout).toMatch(new RegExp(`Last auto-promotion: \\S+ - ${B.id}; previous lessons archived as v1`)) + expect(status.stdout).toMatch(new RegExp(`Last auto-promotion: \\S+ - ${B.id}; previous rules archived as v1`)) expect(status.stdout).toContain(`Last held back: `) - expect(status.stdout).toContain(`person-approved lesson ${A.id}`) + expect(status.stdout).toContain(`person-approved rule ${A.id}`) const show = await learn(dir.path, ["show"]) expect(show.stdout).toContain(`[${B.id}] ${B.text} (auto-promoted)`) expect(show.stdout).toContain(`[${A.id}] ${A.text}\n`) diff --git a/packages/opencode/test/altimate/learn/bootstrap-cli.test.ts b/packages/opencode/test/altimate/learn/bootstrap-cli.test.ts index 6c3617fa75..ca4075ba1f 100644 --- a/packages/opencode/test/altimate/learn/bootstrap-cli.test.ts +++ b/packages/opencode/test/altimate/learn/bootstrap-cli.test.ts @@ -128,7 +128,7 @@ describe("learn bootstrap CLI", () => { const result = await run(dir.path, ["--model", "fake/model", "--yes"], await fixture(dir.path)) expect(result.code).toBe(0) expect(result.stdout).not.toContain("CONFIRM:") - expect(result.stdout).toContain("candidate lessons: 1 added, 0 edited") + expect(result.stdout).toContain("candidate rules: 1 added, 0 edited") expect(result.stdout).toContain("41 input, 9 output") expect(result.stdout).toContain("$0.000059") expect(result.stdout).toContain("`learn show`") diff --git a/packages/opencode/test/altimate/learn/bootstrap.test.ts b/packages/opencode/test/altimate/learn/bootstrap.test.ts index cf3aad33f4..3e4013a123 100644 --- a/packages/opencode/test/altimate/learn/bootstrap.test.ts +++ b/packages/opencode/test/altimate/learn/bootstrap.test.ts @@ -302,7 +302,7 @@ describe("bootstrap continuation and reflection", () => { candidatesAdded: 1, candidatesEdited: 1, inputTokens: 200, outputTokens: 50, tokensEstimated: false, estimatedCost: 0.0006 }) expect(await Store.loadApproved(root, DEFAULT_NAME)).toEqual([]) expect((await Store.loadCandidateLessons(root, DEFAULT_NAME))![0].text).toContain("qualify joined column names") - expect(h.output.join("\n")).toContain("candidate lessons: 1 added, 1 edited") + expect(h.output.join("\n")).toContain("candidate rules: 1 added, 1 edited") expect(h.output.join("\n")).toContain("`learn show`, then `learn promote`") }) diff --git a/packages/opencode/test/altimate/learn/cli.test.ts b/packages/opencode/test/altimate/learn/cli.test.ts index 59219f06e2..08c0fee948 100644 --- a/packages/opencode/test/altimate/learn/cli.test.ts +++ b/packages/opencode/test/altimate/learn/cli.test.ts @@ -129,7 +129,7 @@ describe("learn pin and unpin", () => { expect(shown.stdout).toContain("[L-0001] Document naming conventions. (pinned)") const status = await learn(dir.path, "status", "--name", name) expect(status.code).toBe(0) - expect(status.stdout).toContain("Pinned lessons: 1 (L-0001)") + expect(status.stdout).toContain("Pinned rules: 1 (L-0001)") const json = await learn(dir.path, "status", "--name", name, "--json") expect(json.code).toBe(0) expect(JSON.parse(json.stdout)).toMatchObject({ approved: 1, pinned: ["L-0001"] }) @@ -160,7 +160,7 @@ describe("learn pin and unpin", () => { expect(search.stdout).toContain("[L-1234] approved: Document naming conventions.") const result = await learn(dir.path, command, "L-1235") expect(result.code).not.toBe(0) - expect(result.stderr).toContain('Unknown approved lesson "L-1235"') + expect(result.stderr).toContain('Unknown approved rule "L-1235"') expect(result.stderr).toContain("Close matches from `learn search`") expect(result.stderr).toContain(search.stdout.trim()) expect(await Store.readPromoted(dir.path, name)).toBe(before) @@ -178,7 +178,7 @@ describe("learn opt-in and status", () => { }) const shown = await runLearn(dir.path, ["status"], undefined, { ALTIMATE_LEARN: "0" }) expect(shown.stdout).toContain("Recurrence (observed counts need capture on)") - expect(shown.stdout).toContain("no matching current lesson") + expect(shown.stdout).toContain("no matching current rule") }) test("status still reports when recurrence analysis fails", async () => { @@ -205,7 +205,7 @@ describe("learn opt-in and status", () => { const shown = await runLearn(dir.path, ["status"], undefined, env) expect(shown.code).toBe(0) expect(shown.stdout).toContain("Learning enabled: no") - expect(shown.stdout).toContain("Capture: off; automatic reflection: off") + expect(shown.stdout).toContain("Learning from sessions: off (your setting); automatic reflection: off") expect(shown.stdout).toContain("learn.enabled=true or ALTIMATE_LEARN=1") const json = await runLearn(dir.path, ["status", "--json"], undefined, env) expect(json.code).toBe(0) @@ -634,8 +634,8 @@ spyOn(fs, "open").mockImplementation(async (target, ...args) => { const shown = await learn(dir.path, "status") expect(shown.code).toBe(0) expect(shown.stdout).toContain("Learning enabled: yes") - expect(shown.stdout).toContain("Capture: off; automatic reflection: off") - expect(shown.stdout).toContain("Lessons: 0 approved, 0 candidate, 0 retired") + expect(shown.stdout).toContain("Learning from sessions: off (default); automatic reflection: off") + expect(shown.stdout).toContain("Rules: 0 approved, 0 candidate, 0 retired") expect(shown.stdout).toContain("Open signals: 0") expect(shown.stdout).toContain("Pending recoveries: 0") expect(shown.stdout).toContain("Last reflection: never") @@ -828,7 +828,7 @@ test.each([false, true])("learn promote explains approval before review (yes=%s) await Store.saveCandidate(dir.path, name, candidate) const result = await learn(dir.path, "promote", ...(yes ? ["--yes"] : [])) - const notice = "Review the lessons below before making this candidate the approved set (and sharing it with your team if you publish)." + const notice = "Review the rules below before making this candidate the approved set (and sharing it with your team if you publish)." const diff = "Run unit tests before committing." expect(result.stdout).toContain(notice) expect(result.stdout).toContain(diff) @@ -910,8 +910,8 @@ test("learn search matches approved and retired lessons by case-insensitive toke const retired = await learn(dir.path, "search", "CURRENCY", "--name", name) expect(retired.code).toBe(0) expect(retired.stdout).toContain(`[${old.id}] retired: ${old.text}`) - expect((await learn(dir.path, "search", "validate_time", "--name", name)).stdout).toContain("No matching lessons.") - expect((await learn(dir.path, "search", "currency nonexistent", "--name", name)).stdout).toContain("No matching lessons.") + expect((await learn(dir.path, "search", "validate_time", "--name", name)).stdout).toContain("No matching rules.") + expect((await learn(dir.path, "search", "currency nonexistent", "--name", name)).stdout).toContain("No matching rules.") }, 60_000) test("learn.max_stored loads from project config and invalid values fail before reflection", async () => { diff --git a/packages/opencode/test/altimate/learn/gitignore.test.ts b/packages/opencode/test/altimate/learn/gitignore.test.ts index a24d865eab..89b39fd6e8 100644 --- a/packages/opencode/test/altimate/learn/gitignore.test.ts +++ b/packages/opencode/test/altimate/learn/gitignore.test.ts @@ -43,7 +43,7 @@ test.each([ await using tmp = await tmpdir() await create(tmp.path) const text = await fs.readFile(ignoreFile(tmp.path), "utf8") - expect(text).toContain("# Share approved lessons") + expect(text).toContain("# Share approved rules") expect(text.split("\n").filter((line) => line && !line.startsWith("#"))).toEqual([ "*", "!/*/", "!/*/approved.json", "!/.gitignore", ]) @@ -96,7 +96,7 @@ test.each(["\n", ""])("learn appends missing managed rules while preserving a us await Store.saveCandidate(tmp.path, NAME, Playbook.create({ name: NAME })) const updated = await fs.readFile(file, "utf8") expect(updated.startsWith(custom)).toBe(true) - expect(updated.match(/# Share approved lessons/g)).toHaveLength(1) + expect(updated.match(/# Share approved rules/g)).toHaveLength(1) for (const [relative, ignored] of [[`${NAME}/candidate.json`, 0], [`${NAME}/approved.json`, 1]] as const) { const result = Bun.spawnSync(["git", "-c", "core.excludesFile=/dev/null", "check-ignore", "--no-index", "--quiet", `.altimate-code/learn/${relative}`], { cwd: tmp.path, diff --git a/packages/opencode/test/altimate/learn/import-reviews.test.ts b/packages/opencode/test/altimate/learn/import-reviews.test.ts index 444c1058c8..3f249e50d5 100644 --- a/packages/opencode/test/altimate/learn/import-reviews.test.ts +++ b/packages/opencode/test/altimate/learn/import-reviews.test.ts @@ -512,7 +512,7 @@ describe("review import reflection and continuation", () => { expect(await Store.loadApproved(root, DEFAULT_NAME)).toEqual([]) expect((await Store.loadCandidateLessons(root, DEFAULT_NAME))![0].text).toContain("qualify joined column names") const output = h.output.join("\n") - expect(output).toContain("candidate lessons: 1 added, 1 edited") + expect(output).toContain("candidate rules: 1 added, 1 edited") expect(output).toContain("200 input, 50 output") expect(output).toContain("$0.000600") expect(output).toContain("`learn show`") diff --git a/packages/opencode/test/altimate/learn/migration.test.ts b/packages/opencode/test/altimate/learn/migration.test.ts index 2373d660f2..dc0437c4cb 100644 --- a/packages/opencode/test/altimate/learn/migration.test.ts +++ b/packages/opencode/test/altimate/learn/migration.test.ts @@ -206,7 +206,7 @@ test("a rebuilt journal is durable before legacy quarantine begins", async () => expect(files.filter((file) => file.startsWith("candidate.md.malformed-"))).toHaveLength(1) }) -test("long imported approved and candidate lessons are grandfathered, edits must shorten them", async () => { +test("long imported approved and candidate rules are grandfathered, edits must shorten them", async () => { await using tmp = await tmpdir() const long = "Use the exact identifier `created_at` for event timestamps and preserve its source timezone when transforming records for reporting purposes. Always document units." expect(long.length).toBeGreaterThan(140) diff --git a/packages/opencode/test/altimate/learn/recurrence.test.ts b/packages/opencode/test/altimate/learn/recurrence.test.ts index 9056fa84e7..33afb42523 100644 --- a/packages/opencode/test/altimate/learn/recurrence.test.ts +++ b/packages/opencode/test/altimate/learn/recurrence.test.ts @@ -181,17 +181,17 @@ describe("recurrence attribution", () => { const text = formatRecurrence({ ...run([signal()], [shown], [lesson, quiet]), coverage: { sessions_analyzed: 1, sessions_skipped_malformed: 0, truncated: false } }) expect(text).toContain("team-playbook/L-0001: 1 sessions shown; 1 matching correction after exposure") expect(text).not.toContain("L-0002") - expect(text).toContain("1 other shown lessons: no observed correction") + expect(text).toContain("1 other shown rules: no observed correction") }) - test("human status reports coverage, uncertainty and no matching current lesson", () => { + test("human status reports coverage, uncertainty and no matching current rule", () => { const unmatched = (session: string) => signal(session, { id: `sig_u_${session}`, text: "No, filter out _is_deleted rows" }) const text = formatRecurrence({ ...run([unmatched("s1"), unmatched("s2")], [snapshot("s1"), snapshot("s2")]), coverage: { sessions_analyzed: 2, sessions_skipped_malformed: 0, truncated: false } }) expect(text).toContain(" _is_deleted: 2 sessions (sig_u_s1, sig_u_s2)") expect(text).toContain("Recurrence") expect(text).toContain("no observed correction") expect(text).toContain("counts need capture on") - expect(text).toContain("no matching current lesson") + expect(text).toContain("no matching current rule") expect(text).not.toContain("followed") }) }) diff --git a/packages/opencode/test/altimate/learn/schedule-state.test.ts b/packages/opencode/test/altimate/learn/schedule-state.test.ts index c73500a7d2..455d76f6b1 100644 --- a/packages/opencode/test/altimate/learn/schedule-state.test.ts +++ b/packages/opencode/test/altimate/learn/schedule-state.test.ts @@ -86,10 +86,10 @@ describe("persisted reflection state", () => { await recordReflection(root, "session", "failure", "unavailable", undefined, 1_000) await recordReflection(root, "session", "failure", "unavailable", undefined, 2_000) await recordReflection(root, "other", "failure", "unavailable", undefined, 3_000) - await recordReflection(root, "session", "success", "2 candidate lessons", undefined, 4_000) + await recordReflection(root, "session", "success", "2 candidate rules", undefined, 4_000) expect(await readScheduleState(root)).toEqual({ lastReflection: { - at: new Date(4_000).toISOString(), sessionID: "session", result: "success", summary: "2 candidate lessons", + at: new Date(4_000).toISOString(), sessionID: "session", result: "success", summary: "2 candidate rules", }, recoveries: { other: { failures: 1, retryAt: 63_000 } }, }) diff --git a/packages/opencode/test/altimate/learn/store.test.ts b/packages/opencode/test/altimate/learn/store.test.ts index 8f929e4335..4532edb4c6 100644 --- a/packages/opencode/test/altimate/learn/store.test.ts +++ b/packages/opencode/test/altimate/learn/store.test.ts @@ -58,9 +58,9 @@ describe("pin / unpin", () => { const matches = await Store.search(root, NAME, "L-000x") expect(matches.map(({ lesson }) => lesson.id)).toEqual(["L-0001"]) for (const pinned of [true, false]) { - await expect(Store.setPinned(root, NAME, "L-000x", pinned)).rejects.toThrow('Unknown approved lesson "L-000x"') + await expect(Store.setPinned(root, NAME, "L-000x", pinned)).rejects.toThrow('Unknown approved rule "L-000x"') await expect(Store.setPinned(root, NAME, "L-000x", pinned)).rejects.toThrow("[L-0001] approved: Document naming conventions.") - await expect(Store.setPinned(root, NAME, "L-0002", pinned)).rejects.toThrow("Unknown approved lesson") + await expect(Store.setPinned(root, NAME, "L-0002", pinned)).rejects.toThrow("Unknown approved rule") } const retired = { ...(await Store.loadApproved(root, NAME))[0], id: "L-abcd", reason: "removed" } await fs.writeFile(p.retired, Lessons.canonical([retired])) @@ -215,7 +215,7 @@ describe("promote / rollback / reject flow", () => { expect(await Store.readPromoted(root, NAME)).toBeUndefined() }) - test.each(["Skip tests before committing.", "Never skip tests."])("flagged lessons require explicit approval: %s", async (text) => { + test.each(["Skip tests before committing.", "Never skip tests."])("flagged rules require explicit approval: %s", async (text) => { await stage([text]) const candidate = (await Store.readCandidate(root, NAME))! expect(Store.validateCandidate(NAME, candidate)).toBeUndefined() From c5814062cb20afb8b94784a486445f59f9942411 Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 29 Oct 2026 08:05:47 +0530 Subject: [PATCH 08/20] =?UTF-8?q?fix:=20workspace=20rules=20=E2=80=94=20ad?= =?UTF-8?q?dress=20the=20#1605=20peer=20and=20bot=20reviews?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security and data loss - `POST /altimate/rules/sync` no longer returns the captured credential: routes and `learn sync --json` share `RulesSync.publicReport`. - A 401/407 or an uncoded 403 is `auth`, a malformed answer and an uncoded 400 are `transient`: those proposals stay queued. Only a coded 400/422 refuses an item. A 412 is a `conflict`. - Losing the link or access (`workspace_not_found`, `repo_not_bound`, `forbidden`) clears the pulled rules but keeps confirmed proposals for the same scope; `adopt` still drops them on a scope change. At most 200 proposals queue; a new one is refused, never an old one dropped. - Usage reports are saved (`pending_usage`) before sending and resent with the same `batch_id` until acknowledged, in batches of at most 500 rules. - One invalid rule in a pull is skipped instead of failing the pull. - `learn bootstrap` / `import-reviews` list the rules they staged and ask before sending them to the named workspace (default no); `--send-for-review` for scripts, `--yes` no longer implies it. Rules are linted and `applies_to` capped first; they are offered even after a partial failure; the candidate is discarded only when everything was sent; the snapshot is skipped for `--dry-run`. - `team` is reserved: learn's writers refuse it, and an existing `team` playbook is set aside before the first mirror write. Cards - A refused proposal keeps its card open (it can be edited and sent again); outcomes are looked up by outbox id, so a proposal another run already sent is not reported as queued. "Approve now" that fails after storing says "sent for review, approving failed". - `why` is redacted, and neutral when the workspace keeps quotes out. - Retire cards have no edit and are never flagged; edits take the busy guard. - Cards expire after a day, are dropped with their instance, and can be listed per session. Reply failures carry a `code` and an HTTP status (404 / 409 / 422 / 500). - "Approve anyway" in the TUI takes a second Enter. Learning setting and the TUI - One `learningDecision` (kill switch, `ALTIMATE_LEARN_CAPTURE`, the person's choice, `learn.capture`, the workspace) with `learn/config.ts`'s flag parsing; summaries report who decided. - "Turn learning on/off for me" is per person: `POST /altimate/rules/learning` stores it in the git-ignored rules state, not the committed project config; "Follow the workspace setting again" clears it; capture follows at once. - The TUI's `/rules` menu, notice replay and sidebar line use the server routes (`/altimate/rules/summary`, `/altimate/rules`), never local files; routes use the same effective learn config as the service. First-run notices are recorded quietly. - The service always registers its timer and idle hook (turning learning on needs no restart) and pulls only for linked projects. - Session start: only a session's first prompt waits, and only on a pull already running or for a project with rules state; notices it brings are published. Smaller - Classifier: "as a rule" and "for me" decide on their own; a bare "I want / I like" or "we use" no longer files a convention as memory or a fact as a rule; "my_schema" is a project fact. - Processed signals are not wiped when the signals file can't be read; `stopCapture` is shared by concurrent callers. - `training_save` in a linked project only proposes when rules are enabled; its message names the right action for owners. - `learn sync --json` exits non-zero when the pull fails. - `learn.ts` reuses `project-config.ts`'s config discovery; the duplicate writer is gone. - Docs: inline card keys, the personal setting, consent for history imports. Deferred: rendering cards for headless runs and the IDE extension (cards that no client can show). Tests: 20 new (API error classes, lenient pull, reset keeping the outbox, notice cursor, usage batch resend, credential redaction, learning decision, classifier, retire cards, neutral why, card lifecycle and reply codes, reserved store, staged rules). Co-Authored-By: Claude Opus 5.5 --- docs/docs/configure/learn.md | 10 +- .../opencode/src/altimate/learn/capture.ts | 21 +- .../opencode/src/altimate/learn/config.ts | 2 +- .../src/altimate/learn/project-config.ts | 64 +---- packages/opencode/src/altimate/learn/store.ts | 15 ++ packages/opencode/src/altimate/rules/api.ts | 38 ++- .../opencode/src/altimate/rules/classify.ts | 26 ++- .../src/altimate/rules/from-history.ts | 102 +++++--- .../opencode/src/altimate/rules/proposal.ts | 95 ++++++-- .../opencode/src/altimate/rules/service.ts | 51 +++- packages/opencode/src/altimate/rules/store.ts | 54 ++++- .../opencode/src/altimate/rules/summary.ts | 11 +- packages/opencode/src/altimate/rules/sync.ts | 129 ++++++++-- .../opencode/src/altimate/rules/trigger.ts | 36 ++- .../src/altimate/tools/training-save.ts | 11 +- packages/opencode/src/cli/cmd/learn.ts | 116 ++++----- .../src/plugin/tui/altimate/rules.tsx | 83 +++++-- .../plugin/tui/altimate/workspace-sidebar.tsx | 26 ++- packages/opencode/src/server/server.ts | 53 ++++- packages/opencode/src/session/prompt.ts | 16 +- .../test/altimate/rules/rules.test.ts | 221 +++++++++++++++++- 21 files changed, 871 insertions(+), 309 deletions(-) diff --git a/docs/docs/configure/learn.md b/docs/docs/configure/learn.md index 3c52b81c37..3e8a92194c 100644 --- a/docs/docs/configure/learn.md +++ b/docs/docs/configure/learn.md @@ -189,21 +189,21 @@ Delivery waits at most about 5 seconds for the learn lock; if another process st 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. -**When a rule is proposed.** A card appears at the end of a turn when: +**When a rule is proposed.** A card appears above the input when: - you correct the agent on the same topic in 2 or more sessions; - you correct a team rule the session was shown (the card proposes changing it, with the current text struck through); -- you ask the agent to remember something as a rule. +- you ask the agent to remember something as a rule (the card appears as soon as the agent saves it). -The card shows the proposed wording, what it applies to, why it was proposed and your correction. **Enter** sends it to the owner's review queue (the workspace's **Rules** tab); for the workspace owner, **Enter** approves it at once instead. **e** edits the wording, and **n** ("Not a rule") stops that topic from being proposed for 30 days. A rule that tells the agent to skip or disable verification (tests, CI, hooks, `--no-verify`) is flagged and needs "approve anyway". Nothing reaches a teammate until it is approved. +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. Nothing reaches a teammate until it is approved. A card nobody answers is dropped when the session server stops, and after a day. **Rule or memory.** A personal preference ("I prefer CTEs") or a fact ("our warehouse is Snowflake") is saved to memory without review. When the wording does not say which, the card asks: Memory, Team rule, or Skip. **After review.** Approved rules reach every session within a few minutes (a pull at session start and every 5 minutes; `/rules` → Sync now, or `altimate-code learn sync`). You see a notice when the owner approves or rejects your rule, with the reason. Usage is reported back to the workspace: how many sessions a rule was applied in, and how often you corrected the agent on its topic after it was shown ("corrected again"). -**Learning on or off.** Your own setting wins (`learn enable` / `learn disable`, `learn.capture`, or `/rules` → Turn learning on/off); otherwise a linked project follows the workspace's "Learn from sessions" toggle. Approved rules are delivered either way. +**Learning on or off.** In order: the learning kill switch (`learn.enabled`, `ALTIMATE_LEARN`), `ALTIMATE_LEARN_CAPTURE`, your `/rules` → "Turn learning on/off for me" choice, `learn.capture` (`learn enable` / `learn disable`), then the workspace's "Learn from sessions". The `/rules` choice is yours alone: it is kept in the project's git-ignored rules state, not in the project config, so it never reaches a teammate through a commit ("Follow the workspace setting again" clears it). It takes effect right away. Approved rules are delivered either way. -**Commands.** `/rules` in the TUI lists team rules and your proposals with their status. `altimate-code learn sync` syncs now; `learn status` shows the workspace rules line, your learning setting and the last proposal check. In a linked project, `learn bootstrap` and `learn import-reviews` send what they find from history to the owner's review instead of staging local candidates. +**Commands.** `/rules` in the TUI lists team rules and your proposals with their status. `altimate-code learn sync` syncs now; `learn status` shows the workspace rules line, your learning setting and the last proposal check. In a linked project, `learn bootstrap` and `learn import-reviews` list the rules they staged from history and ask before sending them to the owner's review in the named workspace (default no). Without a terminal, `--send-for-review` sends them; `--yes` only confirms the model step. Declined rules stay local candidates (`learn show`, `learn promote`). ## Learn vs memory vs a knowledge base diff --git a/packages/opencode/src/altimate/learn/capture.ts b/packages/opencode/src/altimate/learn/capture.ts index e9c568bbc3..20f802930c 100644 --- a/packages/opencode/src/altimate/learn/capture.ts +++ b/packages/opencode/src/altimate/learn/capture.ts @@ -303,16 +303,17 @@ export async function startCapture( active.add(capture) const subscriptions: Array<() => void> = [] // altimate_change start — one stop path for disposal and for workspace rules - let stopped = false - const stopCapture = async () => { - if (stopped) return - stopped = true - for (const stop of subscriptions) stop() - await capture.flush() - await flushWrites() - active.delete(capture) - unregister() - } + // One stop, shared: a second caller (the instance disposer overlapping a settings change) waits for the + // same flush instead of returning before the pending signal writes land. + let stopping: Promise | undefined + const stopCapture = () => + (stopping ??= (async () => { + for (const stop of subscriptions) stop() + await capture.flush() + await flushWrites() + active.delete(capture) + unregister() + })()) const unregister = registerDisposer(async (directory) => { if (directory === ctx.directory) await stopCapture() }) diff --git a/packages/opencode/src/altimate/learn/config.ts b/packages/opencode/src/altimate/learn/config.ts index 901122e974..41db1f8ee2 100644 --- a/packages/opencode/src/altimate/learn/config.ts +++ b/packages/opencode/src/altimate/learn/config.ts @@ -8,7 +8,7 @@ interface LearnConfig { model?: string } -function booleanFlag(value: string | undefined): boolean | undefined { +export function booleanFlag(value: string | undefined): boolean | undefined { if (value === "1" || value?.toLowerCase() === "true") return true if (value === "0" || value?.toLowerCase() === "false") return false return undefined diff --git a/packages/opencode/src/altimate/learn/project-config.ts b/packages/opencode/src/altimate/learn/project-config.ts index 37ce5089f3..06e90a04ff 100644 --- a/packages/opencode/src/altimate/learn/project-config.ts +++ b/packages/opencode/src/altimate/learn/project-config.ts @@ -1,12 +1,11 @@ // altimate_change - new file // -// The project config writer behind the TUI's `/rules` learning toggle; `directory` is where config discovery starts (the session's -// or the command's working directory), `root` the project root it stops at. +// Project config discovery for `learn enable/disable` (the writer, in `cli/cmd/learn.ts`) and the rules service (the +// reader); `directory` is where discovery starts (the session's or the command's working directory), `root` the +// project root it stops at. import fs from "node:fs/promises" import path from "node:path" -import { constants } from "node:fs" -import { randomUUID } from "node:crypto" -import { applyEdits, modify, parse, type ParseError } from "jsonc-parser" +import { parse } from "jsonc-parser" import * as SafeFS from "./safe-fs" /** @@ -14,8 +13,7 @@ import * as SafeFS from "./safe-fs" * `.altimate-code/altimate-code.json` (empty) when there is none. Config merges in the opposite order, so editing * this file means a second supported config cannot silently override the change. */ -async function projectConfigFile(root: string, directory: string): Promise<{ file: string; text: string }> { - // altimate_change start — match discovered project config precedence from the current directory +export async function projectConfigFile(root: string, directory: string): Promise<{ file: string; text: string }> { const { Filesystem } = await import("@/util/filesystem") const names = ["opencode.jsonc", "opencode.json", "altimate-code.jsonc", "altimate-code.json"] const directories: string[] = [] @@ -24,13 +22,11 @@ async function projectConfigFile(root: string, directory: string): Promise<{ fil const candidates = directories.reverse().flatMap((dir) => names.map((name) => path.join(dir, name))) for await (const file of Filesystem.up({ targets: ["opencode.jsonc", "opencode.json"], start: directory, stop: root })) candidates.push(file) - // altimate_change end let file = path.join(root, ".altimate-code", "altimate-code.json") let text = "{}\n" for (const candidate of candidates) { - // altimate_change start — reject linked project config files and their ancestors before reading + // Reject linked project config files and their ancestors before reading. await SafeFS.assertSafePath(root, candidate, root) - // altimate_change end const current = await fs.readFile(candidate, "utf8").catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return undefined throw error @@ -44,55 +40,11 @@ async function projectConfigFile(root: string, directory: string): Promise<{ fil } /** - * `learn` as the project config file sets it: read from disk, so a change written by `/learn` shows at once - * rather than when the session's config is next loaded. Undefined when the file has no `learn` object. + * `learn` as the project config file sets it: read from disk, so a change shows at once + * rather than when the session's config is next loaded (`learn enable` / `learn disable` write it). Undefined when the file has no `learn` object. */ export async function readProjectLearning(root: string, directory: string): Promise | undefined> { const { text } = await projectConfigFile(root, directory) const learn = parse(text, [], { allowTrailingComma: true })?.learn return learn && typeof learn === "object" && !Array.isArray(learn) ? learn : undefined } - -/** - * Set `learn.capture` (learning from this person's sessions) in the project config: what `/rules` → "Turn - * learning on/off for me" writes. Match the project locations loaded by Config, never the user's global one. - */ -export async function writeProjectLearning(root: string, directory: string, capture: boolean): Promise { - const found = await projectConfigFile(root, directory) - const file = found.file - let text = found.text - const errors: ParseError[] = [] - const config = parse(text, errors, { allowTrailingComma: true }) - if (errors.length || !config || typeof config !== "object" || Array.isArray(config)) - throw new Error(`Cannot update ${file}: expected a JSON configuration object.`) - const indent = /^([ \t]+)"/m.exec(text)?.[1] - const formattingOptions = { - insertSpaces: !indent?.includes("\t"), - tabSize: indent?.length ?? 2, - eol: text.includes("\r\n") ? "\r\n" : "\n", - } - // Like the MCP config writer, patch JSONC without expanding variables or rewriting other keys. - text = applyEdits(text, modify(text, ["learn", "capture"], capture, { formattingOptions })) - // altimate_change start — preserve regular config permissions without following linked targets or ancestors - await SafeFS.mkdir(root, path.dirname(file), root) - await SafeFS.assertSafePath(root, file, root) - const mode = await fs.lstat(file).then((stat) => stat.mode & 0o777).catch((error: NodeJS.ErrnoException) => { - if (error.code === "ENOENT") return 0o600 - throw error - }) - const temporary = `${file}.${randomUUID()}.tmp` - try { - const handle = await SafeFS.open(root, temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, mode, root) - try { - await handle.writeFile(text) - await handle.chmod(mode) - } finally { - await handle.close() - } - await SafeFS.rename(root, temporary, file, root) - } finally { - await SafeFS.remove(root, temporary, root) - } - // altimate_change end - return file -} diff --git a/packages/opencode/src/altimate/learn/store.ts b/packages/opencode/src/altimate/learn/store.ts index c11b4884ac..f6e9e9a371 100644 --- a/packages/opencode/src/altimate/learn/store.ts +++ b/packages/opencode/src/altimate/learn/store.ts @@ -94,6 +94,16 @@ export function feedbackId(feedback: string, origin: string): string { } // Callers hold the learn transaction lock. Rename also protects readers from partial files. +// altimate_change start — workspace rules: the store the team rules are mirrored into is written only by rules sync +/** Store names learn's own writers refuse: `team` holds the workspace's team rules (`rules/store.ts` `TEAM_STORE`). */ +export const RESERVED_STORES: ReadonlySet = new Set(["team"]) + +function assertWritable(name: string) { + if (RESERVED_STORES.has(name)) + throw new StoreError(`"${name}" holds the workspace's team rules and is written only by workspace sync; choose another --name.`) +} +// altimate_change end + export async function writeAtomic(root: string, file: string, data: string, mode?: number) { const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp` let created = false @@ -201,6 +211,7 @@ export async function search(root: string, name: string, query: string) { /** Pinning is an explicit edit to approved lessons, serialized with reflection and promotion. */ export async function setPinned(root: string, name: string, id: string, pinned: boolean): Promise { + assertWritable(name) return transaction(root, async () => { const approved = await loadApproved(root, name) const lesson = approved.find((lesson) => lesson.id === id) @@ -252,6 +263,7 @@ export async function loadCandidate(root: string, name: string, opts: SeedOption } export async function saveCandidate(root: string, name: string, pb: Playbook.Playbook, applied: Applied[] = []) { + assertWritable(name) return transaction(root, async () => { const p = paths(root, name) const previous = (await loadCandidateLessons(root, name)) ?? await loadApproved(root, name) @@ -630,6 +642,7 @@ function validateLegacy(name: string, text: string): string | undefined { export class StoreError extends Error {} export async function promote(root: string, name: string, opts: PromoteOptions = {}): Promise<{ archived?: number }> { + assertWritable(name) return transaction(root, async () => { const p = paths(root, name) const candidate = await readCandidate(root, name) @@ -682,6 +695,7 @@ export async function promote(root: string, name: string, opts: PromoteOptions = /** Restores the most recently archived version and consumes it. */ export async function rollback(root: string, name: string): Promise<{ restored: number }> { + assertWritable(name) return transaction(root, async () => { const p = paths(root, name) await migrate(root, name) @@ -726,6 +740,7 @@ export async function rollback(root: string, name: string): Promise<{ restored: } export async function reject(root: string, name: string): Promise { + assertWritable(name) return transaction(root, async () => { const p = paths(root, name) const hasCandidate = (await readCandidate(root, name)) !== undefined diff --git a/packages/opencode/src/altimate/rules/api.ts b/packages/opencode/src/altimate/rules/api.ts index 3942835b39..897338a148 100644 --- a/packages/opencode/src/altimate/rules/api.ts +++ b/packages/opencode/src/altimate/rules/api.ts @@ -4,8 +4,9 @@ // // Every call takes the credential captured for one scope (`ActAs`), never the ambient one: rules, // proposals and usage are per tenant and account, and an account switch part-way through a pull must -// not send one account's data with another's key. Responses are parsed with zod; a malformed body is a -// contract break and fails the call. +// not send one account's data with another's key. Responses are parsed with zod; a malformed body fails the +// call as `transient` (the server may already have applied it, so the client keeps what it sent and retries). +// Pulled rules are parsed one by one: an invalid rule is skipped, not a reason to freeze every rule. import z from "zod" import { altimateRequest, ForbiddenError, NotFoundError, WorkspaceApiError, type ActAs } from "../workspace/api-client" @@ -51,11 +52,12 @@ export const SyncResponse = z.object({ is_owner: z.boolean(), settings: Settings, pending_count: z.number().int(), - rules: z.array(RuleOut), + // Each rule is validated on its own in `sync` below. + rules: z.array(z.unknown()), my_reviewed: z.array(Reviewed), cursor: z.string(), }) -export type SyncResponse = z.infer +export type SyncResponse = Omit, "rules"> & { rules: RuleOut[]; skipped: number } export const ORIGINS = ["session", "explicit", "bootstrap", "import_reviews"] as const export type Origin = (typeof ORIGINS)[number] @@ -106,7 +108,10 @@ export type FailureKind = | "repo_not_bound" /** A review refused for a coded reason (verification_flagged, target_changed, version_conflict...). */ | "conflict" + /** The credential was refused (expired or revoked key): nothing is wrong with what was sent. */ + | "auth" | "transient" + /** The server refused this request's content (400/422 with a code): sending it again cannot succeed. */ | "rejected" export class RulesApiError extends Error { @@ -135,17 +140,23 @@ export function classify(error: unknown): RulesApiError { if (code === "rules_not_available" || !error.detail) return new RulesApiError("unsupported", messageOf(error.detail, message), code, 404) return new RulesApiError("rejected", messageOf(error.detail, message), code, 404) } - if (error instanceof ForbiddenError) return new RulesApiError("forbidden", messageOf(error.detail, message), codeOf(error.detail), 403) + if (error instanceof ForbiddenError) { + const code = codeOf(error.detail) + // A 403 with no code is the credential, not a decision about this workspace. + return code ? new RulesApiError("forbidden", messageOf(error.detail, message), code, 403) : new RulesApiError("auth", message, undefined, 403) + } if (error instanceof WorkspaceApiError) { const code = codeOf(error.detail) if (code === "repo_not_bound") return new RulesApiError("repo_not_bound", messageOf(error.detail, message), code, error.status) - if (error.status === undefined || error.status === 408 || error.status === 429 || error.status >= 500) - return new RulesApiError("transient", message, code, error.status) - return new RulesApiError("rejected", messageOf(error.detail, message), code, error.status) + if (error.status === 401 || error.status === 407) return new RulesApiError("auth", message, code, error.status) + // Only a coded 400/422 is about the request's content; anything else (a proxy 413, an uncoded 400) is kept. + if ((error.status === 400 || error.status === 422) && code) return new RulesApiError("rejected", messageOf(error.detail, message), code, error.status) + return new RulesApiError("transient", message, code, error.status) } const named = error as { name?: string; detail?: Record } | undefined if (named?.name === "ConflictError") return new RulesApiError("conflict", messageOf(named.detail, message), codeOf(named.detail), 409) - return new RulesApiError(named?.name === "ZodError" ? "rejected" : "transient", message) + if (named?.name === "PreconditionFailedError") return new RulesApiError("conflict", messageOf(named.detail, message), codeOf(named.detail), 412) + return new RulesApiError("transient", message) } async function call(schema: z.ZodType, actAs: ActAs, method: string, datamateId: number, subpath: string, body?: unknown): Promise { @@ -157,8 +168,13 @@ async function call(schema: z.ZodType, actAs: ActAs, method: string, datam } } -export function sync(actAs: ActAs, datamateId: number, body: { repo_remote: string; known_revision?: number; reviewed_since?: string }) { - return call(SyncResponse, actAs, "POST", datamateId, "/sync", body) +export async function sync(actAs: ActAs, datamateId: number, body: { repo_remote: string; known_revision?: number; reviewed_since?: string }): Promise { + const raw = await call(SyncResponse, actAs, "POST", datamateId, "/sync", body) + const rules = raw.rules.flatMap((rule) => { + const parsed = RuleOut.safeParse(rule) + return parsed.success ? [parsed.data] : [] + }) + return { ...raw, rules, skipped: raw.rules.length - rules.length } } export async function propose(actAs: ActAs, datamateId: number, body: { repo_remote: string; items: ProposalItem[] }) { diff --git a/packages/opencode/src/altimate/rules/classify.ts b/packages/opencode/src/altimate/rules/classify.ts index 6a825ea05f..5dea35c178 100644 --- a/packages/opencode/src/altimate/rules/classify.ts +++ b/packages/opencode/src/altimate/rules/classify.ts @@ -13,8 +13,16 @@ export type Kind = "rule" | "memory" | "ambiguous" +// The person named the kind: these decide on their own, whatever else the text says. +const ASKED_RULE = /\bas\s+a\s+(?:team\s+)?rule\b/i +const ASKED_MEMORY = /\b(?:just\s+)?for\s+me\b|\bas\s+a\s+memory\b|\bin\s+(?:my\s+)?memory\b/i + +// "I want …" / "I like …" open many ordinary requests ("I want staging models in models/staging"): on its own +// that is not enough to file a team convention as a personal memory, so it only makes the text ambiguous. +const WEAK_MEMORY = /\bi\s+(?:like|want)\b/i + const MEMORY_CUES: readonly [RegExp, string][] = [ - [/\bi\s+(?:prefer|like|want|usually|tend to)\b/i, "a personal preference"], + [/\bi\s+(?:prefer|usually|tend to)\b/i, "a personal preference"], [/\bfor\s+me\b|\bmy\s+(?:preference|style|default|setup|machine|laptop)\b/i, "a personal preference"], [/\bremember\s+(?:that|our|the)\b(?!.*\b(?:always|never|must)\b)/i, "a fact to remember"], [/\b(?:our|the)\s+(?:warehouse|database|account|cluster|stack|region|team|company|org)\s+(?:is|uses|runs|lives)\b/i, "a fact about the environment"], @@ -24,25 +32,27 @@ const MEMORY_CUES: readonly [RegExp, string][] = [ const RULE_CUES: readonly [RegExp, string][] = [ [/\b(?:always|never)\b/i, "an always/never instruction"], [/\bmust(?:\s+not)?\b|\bshould(?:\s+not|n't)?\s+(?:use|be|have|name|add|run)\b/i, "a must/should instruction"], - [/\bwe\s+(?:always|never|use|don't|do not)\b|\b(?:our|team|house|repo|project)\s+(?:convention|standard|rule|style|policy)\b/i, "a team convention"], + // Not a bare "we use": "we use Snowflake" is a fact about the environment. + [/\bwe\s+(?:always|never|don't|do not)\b|\b(?:our|team|house|repo|project)\s+(?:convention|standard|rule|style|policy)\b/i, "a team convention"], [/\buse\s+\S+\s+instead\s+of\b|\b(?:don't|do not|avoid)\s+(?:use|using|add|write|name|cast)\b/i, "a do/don't instruction"], [/\b(?:name|prefix|suffix|alias)\s+\S+(?:\s+\S+)?\s+(?:as|with|like)\b/i, "a naming convention"], - [/\bas\s+a\s+(?:team\s+)?rule\b/i, "asked for a rule"], ] export function classify(text: string): { kind: Kind; cue?: string } { + if (ASKED_RULE.test(text)) return { kind: "rule", cue: "asked for a rule" } + if (ASKED_MEMORY.test(text)) return { kind: "memory", cue: "asked to remember it for themselves" } const memory = MEMORY_CUES.find(([pattern]) => pattern.test(text)) const rule = RULE_CUES.find(([pattern]) => pattern.test(text)) - if (rule && !memory) return { kind: "rule", cue: rule[1] } + if (rule && !memory && !WEAK_MEMORY.test(text)) return { kind: "rule", cue: rule[1] } if (memory && !rule) return { kind: "memory", cue: memory[1] } - // "As a rule" wins over a stray preference cue: the person named the kind. - if (rule?.[1] === "asked for a rule") return { kind: "rule", cue: rule[1] } return { kind: "ambiguous" } } -/** A preference is the person's own (global memory); a fact belongs to the project. */ +/** A preference is the person's own (global memory); a fact belongs to the project, even when it says "my". */ export function memoryScope(text: string): "global" | "project" { - return /\bi\s+(?:prefer|like|want|usually|tend to)\b|\bfor\s+me\b|\bmy\s+/i.test(text) ? "global" : "project" + return /\bi\s+(?:prefer|like|want|usually|tend to)\b|\bfor\s+me\b|\bmy\s+(?:preference|style|default|setup|machine|laptop|editor|shell)\b/i.test(text) + ? "global" + : "project" } export * as RuleClassify from "./classify" diff --git a/packages/opencode/src/altimate/rules/from-history.ts b/packages/opencode/src/altimate/rules/from-history.ts index b172f386fc..54ed30ee86 100644 --- a/packages/opencode/src/altimate/rules/from-history.ts +++ b/packages/opencode/src/altimate/rules/from-history.ts @@ -1,68 +1,100 @@ // altimate_change - new file // -// `learn bootstrap` and `learn import-reviews` in a linked project: the lessons they staged from history become -// proposals in the owner's review queue (origin `bootstrap` / `import_reviews`), sent without a card per rule. -// Only what this run staged is sent: the caller snapshots the candidate first (`candidateTexts`). A candidate -// that this run created is discarded; one that existed before is kept for `learn show` / `learn promote`. -// One summary line reports how many were sent. +// `learn bootstrap` and `learn import-reviews` in a linked project: the rules they staged from history can go to the +// owner's review queue (origin `bootstrap` / `import_reviews`), without a card per rule but never without consent: +// `stagedRules` lists what this run staged (linted, scoped), the command shows the list and asks to send it to the +// named workspace (or takes `--send-for-review`), and only then `sendStaged` queues it. Only what this run staged is +// offered: the caller snapshots the candidate first (`candidateTexts`). A candidate this run created is discarded +// once everything in it was sent; otherwise, and when the person declines, it stays for `learn show` / `learn promote`. +import { lint } from "../learn/curator" import * as Store from "../learn/store" +import type { ProposalItem } from "./api" import { RulesStore } from "./store" import { RulesSync } from "./sync" -/** The candidate's lesson texts before a run, or undefined when there is no candidate. */ +/** Path globs per proposal, and their length: the same bounds the card applies. */ +const MAX_APPLIES_TO = 4 +const MAX_GLOB = 200 + +/** The candidate's rule texts before a run, or undefined when there is no candidate. */ export async function candidateTexts(root: string, name: string): Promise | undefined> { const candidate = await Store.loadCandidateLessons(root, name) return candidate ? new Set(candidate.map((lesson) => lesson.text)) : undefined } -export async function proposeStaged(input: { +export interface Staged { + workspace: { name: string; owner: string } + /** What would be sent, in order. */ + items: ProposalItem[] + /** Staged rules that failed the rule lint and stay local only. */ + invalid: { text: string; reason: string }[] +} + +/** What this run staged that is not approved yet, as proposals. Undefined when not linked or nothing is new. */ +export async function stagedRules(input: { root: string - directory: string - learn: Parameters[2] name: string origin: "bootstrap" | "import_reviews" /** `candidateTexts` before the run. */ before: Set | undefined -}): Promise<{ status: "not_linked" } | { status: "none" } | { status: "sent"; proposed: number; sent: number; queued: number; message: string }> { +}): Promise { const state = await RulesStore.read(input.root) - if (!state.scope) return { status: "not_linked" } + if (!state.workspace) return undefined const [approved, candidate] = await Promise.all([ Store.loadApproved(input.root, input.name), Store.loadCandidateLessons(input.root, input.name), ]) - if (!candidate) return { status: "none" } + if (!candidate) return undefined const known = new Set([...approved.map((lesson) => lesson.text), ...(input.before ?? [])]) - const added = candidate.filter((lesson) => !known.has(lesson.text)) - if (!added.length) { - // Nothing new beyond the approved lessons: a candidate this run created only duplicates them. - if (!input.before) await Store.reject(input.root, input.name) - return { status: "none" } - } - for (const lesson of added) - await RulesSync.enqueue(input.root, { + const items: ProposalItem[] = [] + const invalid: Staged["invalid"] = [] + for (const lesson of candidate) { + if (known.has(lesson.text)) continue + const reason = lint(lesson.text) + if (reason) { + invalid.push({ text: lesson.text, reason }) + continue + } + items.push({ change: "add", text: lesson.text, - applies_to: lesson.trigger?.paths ?? [], + applies_to: (lesson.trigger?.paths ?? []).filter((glob) => glob.length <= MAX_GLOB).slice(0, MAX_APPLIES_TO), why: input.origin === "bootstrap" ? "Found in past sessions (learn bootstrap)" : "Found in past pull request reviews", origin: input.origin, evidence_sessions: 1, corrections: [], }) - // The workspace's review queue replaces a candidate this run created. - if (!input.before) await Store.reject(input.root, input.name) - const report = await RulesSync.runFresh(input.root, input.directory, input.learn) - const sent = report.push?.sent.filter((s) => s.status === "pending").length ?? 0 - const queued = (await RulesStore.read(input.root)).outbox.length - const owner = state.workspace?.owner_name ?? "the workspace owner" - return { - status: "sent", - proposed: added.length, - sent, - queued, - message: `${sent} ${sent === 1 ? "rule" : "rules"} sent for ${owner}'s review in ${state.workspace?.name ?? "the workspace"}` + - (queued ? `; ${queued} queued until the workspace is reachable` : "") + "." + - (input.before ? " Your earlier local candidate was kept: `learn show`." : ""), } + if (!items.length && !invalid.length) return undefined + return { workspace: { name: state.workspace.name, owner: state.workspace.owner_name ?? "the workspace owner" }, items, invalid } +} + +/** Queue the confirmed proposals and send them now. */ +export async function sendStaged(input: { + root: string + directory: string + learn: Parameters[2] + name: string + staged: Staged + /** `candidateTexts` before the run: a candidate that existed before is never discarded. */ + before: Set | undefined +}): Promise<{ sent: number; queued: number; refused: number; message: string }> { + const ids = await RulesSync.enqueueMany(input.root, input.staged.items.map((item) => ({ item }))) + const report = await RulesSync.runFresh(input.root, input.directory, input.learn) + const state = await RulesStore.read(input.root) + const outcomes = ids.map((id, index) => RulesSync.outcomeOf(state, report, id, input.staged.items[index].text)) + const queued = outcomes.filter((o) => o.status === "queued").length + const refused = outcomes.filter((o) => o.status === "sent" && o.row?.error).length + const sent = outcomes.length - queued - refused + // The workspace's review queue replaces a candidate this run created, once it holds every rule of it. + const discard = !input.before && queued === 0 && refused === 0 && input.staged.invalid.length === 0 + if (discard) await Store.reject(input.root, input.name) + const { owner, name } = input.staged.workspace + const message = `${sent} ${sent === 1 ? "rule" : "rules"} sent for ${owner}'s review in ${name}` + + (queued ? `; ${queued} queued until the workspace is reachable` : "") + + (refused ? `; ${refused} refused by the workspace` : "") + "." + + (discard ? "" : " The local candidate was kept: `learn show`.") + return { sent, queued, refused, message } } export * as RulesFromHistory from "./from-history" diff --git a/packages/opencode/src/altimate/rules/proposal.ts b/packages/opencode/src/altimate/rules/proposal.ts index 84587cd148..9c31865dca 100644 --- a/packages/opencode/src/altimate/rules/proposal.ts +++ b/packages/opencode/src/altimate/rules/proposal.ts @@ -41,6 +41,20 @@ interface Pending { learn: { enabled?: boolean } | undefined /** A reply is being carried out: a second one is refused. */ busy?: boolean + /** When the card was asked (ms): cards older than `CARD_TTL_MS` are dropped. */ + at: number +} + +/** An unanswered card lasts a day; after that it is dropped rather than re-offered to every new client. */ +export const CARD_TTL_MS = 24 * 60 * 60_000 + +function expire(now = Date.now()) { + for (const [id, entry] of pending) if (now - entry.at > CARD_TTL_MS) pending.delete(id) +} + +/** Drop the cards of a project (its instance is going away; nobody can answer them after that). */ +export function drop(directory: string) { + for (const [id, entry] of pending) if (entry.directory === directory) pending.delete(id) } // Process-global like `question`'s registry: the server routes and the session loop may load this module @@ -59,11 +73,14 @@ async function publish RulesStore.dismissed(state, topic))) return undefined for (const existing of pending.values()) @@ -94,19 +112,26 @@ export async function ask(input: { sessionID: string; root: string; directory: s return existing.card } const card = build(randomUUID(), input.sessionID, input.draft, state) - pending.set(card.id, { card, root: input.root, directory: input.directory, learn: input.learn }) + pending.set(card.id, { card, root: input.root, directory: input.directory, learn: input.learn, at: Date.now() }) await publish(RuleEvents.Event.Asked, card) return card } -export function list(directory?: string): ProposalCard[] { - return [...pending.values()].filter((p) => !directory || p.directory === directory).map((p) => p.card) +/** Open cards, optionally of one project and one session. */ +export function list(directory?: string, sessionID?: string): ProposalCard[] { + expire() + return [...pending.values()] + .filter((p) => (!directory || p.directory === directory) && (!sessionID || p.card.sessionID === sessionID)) + .map((p) => p.card) } export type ReplyAction = ProposalCard["actions"][number] +/** Why a reply did not go through, for clients and HTTP status: the card is gone, the request does not fit this + * card (or another reply is running), the new wording failed the lint, or the action itself failed. */ +export type ReplyFailure = "not_open" | "busy" | "not_available" | "invalid_text" | "failed" export type ReplyResult = | { ok: true; message: string; card?: ProposalCard; closed: boolean } - | { ok: false; error: string; card?: ProposalCard } + | { ok: false; code: ReplyFailure; error: string; card?: ProposalCard } /** Lint an edited wording like the curator does for any rule: one line, within the limit, no commands. */ function editProblem(text: string): string | undefined { @@ -127,20 +152,27 @@ export function slug(text: string): string { * chosen action succeeds. */ export async function reply(id: string, input: { action: ReplyAction; text?: string }, directory?: string): Promise { const entry = pending.get(id) - if (!entry || (directory !== undefined && entry.directory !== directory)) return { ok: false, error: "This proposal is no longer open." } - if (entry.busy) return { ok: false, error: "This proposal is already being answered.", card: entry.card } + if (!entry || (directory !== undefined && entry.directory !== directory)) + return { ok: false, code: "not_open", error: "This proposal is no longer open." } + if (entry.busy) return { ok: false, code: "busy", error: "This proposal is already being answered.", card: entry.card } const { card } = entry - if (!card.actions.includes(input.action)) return { ok: false, error: `"${input.action}" is not available for this proposal.`, card } + if (!card.actions.includes(input.action)) + return { ok: false, code: "not_available", error: `"${input.action}" is not available for this proposal.`, card } if (input.action === "edit") { const text = (input.text ?? "").trim() const problem = editProblem(text) - if (problem) return { ok: false, error: `Not changed: the rule ${problem}.`, card } + if (problem) return { ok: false, code: "invalid_text", error: `Not changed: the rule ${problem}.`, card } const flag = verificationWarning(text) const next: ProposalCard = { ...card, text, chars: text.length, weakens_verification: flag } if (!flag) delete next.weakens_verification - entry.card = next - await publish(RuleEvents.Event.Updated, next) + entry.busy = true + try { + entry.card = next + await publish(RuleEvents.Event.Updated, next) + } finally { + entry.busy = false + } return { ok: true, message: "Wording updated.", card: next, closed: false } } @@ -150,7 +182,7 @@ export async function reply(id: string, input: { action: ReplyAction; text?: str message = await act(entry, input.action) } catch (error) { log.warn("rule proposal reply failed", { action: input.action, error }) - return { ok: false, error: `Not done: ${error instanceof Error ? error.message : String(error)}`, card } + return { ok: false, code: "failed", error: `Not done: ${error instanceof Error ? error.message : String(error)}`, card: entry.card } } finally { entry.busy = false } @@ -182,13 +214,15 @@ async function act(entry: Pending, action: Exclude): Promis message = "Saved as a rule for this project." } else { const approve = action === "approve" ? (card.weakens_verification ? "approve_anyway" : "approve") : undefined - // The workspace decides whether the person's words travel with a proposal. + // The workspace decides whether the person's words travel with a proposal: without quotes, `why` (which can + // paraphrase the correction) is replaced by a neutral line too. const quotes = (await RulesStore.read(entry.root)).settings?.include_quotes !== false - await RulesSync.enqueue(entry.root, { + const why = card.why ? (quotes ? redactSecrets(card.why) : neutralWhy(card)) : undefined + const outboxId = await RulesSync.enqueue(entry.root, { change: card.change, text: card.text, applies_to: card.applies_to, - ...(card.why ? { why: card.why } : {}), + ...(why ? { why } : {}), origin: card.origin, ...(card.replaces ? { replaces: card.replaces.public_id, replaces_version: card.replaces.version } : {}), evidence_sessions: card.evidence.sessions, @@ -197,17 +231,34 @@ async function act(entry: Pending, action: Exclude): Promis : [], }, approve) const report = await RulesSync.runFresh(entry.root, entry.directory, entry.learn).catch(() => undefined) - const sent = report?.push?.sent.find((s) => s.text === card.text) + const outcome = RulesSync.outcomeOf(await RulesStore.read(entry.root), report, outboxId, card.text) const owner = card.workspace?.owner_name ?? "the workspace owner" - if (!sent) message = `Queued: it will be sent to ${owner}'s review queue when the workspace is reachable.` - else if (sent.error) message = `Not sent: ${sent.error}` - else if (sent.status === "exists") message = "Already a team rule (or waiting for review): nothing new sent." - else if (sent.approved) message = `Approved: now a team rule in ${card.workspace?.name ?? "the workspace"}.` - else message = `Sent to ${owner}'s review queue in ${card.workspace?.name ?? "the workspace"}.` + const ws = card.workspace?.name ?? "the workspace" + if (outcome.status === "queued") { + message = `Queued: it will be sent to ${owner}'s review queue when the workspace is reachable.` + } else if (outcome.row?.error) { + // Refused (e.g. an invalid wording): the card stays open so the wording can be edited and sent again. + throw new Error(`not sent: ${outcome.row.error}`) + } else if (outcome.row?.status === "exists") { + message = "Already a team rule (or waiting for review): nothing new sent." + } else if (outcome.row?.approved || outcome.sent?.status === "active") { + message = `Approved: now a team rule in ${ws}.` + } else if (outcome.row?.approve_error) { + message = `Sent to ${owner}'s review queue in ${ws}, but approving it failed (${outcome.row.approve_error}). Approve it in the workspace's Rules tab.` + } else { + message = `Sent to ${owner}'s review queue in ${ws}.` + } } return message } +/** `why` without the drafter's wording, for a workspace that keeps the person's words out of proposals. */ +function neutralWhy(card: ProposalCard): string { + if (card.origin === "explicit") return "Asked to remember this" + if (card.change !== "add") return "Corrected after the rule was shown" + return card.evidence.sessions > 1 ? `Corrected in ${card.evidence.sessions} sessions` : "Corrected in a session" +} + /** A session reference the server can count without learning the session id. */ function hashRef(sessionID: string) { return new Bun.CryptoHasher("sha256").update(sessionID).digest("hex").slice(0, 16) diff --git a/packages/opencode/src/altimate/rules/service.ts b/packages/opencode/src/altimate/rules/service.ts index 4f2fe0f393..ec38d8f940 100644 --- a/packages/opencode/src/altimate/rules/service.ts +++ b/packages/opencode/src/altimate/rules/service.ts @@ -2,7 +2,9 @@ // // Per instance: keep workspace rules fresh and turn a session's corrections into proposal cards. // -// - Sync in the background at start, then every `REFRESH_MS` (approved rules reach sessions within minutes). +// - Sync in the background at start, then every `REFRESH_MS` (approved rules reach sessions within minutes), for a +// project that is linked (local binding or pulled state); others pay no network call. The timer and the idle +// hook are always registered and check the settings each time, so turning learning on later needs no restart. // - Capture corrections when learning is on for this project: the person's own setting, else the // workspace's "Learn from sessions" (see `learningEnabled`). Capture enabled in config starts on its own; // capture started here stops when learning turns off (setting changed, or the project unlinked). @@ -25,16 +27,26 @@ const log = Log.create({ service: "rules.service" }) type LearnConfig = { enabled?: boolean; capture?: boolean; model?: string } | undefined -/** The loaded config's `learn`, with the project file's `learn` as it is on disk now (`learn enable/disable` - * and `/rules` write it while the session runs). */ -async function learnConfig(ctx: InstanceContext): Promise { - const [{ Config }, { readProjectLearning }] = await Promise.all([import("@/config/config"), import("../learn/project-config")]) - const loaded = Config.peek(ctx)?.learn as LearnConfig - const root = ctx.worktree !== "/" ? ctx.worktree : ctx.directory - const onDisk = (await readProjectLearning(root, ctx.directory).catch(() => undefined)) as LearnConfig +/** `loaded` (the session's config) with the project file's `learn` as it is on disk now: `learn enable/disable` + * write it while the session runs. Shared by the service and the routes so both decide the same way. */ +export async function effectiveLearn(loaded: LearnConfig, root: string, directory: string): Promise { + const { readProjectLearning } = await import("../learn/project-config") + const onDisk = (await readProjectLearning(root, directory).catch(() => undefined)) as LearnConfig return loaded || onDisk ? { ...loaded, ...onDisk } : undefined } +async function learnConfig(ctx: InstanceContext): Promise { + const { Config } = await import("@/config/config") + return effectiveLearn(Config.peek(ctx)?.learn as LearnConfig, ctx.worktree !== "/" ? ctx.worktree : ctx.directory, ctx.directory) +} + +/** Whether a pull can find anything: pulled state, or a local workspace binding. No network. */ +async function linked(root: string, directory: string): Promise { + if ((await RulesStore.read(root).catch(() => undefined))?.scope) return true + const { readLocalBinding } = await import("../workspace/state") + return !!(await readLocalBinding(directory).catch(() => null)) +} + function deps(ctx: InstanceContext, model?: string): RuleTrigger.TriggerDeps { return { source: async (sessionID) => (await import("../learn/session-reflect")).sourceFromSession(sessionID, ctx), @@ -73,9 +85,20 @@ export async function syncNow(root: string, directory: string, learn: Parameters return report } +/** + * The bounded pull before a session's first turn, so it sees rules approved since the last refresh, with the + * notices it brings published. The session loop calls it once per session; later prompts rely on the timer. + */ +export async function sessionStart(root: string, directory: string, learn: LearnConfig): Promise { + if (!RulesSync.rulesEnabled(learn) || !(await linked(root, directory))) return + // A project with no rules state yet only waits for the service's first pull if it is running: starting one here + // would add up to `SESSION_WAIT_MS` to the first prompt of every session in a project without rules. + const hasState = !!(await RulesStore.read(root).catch(() => undefined))?.scope + await RulesSync.sessionStart(root, directory, learn, { startIfDue: hasState, onReport: (report) => publishNotices(directory, report) }) +} + export async function start(ctx: InstanceContext): Promise { try { - if (!RulesSync.rulesEnabled(await learnConfig(ctx))) return const root = await fs.realpath(ctx.worktree !== "/" ? ctx.worktree : ctx.directory) const { Instance } = await import("@/project/instance") let disposed = false @@ -106,7 +129,7 @@ export async function start(ctx: InstanceContext): Promise { const refresh = Instance.bind(async () => { if (disposed) return const learn = await learnConfig(ctx) - if (!RulesSync.rulesEnabled(learn)) return void (await followLearning(learn)) + if (!RulesSync.rulesEnabled(learn) || !(await linked(root, ctx.directory))) return void (await followLearning(learn)) const report = await syncNow(root, ctx.directory, learn).catch((error) => { log.warn("rules sync failed", { error: error instanceof Error ? error.message : String(error) }) return undefined @@ -125,8 +148,13 @@ export async function start(ctx: InstanceContext): Promise { await flushCapture() await RuleTrigger.afterTurn({ sessionID, root, directory: ctx.directory, learn, deps: deps(ctx, learn?.model) }) }) + // A settings change (`/rules` learning on/off) starts or stops capture now, not at the next refresh. + const onChanged = Instance.bind(async () => { + if (!disposed) await followLearning(await learnConfig(ctx)) + }) const stop = Bus.subscribeAll((event) => { if (event.type === "session.idle") void onIdle(event.properties.sessionID) + if (event.type === RuleEvents.Event.Changed.type && (event.properties as { directory?: string }).directory === ctx.directory) void onChanged() }) const unregister = registerDisposer(async (directory) => { if (directory !== ctx.directory) return @@ -134,6 +162,9 @@ export async function start(ctx: InstanceContext): Promise { clearInterval(timer) stop() unregister() + // Cards nobody answered go with the instance: they cannot be answered after it. + const { RuleProposal } = await import("./proposal") + RuleProposal.drop(ctx.directory) // The capture's own disposer stops it too; this covers a stop handle taken before disposal. await stopCapture?.() stopCapture = undefined diff --git a/packages/opencode/src/altimate/rules/store.ts b/packages/opencode/src/altimate/rules/store.ts index db77c2095a..1ff6560e8f 100644 --- a/packages/opencode/src/altimate/rules/store.ts +++ b/packages/opencode/src/altimate/rules/store.ts @@ -16,13 +16,15 @@ import fs from "node:fs/promises" import path from "node:path" import z from "zod" import * as SafeFS from "../learn/safe-fs" -import { canonical, type Lesson } from "../learn/lesson" +import { Log } from "@/util/log" +import { canonical, parse as parseLessons, type Lesson } from "../learn/lesson" import { withLearnLock } from "../learn/lock" import * as Store from "../learn/store" import { RuleOut, Settings } from "./api" -/** The read-only store team rules are mirrored into. */ +/** The read-only store team rules are mirrored into (reserved: `learn/store.ts` `RESERVED_STORES`). */ export const TEAM_STORE = "team" +const log = Log.create({ service: "rules.store" }) const LOCK = { timeoutMs: 5_000 } const MAX_NOTICES = 50 const MAX_SENT = 200 @@ -110,8 +112,16 @@ export const State = z.object({ last_check: z.object({ at: z.string(), session: z.string(), outcome: z.string() }).optional(), /** Correction signals already considered for a proposal. */ processed_signals: z.array(z.string()).default([]), - /** Set once `learn bootstrap` proposed rules from history for this scope. */ - bootstrapped_at: z.string().optional(), + /** The person's own "learning from sessions" choice for this project (`/rules`), when they made one. Kept here, + * not in the project config, because that file is usually committed and the choice is per person. */ + personal_learning: z.boolean().optional(), + /** A usage report sent but not yet acknowledged: resent with the same `batch_id` so the server counts it once. */ + pending_usage: z.object({ + batch_id: z.string(), + items: z.array(z.object({ public_id: z.string(), applied: z.number().int(), helpful: z.number().int(), harmful: z.number().int(), corrected_again: z.number().int() })), + sessions: z.array(z.string()), + corrected: z.record(z.string(), z.number().int()), + }).optional(), }) export type State = z.infer @@ -212,14 +222,42 @@ async function writeMirror(root: string, rules: readonly RuleOut[]) { const file = Store.paths(root, TEAM_STORE).approved const text = canonical(rules.map(toLesson)) const previous = await fs.readFile(file, "utf8").catch(() => undefined) - if (previous !== text) await Store.writeAtomic(root, file, text) + if (previous === text) return + // A playbook someone named `team` before workspace rules existed: keep a copy instead of overwriting it. + if (previous !== undefined && !isMirror(previous)) { + const backup = `${file}.before-workspace-rules-${Date.now()}` + await Store.writeAtomic(root, backup, previous, 0o600) + log.warn("an existing `team` rule store was set aside for the workspace team rules", { backup }) + } + await Store.writeAtomic(root, file, text) } -/** Forget everything pulled for another scope (account, workspace or remote changed) or after unlinking. */ -export async function reset(root: string): Promise { - await update(root, (state) => ({ ...structuredClone(EMPTY), dismissed: state.dismissed })) +/** Whether an `approved.json` is this mirror: every entry came from a workspace rule. */ +function isMirror(text: string): boolean { + try { + return parseLessons(text).every((lesson) => lesson.provenance?.startsWith("workspace rule ")) + } catch { + return false + } } +/** + * Forget what was pulled. The person's dismissals and own learning choice always survive. Queued proposals survive + * too when the scope did not change (`keepOutbox`: the link went away or access was refused, possibly for a while); + * `adopt` drops them before anything is sent to a different scope. + */ +export async function reset(root: string, options: { keepOutbox?: boolean } = {}): Promise { + await update(root, (state) => ({ + ...structuredClone(EMPTY), + dismissed: state.dismissed, + ...(state.personal_learning !== undefined ? { personal_learning: state.personal_learning } : {}), + ...(options.keepOutbox ? { outbox: state.outbox, scope: state.scope } : {}), + })) +} + +/** Queued proposals at most; a new one is refused (the card stays open) rather than an older one dropped. */ +export const MAX_OUTBOX = 200 + export function dismissed(state: State, topic: string): boolean { return state.dismissed.some((d) => d.topic === topic.toLowerCase()) } diff --git a/packages/opencode/src/altimate/rules/summary.ts b/packages/opencode/src/altimate/rules/summary.ts index eeff22510d..d0502a1986 100644 --- a/packages/opencode/src/altimate/rules/summary.ts +++ b/packages/opencode/src/altimate/rules/summary.ts @@ -19,6 +19,8 @@ export interface Summary { learning: boolean /** Who decided `learning`: the person's own setting, or the workspace's "Learn from sessions". */ learning_source: "you" | "workspace" | "default" + /** The person's `/rules` choice, when they made one (absent: following config or the workspace). */ + personal_learning?: boolean pulled_at?: string last_error?: string notices: RulesStore.Notice[] @@ -28,16 +30,17 @@ export interface Summary { export async function summary(root: string, learn: LearnConfig, env: NodeJS.ProcessEnv = process.env): Promise { const state = await RulesStore.read(root) - const own = env.ALTIMATE_LEARN_CAPTURE !== undefined && env.ALTIMATE_LEARN_CAPTURE !== "" || learn?.capture !== undefined || learn?.enabled === false + const learning = RuleTrigger.learningDecision(learn, state, env) return { - linked: !!state.scope, + linked: !!state.workspace, ...(state.workspace ? { workspace: state.workspace } : {}), team_rules: state.rules.length, pending_review: state.pending_count, mine_pending: state.sent.filter((s) => s.status === "pending").length, queued: state.outbox.length, - learning: RuleTrigger.learningEnabled(learn, state, env), - learning_source: own ? "you" : state.scope ? "workspace" : "default", + learning: learning.on, + learning_source: learning.source, + ...(state.personal_learning !== undefined ? { personal_learning: state.personal_learning } : {}), ...(state.pulled_at ? { pulled_at: state.pulled_at } : {}), ...(state.last_error ? { last_error: state.last_error } : {}), notices: state.notices.slice(-10).reverse(), diff --git a/packages/opencode/src/altimate/rules/sync.ts b/packages/opencode/src/altimate/rules/sync.ts index 139dd54203..4a7b6ee5df 100644 --- a/packages/opencode/src/altimate/rules/sync.ts +++ b/packages/opencode/src/altimate/rules/sync.ts @@ -25,6 +25,8 @@ export const REFRESH_MS = 5 * 60_000 export const SESSION_WAIT_MS = 2_000 const MAX_PROPOSALS = 50 const MAX_USAGE_DELTA = 50 +/** The server's per-batch limit on usage items (`MAX_USAGE_ITEMS`). */ +const MAX_USAGE_ITEMS = 500 type LearnConfig = { enabled?: boolean } | undefined @@ -122,9 +124,9 @@ export async function pull(root: string, ctx: Ready): Promise { }) return { ok: true, rules: state.rules.length, pending: state.pending_count, notices: fresh } } catch (error) { - if (error instanceof RulesApiError && (error.kind === "workspace_not_found" || error.kind === "repo_not_bound")) { - // The link is gone: stop delivering its rules. - await RulesStore.reset(root) + if (error instanceof RulesApiError && (error.kind === "workspace_not_found" || error.kind === "repo_not_bound" || error.kind === "forbidden")) { + // The link is gone or access was taken away: stop delivering its rules. Queued proposals stay (same scope). + await RulesStore.reset(root, { keepOutbox: true }) } else { await RulesStore.update(root, (state) => { state.last_error = errorText(error) }) } @@ -134,7 +136,18 @@ export async function pull(root: string, ctx: Ready): Promise { } export interface PushResult { - sent: { text: string; status: string; public_id?: string; error?: string; approved?: boolean }[] + sent: { + /** The outbox entry this answers. */ + id: string + text: string + status: string + public_id?: string + /** Not stored: the server refused it, or answered with a non-storing status. */ + error?: string + approved?: boolean + /** Stored for review, but "Approve now" failed (e.g. verification_flagged). */ + approve_error?: string + }[] queued: number error?: string } @@ -180,7 +193,7 @@ export async function push(root: string, ctx: Ready): Promise { if (error) { if (error.kind === "rejected") { done.add(entry.id) - sent.push({ text: entry.item.text, status: "rejected", error: error.message }) + sent.push({ id: entry.id, text: entry.item.text, status: "rejected", error: error.message }) } else failed.set(entry.id, error.message) continue } @@ -191,6 +204,7 @@ export async function push(root: string, ctx: Ready): Promise { done.add(entry.id) const ok = result.status === "pending" || result.status === "exists" || result.duplicate sent.push({ + id: entry.id, text: entry.item.text, status: result.status, ...(result.public_id ? { public_id: result.public_id } : {}), @@ -209,7 +223,7 @@ export async function push(root: string, ctx: Ready): Promise { if (row) Object.assign(row, { status: reviewed.status, approved: true }) } catch (error) { const row = sent.find((s) => s.public_id === approval.publicId) - if (row) row.error = `sent for review; approving failed: ${errorText(error)}` + if (row) row.approve_error = errorText(error) } } const now = new Date().toISOString() @@ -234,15 +248,61 @@ export async function push(root: string, ctx: Ready): Promise { return { sent, queued: after.outbox.length, ...(error ? { error } : {}) } } -/** Queue a proposal; it is sent by the next push. */ -export async function enqueue(root: string, item: ProposalItem, approve?: "approve" | "approve_anyway") { +/** Queue proposals for the next push, in one write. Returns their outbox ids, in order. Refuses past `MAX_OUTBOX`. */ +export async function enqueueMany(root: string, items: readonly { item: ProposalItem; approve?: "approve" | "approve_anyway" }[]): Promise { + const ids = items.map(() => randomUUID()) + const at = new Date().toISOString() await RulesStore.update(root, (state) => { - state.outbox.push({ id: randomUUID(), created_at: new Date().toISOString(), attempts: 0, item, ...(approve ? { approve } : {}) }) + if (state.outbox.length + items.length > RulesStore.MAX_OUTBOX) + throw new Error(`${state.outbox.length} proposals are already waiting to be sent; sync before proposing more`) + items.forEach(({ item, approve }, index) => + state.outbox.push({ id: ids[index], created_at: at, attempts: 0, item, ...(approve ? { approve } : {}) })) }) + return ids } -/** Report usage of team rules: delivered sessions and "corrected again", each counted once. */ +/** Queue one proposal; it is sent by the next push. Returns its outbox id. */ +export async function enqueue(root: string, item: ProposalItem, approve?: "approve" | "approve_anyway"): Promise { + return (await enqueueMany(root, [{ item, ...(approve ? { approve } : {}) }]))[0] +} + +/** Where an enqueued proposal stands after a run: still queued, sent (with the server's answer), or gone. */ +export function outcomeOf(state: State, report: RunReport | undefined, outboxId: string, text: string): + | { status: "queued"; error?: string } + | { status: "sent"; row: PushResult["sent"][number] | undefined; sent?: State["sent"][number] } { + const queued = state.outbox.find((entry) => entry.id === outboxId) + if (queued) return { status: "queued", ...(queued.last_error ? { error: queued.last_error } : {}) } + const row = report?.push?.sent.find((s) => s.id === outboxId) + // Sent by another run (background refresh, session start): its record is in `sent`, by text. + return { status: "sent", row, sent: row?.public_id ? state.sent.find((s) => s.public_id === row.public_id) : [...state.sent].reverse().find((s) => s.text === text) } +} + +type PendingUsage = NonNullable + +/** Send one batch; on success advance the reported state and clear it from `pending_usage`. */ +async function sendUsage(root: string, ctx: Ready, batch: PendingUsage): Promise { + await RulesApi.usage(ctx.actAs, ctx.scope.datamateId, { batch_id: batch.batch_id, items: batch.items }) + await RulesStore.update(root, (s) => { + s.usage_sessions.push(...batch.sessions.filter((session) => !s.usage_sessions.includes(session))) + s.corrected_reported = { ...s.corrected_reported, ...batch.corrected } + if (s.pending_usage?.batch_id === batch.batch_id) s.pending_usage = undefined + }) +} + +/** + * Report usage of team rules: delivered sessions and "corrected again", each counted once. A batch is saved + * (`pending_usage`) before it is sent and resent with the same `batch_id` until the server acknowledges it, so a + * lost response cannot count twice. At most `MAX_USAGE_ITEMS` rules per batch; the rest go in the next run. + */ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: number; error?: string }> { + const before = await RulesStore.read(root) + if (before.pending_usage) { + try { + await sendUsage(root, ctx, before.pending_usage) + } catch (error) { + return { items: 0, error: errorText(error) } + } + } const state = await RulesStore.read(root) if (!state.rules.length) return { items: 0 } const byLesson = new Map(state.rules.map((rule) => [RulesStore.lessonId(rule.rule_key), rule])) @@ -251,13 +311,15 @@ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: nu const reported = new Set(state.usage_sessions) const counts = new Map() const sessions: string[] = [] - // A rule counts at most `MAX_USAGE_DELTA` sessions per report: later sessions wait for the next one. + // A rule counts at most `MAX_USAGE_DELTA` sessions per report, and a batch at most `MAX_USAGE_ITEMS` rules: + // later sessions wait for the next report. for (const snapshot of snapshots) { if (reported.has(snapshot.session)) continue const shown = [...new Set(snapshot.shown.filter((entry) => entry.name === RulesStore.TEAM_STORE).map((entry) => entry.id))] .flatMap((id) => byLesson.get(id)?.public_id ?? []) if (!shown.length) continue if (shown.some((publicId) => (counts.get(publicId)?.applied ?? 0) >= MAX_USAGE_DELTA)) break + if (new Set([...counts.keys(), ...shown]).size > MAX_USAGE_ITEMS) break sessions.push(snapshot.session) for (const publicId of shown) { const row = counts.get(publicId) ?? { applied: 0, corrected_again: 0 } @@ -275,6 +337,7 @@ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: nu if (!rule) continue const baseline = Math.min(state.corrected_reported[rule.public_id] ?? 0, row.recurred) const delta = Math.min(row.recurred - baseline, MAX_USAGE_DELTA) + if (delta > 0 && !counts.has(rule.public_id) && counts.size >= MAX_USAGE_ITEMS) continue corrected[rule.public_id] = baseline + Math.max(0, delta) if (delta <= 0) continue const entry = counts.get(rule.public_id) ?? { applied: 0, corrected_again: 0 } @@ -285,17 +348,19 @@ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: nu if (Object.keys(corrected).length) await RulesStore.update(root, (s) => { s.corrected_reported = { ...s.corrected_reported, ...corrected } }) return { items: 0 } } - const items = [...counts].map(([public_id, c]) => ({ public_id, applied: c.applied, helpful: 0, harmful: 0, corrected_again: c.corrected_again })) + const batch: PendingUsage = { + batch_id: randomUUID(), + items: [...counts].map(([public_id, c]) => ({ public_id, applied: c.applied, helpful: 0, harmful: 0, corrected_again: c.corrected_again })), + sessions, + corrected, + } + await RulesStore.update(root, (s) => { s.pending_usage = batch }) try { - await RulesApi.usage(ctx.actAs, ctx.scope.datamateId, { batch_id: randomUUID(), items }) + await sendUsage(root, ctx, batch) } catch (error) { return { items: 0, error: errorText(error) } } - await RulesStore.update(root, (s) => { - s.usage_sessions.push(...sessions) - s.corrected_reported = { ...s.corrected_reported, ...corrected } - }) - return { items: items.length } + return { items: batch.items.length } } export interface RunReport { @@ -305,6 +370,12 @@ export interface RunReport { usage?: { items: number; error?: string } } +/** A run report safe to hand to a client or print: the captured credential (`actAs`) is left out. */ +export function publicReport(report: RunReport) { + const { context, ...rest } = report + return { ...rest, context: context.status === "ready" ? { status: context.status, scope: context.scope } : context } +} + /** Push, pull, then report usage: "Sync now", `learn sync`, and the background refresh. */ export async function run(root: string, directory: string, learn: LearnConfig): Promise { const key = await canonicalRoot(root) @@ -312,7 +383,7 @@ export async function run(root: string, directory: string, learn: LearnConfig): if (ctx.status !== "ready") { if (ctx.status === "skipped" && ctx.unlinked) { const state = await RulesStore.read(root) - if (state.scope) await RulesStore.reset(root) + if (state.workspace) await RulesStore.reset(root, { keepOutbox: true }) } failedAt.set(key, Date.now()) return { context: ctx } @@ -361,12 +432,24 @@ export async function due(root: string): Promise { /** Session start: wait (at most `waitMs`) for a run in flight, or start one when due, so the first turn sees * fresh rules. */ -export async function sessionStart(root: string, directory: string, learn: LearnConfig, waitMs = SESSION_WAIT_MS): Promise { +export async function sessionStart( + root: string, + directory: string, + learn: LearnConfig, + options: { waitMs?: number; onReport?: (report: RunReport) => Promise; startIfDue?: boolean } = {}, +): Promise { if (!rulesEnabled(learn)) return const existing = inflight.get(await canonicalRoot(root)) - if (!existing && !(await due(root))) return - const running = (existing ?? runShared(root, directory, learn)).catch((error) => log.warn("rules session sync failed", { error: errorText(error) })) - await Promise.race([running, new Promise((resolve) => setTimeout(resolve, waitMs).unref?.())]) + // With `startIfDue: false` only a run already in flight is waited on (the service's first pull of a freshly + // linked project); nothing new is started on the prompt path. + if (!existing && (options.startIfDue === false || !(await due(root)))) return + // A run this call starts publishes its notices (the service's refresh publishes its own). + const running = existing ?? runShared(root, directory, learn).then(async (report) => { + await options.onReport?.(report) + return report + }) + const settled = running.catch((error) => log.warn("rules session sync failed", { error: errorText(error) })) + await Promise.race([settled, new Promise((resolve) => setTimeout(resolve, options.waitMs ?? SESSION_WAIT_MS).unref?.())]) } export * as RulesSync from "./sync" diff --git a/packages/opencode/src/altimate/rules/trigger.ts b/packages/opencode/src/altimate/rules/trigger.ts index 8db26ede63..e78845c387 100644 --- a/packages/opencode/src/altimate/rules/trigger.ts +++ b/packages/opencode/src/altimate/rules/trigger.ts @@ -12,6 +12,7 @@ // an ADD becomes an add card, an EDIT or REMOVE of a team rule becomes an edit or retire card, and a // HELPFUL on a team rule (the rule already says it) makes no card. import { Log } from "@/util/log" +import { booleanFlag, learnEnabled } from "../learn/config" import { lint } from "../learn/curator" import { readShownSnapshots } from "../learn/delivery" import { buildDigest, type DigestSource } from "../learn/digest" @@ -35,17 +36,26 @@ export interface TriggerDeps { type LearnConfig = { enabled?: boolean; capture?: boolean } | undefined +export type LearningSource = "you" | "workspace" | "default" + /** - * Whether sessions here learn (capture corrections and propose rules). The person's own setting wins - * (`learn enable` / `learn disable`, or `ALTIMATE_LEARN_CAPTURE`); otherwise a linked project follows the - * workspace's "Learn from sessions"; otherwise off. + * Whether sessions here learn from corrections (capture them and propose rules), and who decided. In order: the + * learning kill switch (`ALTIMATE_LEARN`, `learn.enabled`); `ALTIMATE_LEARN_CAPTURE`; the person's `/rules` choice + * (`personal_learning`, git-ignored); `learn.capture` (`learn enable` / `learn disable`); a linked workspace's + * "Learn from sessions"; otherwise off. Env flags parse like `learn/config.ts`, so the two agree. */ +export function learningDecision(learn: LearnConfig, state: RulesStore.State, env: NodeJS.ProcessEnv = process.env): { on: boolean; source: LearningSource } { + if (!learnEnabled(learn, env)) return { on: false, source: "you" } + const forced = booleanFlag(env.ALTIMATE_LEARN_CAPTURE) + if (forced !== undefined) return { on: forced, source: "you" } + if (state.personal_learning !== undefined) return { on: state.personal_learning, source: "you" } + if (learn?.capture !== undefined) return { on: learn.capture, source: "you" } + if (state.workspace) return { on: state.settings?.learning_enabled === true, source: "workspace" } + return { on: false, source: "default" } +} + export function learningEnabled(learn: LearnConfig, state: RulesStore.State, env: NodeJS.ProcessEnv = process.env): boolean { - if (learn?.enabled === false || /^(0|false)$/i.test(env.ALTIMATE_LEARN ?? "")) return false - const forced = env.ALTIMATE_LEARN_CAPTURE - if (forced !== undefined && forced !== "") return /^(1|true)$/i.test(forced) - if (learn?.capture !== undefined) return learn.capture - return !!state.scope && state.settings?.learning_enabled === true + return learningDecision(learn, state, env).on } const STOP = new Set("the and for with that this from into when then than are was were have has had not use using should must always never only all any each your you our its their them they these those also but can will would could does like just".split(" ")) @@ -214,11 +224,15 @@ export async function afterTurn(input: { } finally { running.delete(input.sessionID) if (outcome !== "no new corrections" || checked.length) { - // Keep only ids of signals still on disk, so the list cannot grow past them or forget a live one. - const live = checked.length ? new Set((await readSignals(input.root, Playbook.DEFAULT_NAME).catch(() => [] as Signal[])).map((signal) => signal.id)) : undefined + // Keep only ids of signals still on disk, so the list cannot grow past them or forget a live one. When the + // signals cannot be read, nothing is pruned: an empty read must not wipe what was processed. + const signals = checked.length ? await readSignals(input.root, Playbook.DEFAULT_NAME).catch(() => undefined) : undefined + const live = signals ? new Set(signals.map((signal) => signal.id)) : undefined await RulesStore.update(input.root, (s) => { s.last_check = { at: new Date().toISOString(), session: input.sessionID, outcome } - if (live) s.processed_signals = [...s.processed_signals.filter((id) => live.has(id)), ...checked] + if (!checked.length) return + const kept = live ? s.processed_signals.filter((id) => live.has(id)) : s.processed_signals + s.processed_signals = [...kept, ...checked.filter((id) => !kept.includes(id))] }).catch(() => {}) } } diff --git a/packages/opencode/src/altimate/tools/training-save.ts b/packages/opencode/src/altimate/tools/training-save.ts index 7a29150449..12f9858050 100644 --- a/packages/opencode/src/altimate/tools/training-save.ts +++ b/packages/opencode/src/altimate/tools/training-save.ts @@ -22,16 +22,19 @@ function lastUserText(messages: unknown): string | undefined { } async function proposeTeamRule(content: string, sessionID: string, said?: string) { - const [{ Instance }, { Config }, { RulesStore }, { RuleTrigger }] = await Promise.all([ + const [{ Instance }, { Config }, { RulesStore }, { RuleTrigger }, { RulesSync }] = await Promise.all([ import("../../project/instance"), import("@/config/config"), import("../rules/store"), import("../rules/trigger"), + import("../rules/sync"), ]) const root = Instance.worktree !== "/" ? Instance.worktree : Instance.directory + const learn = (await Config.get()).learn + // With learning or workspaces off, nothing would ever send the card: save it locally as before. + if (!RulesSync.rulesEnabled(learn)) return undefined const state = await RulesStore.read(root) if (!state.scope || !state.workspace) return undefined - const learn = (await Config.get()).learn const result = await RuleTrigger.explicit({ sessionID, root, directory: Instance.directory, learn, text: content, said }) const metadata = { action: "proposed" as string, kind: "rule", name: "", scope: "project" } if (result.status === "memory") @@ -53,8 +56,8 @@ async function proposeTeamRule(content: string, sessionID: string, said?: string output: [ result.choose ? `The person sees a card asking whether to save this as a memory (just for them, applies now) or as a team rule for ${state.workspace.name} (goes to ${owner} for review), or to skip it.` - : `Proposed as a team rule for ${state.workspace.name}. The person sees a card to send it to ${owner} for review` + - (state.workspace.is_owner ? " or approve it now" : "") + ", edit the wording, or decline.", + : `Proposed as a team rule for ${state.workspace.name}. The person sees a card to ` + + (state.workspace.is_owner ? "approve it now" : `send it to ${owner} for review`) + ", edit the wording, or decline.", "Follow the rule for the rest of this session. Do not save it again, and do not say it is already a team rule: it applies to teammates only after approval.", ].join("\n"), } diff --git a/packages/opencode/src/cli/cmd/learn.ts b/packages/opencode/src/cli/cmd/learn.ts index 66a47de6e2..7f12a07db3 100644 --- a/packages/opencode/src/cli/cmd/learn.ts +++ b/packages/opencode/src/cli/cmd/learn.ts @@ -87,35 +87,12 @@ const LEARN_DISABLED_HINT = // altimate_change start — `autoPromote` writes learn.auto_promote; `false` only clears a key the file already sets async function writeProjectLearning(root: string, enabled: boolean, autoPromote?: boolean): Promise { // altimate_change end - // altimate_change start — match discovered project config precedence from the current directory - const [{ Instance }, { Filesystem }] = await Promise.all([ - import("@/project/instance"), import("@/util/filesystem"), + // altimate_change start — one discovery for every project-config writer (`learn/project-config.ts`) + const [{ Instance }, { projectConfigFile }] = await Promise.all([ + import("@/project/instance"), import("../../altimate/learn/project-config"), ]) - // Config merges in the opposite order. Edit the highest-precedence existing project file - // so a second supported config cannot silently override the new opt-in setting. - const names = ["opencode.jsonc", "opencode.json", "altimate-code.jsonc", "altimate-code.json"] - const directories: string[] = [] - for await (const dir of Filesystem.up({ targets: [".altimate-code", ".opencode"], start: Instance.directory, stop: root })) - directories.push(dir) - const candidates = directories.reverse().flatMap((dir) => names.map((name) => path.join(dir, name))) - for await (const file of Filesystem.up({ targets: ["opencode.jsonc", "opencode.json"], start: Instance.directory, stop: root })) - candidates.push(file) + let { file, text } = await projectConfigFile(root, Instance.directory) // altimate_change end - let file = path.join(root, ".altimate-code", "altimate-code.json") - let text = "{}\n" - for (const candidate of candidates) { - // altimate_change start — reject linked project config files and their ancestors before reading - await SafeFS.assertSafePath(root, candidate, root) - // altimate_change end - const current = await fs.readFile(candidate, "utf8").catch((error: NodeJS.ErrnoException) => { - if (error.code === "ENOENT") return undefined - throw error - }) - if (current === undefined) continue - file = candidate - text = current - break - } const errors: ParseError[] = [] const config = parse(text, errors, { allowTrailingComma: true }) if (errors.length || !config || typeof config !== "object" || Array.isArray(config)) @@ -453,7 +430,12 @@ const SyncCommand = effectCmd({ const { RulesSync } = await import("../../altimate/rules/sync") const { Instance } = await import("@/project/instance") const report = await RulesSync.runFresh(root, Instance.directory, (await Config.get()).learn) - if (args.json) return out(JSON.stringify(report, (key, value) => (key === "actAs" ? undefined : value), 2)) + if (args.json) { + out(JSON.stringify(RulesSync.publicReport(report), null, 2)) + // Same exit status as the text mode: a failed pull is a failed sync. + if (report.context.status === "ready" && !report.pull?.ok) process.exitCode = 1 + return + } if (report.context.status !== "ready") return out(`Rules sync skipped: ${report.context.reason}.`) for (const sent of report.push?.sent ?? []) out(`Sent "${sent.text}": ${sent.error ?? sent.status}`) if (report.push?.error) out(`Proposals not sent (kept queued): ${report.push.error}`) @@ -467,6 +449,39 @@ const SyncCommand = effectCmd({ }) // altimate_change end +// altimate_change start — workspace rules: rules staged from history go to the owner's review only with consent +/** + * In a linked project, list what this run staged and ask before sending it to the named workspace's review queue + * (default no). Without a terminal it needs `--send-for-review`. Declined or skipped rules stay local candidates. + */ +async function offerStagedForReview(input: { name: string; origin: "bootstrap" | "import_reviews"; before: Set | undefined; send: boolean }) { + const [{ Config }, { Instance }, { RulesFromHistory }] = await Promise.all([ + import("@/config/config"), + import("@/project/instance"), + import("../../altimate/rules/from-history"), + ]) + const root = await projectRoot() + const staged = await RulesFromHistory.stagedRules({ root, name: input.name, origin: input.origin, before: input.before }) + if (!staged) return + for (const invalid of staged.invalid) out(`Kept local (not a valid rule: ${invalid.reason}): ${invalid.text}`) + if (!staged.items.length) return + const { owner, name } = staged.workspace + out(`Staged ${staged.items.length} ${staged.items.length === 1 ? "rule" : "rules"} that can go to ${owner}'s review in ${name}:`) + for (const item of staged.items) out(` - ${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 + if (!send) { + out("Not sent; they stay local candidates (`learn show`, `learn promote`). Rerun with --send-for-review to send them.") + return + } + const result = await RulesFromHistory.sendStaged({ + root, directory: Instance.directory, learn: (await Config.get()).learn, name: input.name, staged, before: input.before, + }) + out(result.message) +} +// altimate_change end + const BootstrapCommand = effectCmd({ command: "bootstrap", describe: "seed candidate rules from this project's past sessions", @@ -476,12 +491,15 @@ const BootstrapCommand = effectCmd({ .option("model", { type: "string", alias: ["m"], describe: "chosen provider/model (default: learn.model, then the configured default model)" }) .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)" }) + // 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 go to the workspace owner's review instead. 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 owner'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 = yield* run("", async () => + const staged = args["dry-run"] ? undefined : yield* run("", async () => (await import("../../altimate/rules/from-history")).RulesFromHistory.candidateTexts(await projectRoot(), args.name as string)) // altimate_change end const result = yield* run("", async () => { @@ -519,18 +537,12 @@ const BootstrapCommand = effectCmd({ }, }) }) - if (result?.failures) return yield* fail("Bootstrap reflection failed; signals remain queued. Rerun `learn bootstrap` to continue.") - // altimate_change start — linked project: the staged lessons go to the workspace owner's review as rules - if (!args["dry-run"]) yield* run("", async () => { - const [{ Config }, { Instance }] = await Promise.all([import("@/config/config"), import("@/project/instance")]) - const { RulesFromHistory } = await import("../../altimate/rules/from-history") - const sent = await RulesFromHistory.proposeStaged({ - root: await projectRoot(), directory: Instance.directory, learn: (await Config.get()).learn, name: args.name as string, origin: "bootstrap", - before: staged, - }) - if (sent.status === "sent") out(sent.message) - }) + // altimate_change start — linked project: offer what was staged (also after a partial failure, or a rerun + // would treat it as an earlier candidate and never offer it) + if (!args["dry-run"]) yield* run("", () => + offerStagedForReview({ name: args.name as string, origin: "bootstrap", before: staged, send: args["send-for-review"] === true })) // altimate_change end + if (result?.failures) return yield* fail("Bootstrap reflection failed; signals remain queued. Rerun `learn bootstrap` to continue.") }), }) @@ -549,11 +561,14 @@ const ImportReviewsCommand = effectCmd({ .option("model", { type: "string", alias: ["m"], describe: "chosen provider/model (default: learn.model, then the configured default model)" }) .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)" }) + // 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 go to the workspace owner's review instead. 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 owner'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 = yield* run("", async () => + const staged = args["dry-run"] ? undefined : yield* run("", async () => (await import("../../altimate/rules/from-history")).RulesFromHistory.candidateTexts(await projectRoot(), args.name as string)) // altimate_change end const result = yield* run("", async () => { @@ -593,18 +608,11 @@ const ImportReviewsCommand = effectCmd({ }, }) }) - if (result?.failures) return yield* fail("Review reflection failed; signals remain queued. Rerun `learn import-reviews` to continue.") - // altimate_change start — linked project: the staged lessons go to the workspace owner's review as rules - if (!args["dry-run"]) yield* run("", async () => { - const [{ Config }, { Instance }] = await Promise.all([import("@/config/config"), import("@/project/instance")]) - const { RulesFromHistory } = await import("../../altimate/rules/from-history") - const sent = await RulesFromHistory.proposeStaged({ - root: await projectRoot(), directory: Instance.directory, learn: (await Config.get()).learn, name: args.name as string, origin: "import_reviews", - before: staged, - }) - if (sent.status === "sent") out(sent.message) - }) + // altimate_change start — linked project: offer what was staged (also after a partial failure) + if (!args["dry-run"]) yield* run("", () => + offerStagedForReview({ name: args.name as string, origin: "import_reviews", before: staged, send: args["send-for-review"] === true })) // altimate_change end + if (result?.failures) return yield* fail("Review reflection failed; signals remain queued. Rerun `learn import-reviews` to continue.") }), }) diff --git a/packages/opencode/src/plugin/tui/altimate/rules.tsx b/packages/opencode/src/plugin/tui/altimate/rules.tsx index 0ad45db3b2..56d93988dd 100644 --- a/packages/opencode/src/plugin/tui/altimate/rules.tsx +++ b/packages/opencode/src/plugin/tui/altimate/rules.tsx @@ -18,9 +18,8 @@ import type { TuiDialogSelectOption, TuiPlugin, TuiPluginApi, TuiPromptRef } fro import type { BuiltinTuiPlugin } from "@opencode-ai/tui/builtins" import { createEffect, createRoot, createSignal, Show } from "solid-js" import type { ProposalCard } from "@/altimate/rules/events" -import { RulesSummary } from "@/altimate/rules/summary" +import type { RulesSummary } from "@/altimate/rules/summary" import { describeAge } from "@/altimate/workspace/attach-snapshot" -import { readProjectLearning, writeProjectLearning } from "@/altimate/learn/project-config" const id = "altimate:rules" @@ -43,13 +42,19 @@ async function post(api: TuiPluginApi, url: string, body?: unknown): Promise< const on = (api: TuiPluginApi) => api.event.on as unknown as (type: string, handler: (event: { properties: any }) => void) => () => void const root = (api: TuiPluginApi) => (api.state.path.worktree && api.state.path.worktree !== "/" ? api.state.path.worktree : api.state.path.directory) -type LearnConfig = { enabled?: boolean; capture?: boolean } | undefined -/** The loaded config's `learn`, with the project file's `learn` as on disk now: the `/rules` toggle writes that - * file, and the loaded config does not change until the next session. */ -const learnConfig = async (api: TuiPluginApi): Promise => { - const loaded = (api.state.config as { learn?: LearnConfig }).learn - const onDisk = (await readProjectLearning(root(api), api.state.path.directory).catch(() => undefined)) as LearnConfig - return loaded || onDisk ? { ...loaded, ...onDisk } : undefined +type Summary = RulesSummary.Summary & { ok: boolean; line: string } +type Row = RulesSummary.RuleRow + +async function get(api: TuiPluginApi, url: string): Promise { + const result = await raw(api)?.get({ url }).catch(() => undefined) + return result?.data as T | undefined +} + +/** Rules state from the session server: the TUI may be attached to a server on another host or path, so it never + * reads the rules files itself. */ +async function summaryOf(api: TuiPluginApi): Promise { + const summary = await get(api, "/altimate/rules/summary") + return summary?.ok ? summary : undefined } function title(card: ProposalCard) { @@ -67,7 +72,8 @@ function meta(card: ProposalCard) { return parts.join(" · ") } -/** Button labels, in the order shown: the team action first, so Enter on a fresh card takes it. */ +/** Button labels, in the order shown. Enter on a fresh card takes the first: the team action, or "Memory" on a + * "Save as" card. */ function buttons(card: ProposalCard): { action: ProposalCard["actions"][number]; label: string }[] { const label: Record = { send: card.choose ? "Team rule — send for review" : "Send for review", @@ -102,6 +108,8 @@ function InlineCard(props: { more: number focused: boolean selected: number + /** "Approve anyway" was pressed once: say what a second Enter does. */ + confirming?: boolean onPick: (index: number) => void onFocus: () => void }) { @@ -162,7 +170,11 @@ function InlineCard(props: { - {explain(card(), options()[props.selected]?.action ?? "")} + + {props.confirming + ? "It weakens verification for every teammate. Press Enter again to approve anyway." + : explain(card(), options()[props.selected]?.action ?? "")} + {"⇆"} select enter confirm {" "}esc back to typing @@ -184,6 +196,8 @@ function installCards(api: TuiPluginApi) { const [focused, setFocused] = createSignal(false) const [selected, setSelected] = createSignal(0) const [busy, setBusy] = createSignal(false) + /** The flagged card whose "Approve anyway" was pressed once and waits for a second Enter. */ + const [confirming, setConfirming] = createSignal(undefined) let prompt: TuiPromptRef | undefined let sessionID: string | undefined let layer: (() => void) | undefined @@ -199,6 +213,7 @@ function installCards(api: TuiPluginApi) { } const unfocus = () => { + setConfirming(undefined) releaseKeys() setFocused(false) prompt?.focus() @@ -248,6 +263,12 @@ function installCards(api: TuiPluginApi) { if (!card || !option) return setSelected(index) if (option.action === "edit") return edit(card) + // Approving a rule that weakens verification for the whole workspace takes a second Enter. + if (option.action === "approve" && card.weakens_verification && confirming() !== card.id) { + setConfirming(card.id) + return + } + setConfirming(undefined) void reply(card, option.action) } @@ -260,6 +281,7 @@ function installCards(api: TuiPluginApi) { const move = (step: number) => { const card = current() if (!card) return + setConfirming(undefined) const count = buttons(card).length setSelected((index) => (index + step + count) % count) } @@ -323,6 +345,7 @@ function installCards(api: TuiPluginApi) { more={cards().filter((c) => c.sessionID === props.session_id).length - 1} focused={focused()} selected={selected()} + confirming={confirming() === card().id} onPick={(index) => (focused() ? pick(index) : focus())} onFocus={focus} /> @@ -369,16 +392,22 @@ function installCards(api: TuiPluginApi) { on(api)("rule.proposal.replied", (event) => closed((event.properties as { id: string }).id)) // Each review is shown once per project, whether it arrived as an event or was pulled before this TUI // subscribed (the startup sync): remembered by id and status. + // The first time a project is seen, its existing reviews are recorded silently: they are history, not news. const seenKey = () => `altimate.rules.notices.${root(api)}` - const notify = (p: { public_id: string; text: string; status: string; reason?: string; reviewer?: string }) => { + const notify = (p: { public_id: string; text: string; status: string; reason?: string; reviewer?: string }, quiet = false) => { const seen = new Set(api.kv.get(seenKey(), [])) const key = `${p.public_id}:${p.status}` if (seen.has(key)) return api.kv.set(seenKey(), [...seen, key].slice(-200)) - reviewToast(api, p) + if (!quiet) reviewToast(api, p) } on(api)("rule.proposal.reviewed", (event) => notify(event.properties)) - const replay = () => void learnConfig(api).then((learn) => RulesSummary.summary(root(api), learn)).then((s) => [...s.notices].reverse().forEach(notify)).catch(() => {}) + const replay = () => + void summaryOf(api).then((summary) => { + const first = api.kv.get(seenKey(), undefined) === undefined + if (first) api.kv.set(seenKey(), []) + for (const notice of [...(summary?.notices ?? [])].reverse()) notify(notice, first) + }) on(api)("altimate.rules.changed", replay) setTimeout(() => { replay() @@ -395,12 +424,13 @@ function reviewToast(api: TuiPluginApi, p: { text: string; status: string; reaso } async function showMenu(api: TuiPluginApi) { - const summary = await RulesSummary.summary(root(api), await learnConfig(api)) - const rows = await RulesSummary.rows(root(api)) + const summary = await summaryOf(api) + if (!summary) return api.ui.toast({ variant: "error", message: "Rules are not available from the session server.", duration: 6000 }) + const rows = (await get<{ ok: boolean; rules: Row[] }>(api, "/altimate/rules"))?.rules ?? [] const team = rows.filter((r) => r.source === "team") const mine = rows.filter((r) => r.source === "mine") const status = summary.linked - ? `${summary.workspace?.name ?? "Workspace"} · ${RulesSummary.sidebarLine(summary)}${summary.pulled_at ? ` · synced ${describeAge(Date.parse(summary.pulled_at))}` : ""}` + ? `${summary.workspace?.name ?? "Workspace"} · ${summary.line}${summary.pulled_at ? ` · synced ${describeAge(Date.parse(summary.pulled_at))}` : ""}` : "Not linked to a workspace — run `altimate-code link`" const learning = `Learning from sessions: ${summary.learning ? "on" : "off"} (${summary.learning_source === "you" ? "your setting" : summary.learning_source === "workspace" ? "workspace setting" : "default"})` const label: Record = { active: "approved", pending: "pending review", rejected: "rejected", queued: "queued", retired: "retired", superseded: "replaced" } @@ -421,9 +451,12 @@ async function showMenu(api: TuiPluginApi) { { title: summary.learning ? "Turn learning off for me" : "Turn learning on for me", value: "toggle", - description: `${learning}. Your setting overrides the workspace's "Learn from sessions".`, + description: `${learning}. Just for you, in this project; it overrides the workspace's "Learn from sessions".`, category: "Actions", }, + ...(summary.personal_learning !== undefined + ? [{ title: "Follow the workspace setting again", value: "follow", description: "Forget your own choice for this project", category: "Actions" }] + : []), ] api.ui.dialog.replace(() => ( { // Sync and the learning toggle are actions, not navigation: close the menu, report with a toast. - if (option.value === "sync" || option.value === "toggle") api.ui.dialog.clear() + if (option.value === "sync" || option.value === "toggle" || option.value === "follow") api.ui.dialog.clear() if (option.value === "sync") { api.ui.toast({ variant: "info", message: "Syncing rules…", duration: 3000 }) const report = await post<{ ok: boolean; pull?: { ok: boolean; rules: number; pending: number; error?: string; notices: unknown[] }; push?: { sent: unknown[] }; context?: { status: string; reason?: string } }>(api, "/altimate/rules/sync").catch(() => undefined) @@ -444,12 +477,12 @@ async function showMenu(api: TuiPluginApi) { else api.ui.toast({ variant: "error", message: `Rules sync failed: ${report?.pull?.error ?? "unknown error"}`, duration: 8000 }) return } - if (option.value === "toggle") { - const file = await writeProjectLearning(root(api), api.state.path.directory, !summary.learning).catch((error) => { - api.ui.toast({ variant: "error", message: `Learning not changed: ${error instanceof Error ? error.message : String(error)}`, duration: 8000 }) - return undefined - }) - if (file) api.ui.toast({ variant: "success", message: `Learning ${summary.learning ? "off" : "on"} for you in this project (${file}). Applies from the next session.`, duration: 8000 }) + if (option.value === "toggle" || option.value === "follow") { + const on = option.value === "follow" ? null : !summary.learning + const next = await post(api, "/altimate/rules/learning", { on }).catch(() => undefined) + if (!next?.ok) return api.ui.toast({ variant: "error", message: `Learning not changed: ${next?.error ?? "the session server did not answer"}`, duration: 8000 }) + const source = next.learning_source === "you" ? "your setting" : next.learning_source === "workspace" ? "workspace setting" : "default" + api.ui.toast({ variant: "success", message: `Learning from sessions is ${next.learning ? "on" : "off"} for you in this project (${source}).`, duration: 6000 }) return } }} diff --git a/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx b/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx index b76a0e7fb1..21fa68dee0 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx @@ -21,9 +21,6 @@ import { accountScope, boundAttachSnapshot, sidebarAttachLine } from "@/altimate // altimate_change end import { AltimateApi } from "@/altimate/api/client" import { openManageUrl } from "./workspace" -// altimate_change start — workspace rules line -import { RulesSummary } from "@/altimate/rules/summary" -// altimate_change end const id = "altimate:sidebar-workspace" @@ -71,15 +68,21 @@ function View(props: { api: TuiPluginApi }) { const [manageUrl, setManageUrl] = createSignal(null) // altimate_change start - status lines const [detail, setDetail] = createSignal(null) - // altimate_change start — workspace rules: "6 team rules · 3 pending review", from local state - const [rules, setRules] = createSignal(null) - const rulesRoot = () => (props.api.state.path.worktree && props.api.state.path.worktree !== "/" ? props.api.state.path.worktree : props.api.state.path.directory) + // altimate_change end + // altimate_change start — workspace rules: "6 team rules · 3 pending review", from the session server (which may + // run on another host than this TUI, so never from local files) + const [rules, setRules] = createSignal<{ line: string; pulled_at?: string } | null>(null) const readRules = () => { - const learn = (props.api.state.config as { learn?: { enabled?: boolean; capture?: boolean } }).learn - void RulesSummary.summary(rulesRoot(), learn).then((s) => setRules(s.linked ? s : null)).catch(() => setRules(null)) + const client = (props.api.client as unknown as { client?: { get(input: { url: string }): Promise<{ data?: unknown }> } }).client + void client + ?.get({ url: "/altimate/rules/summary" }) + .then((result) => { + const summary = result.data as { ok?: boolean; linked?: boolean; line?: string; pulled_at?: string } | undefined + setRules(summary?.ok && summary.linked && summary.line ? { line: summary.line, ...(summary.pulled_at ? { pulled_at: summary.pulled_at } : {}) } : null) + }) + .catch(() => setRules(null)) } // altimate_change end - // altimate_change end // altimate_change start - what the last session got, in numbers const [attachLine, setAttachLine] = createSignal(null) const readAttachLine = (bound: CachedBinding | null) => { @@ -143,6 +146,9 @@ function View(props: { api: TuiPluginApi }) { // one clears. const clearRendered = () => { setDetail(null) + // altimate_change start — workspace rules: the line belongs to the binding being cleared + setRules(null) + // altimate_change end setManageUrl(null) setBinding(undefined) boundScope = null @@ -330,7 +336,7 @@ function View(props: { api: TuiPluginApi }) { {(r) => ( - {`rules: ${RulesSummary.sidebarLine(r())}`} + {`rules: ${r().line}`} {r().pulled_at ? ` · synced ${describeAge(Date.parse(r().pulled_at!))}` : ""} )} diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 274d227ab3..82797a854f 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1265,7 +1265,7 @@ export namespace Server { const refused = workspaceRouteRefusal(c.req.header("origin"), c.req.header("host"), undefined, c.req.header("sec-fetch-site")) if (refused) return c.json(refused.body, refused.status) const { RuleProposal } = await import("../altimate/rules/proposal") - return c.json(RuleProposal.list(Instance.directory)) + return c.json(RuleProposal.list(Instance.directory, c.req.query("session") || undefined)) }) .post("/rule-proposal/:id/reply", async (c) => { const refused = workspaceRouteRefusal(c.req.header("origin"), c.req.header("host"), undefined, c.req.header("sec-fetch-site")) @@ -1284,7 +1284,8 @@ export namespace Server { { action: action as (typeof actions)[number], ...(text !== undefined ? { text } : {}) }, Instance.directory, ) - return c.json(result, result.ok ? 200 : 409) + const status = result.ok ? 200 : { not_open: 404, busy: 409, not_available: 409, invalid_text: 422, failed: 500 }[result.code] + return c.json(result, status as 200 | 404 | 409 | 422 | 500) } catch (err) { const error = err instanceof Error ? err.message : String(err) log.error("rule proposal reply failed", { error }) @@ -1294,9 +1295,14 @@ export namespace Server { .get("/altimate/rules/summary", async (c) => { const refused = workspaceRouteRefusal(c.req.header("origin"), c.req.header("host"), undefined, c.req.header("sec-fetch-site")) if (refused) return c.json(refused.body, refused.status) - const { RulesSummary } = await import("../altimate/rules/summary") + const [{ RulesSummary }, { RulesService }, { Config }] = await Promise.all([ + import("../altimate/rules/summary"), + import("../altimate/rules/service"), + import("@/config/config"), + ]) const root = Instance.worktree !== "/" ? Instance.worktree : Instance.directory - const summary = await RulesSummary.summary(root, (await (await import("@/config/config")).Config.get()).learn) + const learn = await RulesService.effectiveLearn((await Config.get()).learn, root, Instance.directory) + const summary = await RulesSummary.summary(root, learn) return c.json({ ok: true as const, ...summary, line: RulesSummary.sidebarLine(summary) }) }) .get("/altimate/rules", async (c) => { @@ -1308,14 +1314,49 @@ export namespace Server { const root = Instance.worktree !== "/" ? Instance.worktree : Instance.directory return c.json({ ok: true as const, rules: await RulesSummary.rows(root, view ?? "all") }) }) + // The person's own "learning from sessions" choice for this project: `on` true/false, or null to follow the + // config and the workspace again. Stored per person (git-ignored rules state), never in the project config. + .post("/altimate/rules/learning", async (c) => { + const refused = workspaceRouteRefusal(c.req.header("origin"), c.req.header("host"), undefined, c.req.header("sec-fetch-site")) + if (refused) return c.json(refused.body, refused.status) + const read = await readJsonObject(() => c.req.text(), "rules learning") + if ("failure" in read) return c.json(read.failure.body, read.failure.status) + const { on } = read.body as { on?: unknown } + if (on !== null && typeof on !== "boolean") return c.json({ ok: false, error: "on must be true, false or null." }, 400) + try { + const [{ RulesStore }, { RulesSummary }, { RulesService }, { RuleEvents }, { Config }, { Bus }] = await Promise.all([ + import("../altimate/rules/store"), + import("../altimate/rules/summary"), + import("../altimate/rules/service"), + import("../altimate/rules/events"), + import("@/config/config"), + import("@/bus"), + ]) + const root = Instance.worktree !== "/" ? Instance.worktree : Instance.directory + await RulesStore.update(root, (state) => { + if (on === null) delete state.personal_learning + else state.personal_learning = on + }) + await Bus.publish(RuleEvents.Event.Changed, { directory: Instance.directory }).catch(() => {}) + const summary = await RulesSummary.summary(root, await RulesService.effectiveLearn((await Config.get()).learn, root, Instance.directory)) + return c.json({ ok: true as const, ...summary, line: RulesSummary.sidebarLine(summary) }) + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + log.error("rules learning update failed", { error }) + return c.json({ ok: false, error }, 500) + } + }) .post("/altimate/rules/sync", async (c) => { const refused = workspaceRouteRefusal(c.req.header("origin"), c.req.header("host"), undefined, c.req.header("sec-fetch-site")) if (refused) return c.json(refused.body, refused.status) try { const { RulesService } = await import("../altimate/rules/service") const root = Instance.worktree !== "/" ? Instance.worktree : Instance.directory - const report = await RulesService.syncNow(root, Instance.directory, (await (await import("@/config/config")).Config.get()).learn) - return c.json({ ok: true as const, ...report }) + const { Config } = await import("@/config/config") + const learn = await RulesService.effectiveLearn((await Config.get()).learn, root, Instance.directory) + const { RulesSync } = await import("../altimate/rules/sync") + // Never the captured credential: the report goes to clients that may log or cache it. + return c.json({ ok: true as const, ...RulesSync.publicReport(await RulesService.syncNow(root, Instance.directory, learn)) }) } catch (err) { const error = err instanceof Error ? err.message : String(err) log.error("rules sync failed", { error }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e271265cfb..8cd338d540 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -131,6 +131,10 @@ import * as RunBudgetEffort from "@/altimate/session/run-budget-effort" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false +// altimate_change start — workspace rules: sessions that already had their session-start pull in this process +const rulesSessionsStarted = new Set() +// altimate_change end + const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format. IMPORTANT: @@ -768,12 +772,14 @@ export namespace SessionPrompt { // An unused project pays only an existence check; do not load stores, migrate, // create state, or even schedule async learning work on that default-off path. const learnRoot = Instance.worktree !== "/" ? Instance.worktree : Instance.directory - // Workspace rules: a pull at session start, bounded, so the first turn delivers newly approved rules. - // Only a project with rules state awaits anything; every other prompt stays on the synchronous path. - if (existsSync(path.join(learnRoot, ".altimate-code", "learn", "team", "rules.json"))) - await import("../altimate/rules/sync") - .then((m) => m.sessionStart(learnRoot, Instance.directory, altCfg.learn)) + // Workspace rules: a bounded pull before a session's first turn, so it delivers newly approved rules. Only the + // first prompt of a session awaits anything; every later prompt stays on the synchronous path. + if (!rulesSessionsStarted.has(sessionID)) { + rulesSessionsStarted.add(sessionID) + await import("../altimate/rules/service") + .then((m) => m.RulesService.sessionStart(learnRoot, Instance.directory, altCfg.learn)) .catch((error) => log.warn("rules session sync failed", { error })) + } const lessonState = existsSync(path.join(learnRoot, ".altimate-code", "learn")) ? new LessonDelivery(learnRoot, altCfg.learn, Instance.directory) : undefined diff --git a/packages/opencode/test/altimate/rules/rules.test.ts b/packages/opencode/test/altimate/rules/rules.test.ts index e081c4d87d..06662f2181 100644 --- a/packages/opencode/test/altimate/rules/rules.test.ts +++ b/packages/opencode/test/altimate/rules/rules.test.ts @@ -8,7 +8,8 @@ import { RuleProposal } from "../../../src/altimate/rules/proposal" import { RulesStore } from "../../../src/altimate/rules/store" import { RulesSummary } from "../../../src/altimate/rules/summary" import { RulesSync } from "../../../src/altimate/rules/sync" -import { appliesTo, learningEnabled } from "../../../src/altimate/rules/trigger" +import { appliesTo, learningDecision, learningEnabled } from "../../../src/altimate/rules/trigger" +import { RulesFromHistory } from "../../../src/altimate/rules/from-history" import { parse } from "../../../src/altimate/learn/lesson" import * as Store from "../../../src/altimate/learn/store" @@ -292,3 +293,221 @@ describe("sending the outbox", () => { expect((await RulesStore.read(root)).outbox).toEqual([]) }) }) + +describe("the workspace API, by path", () => { + // A stand-in for `/datamates/:id/rules/{sync,proposals,usage}`: each test sets the answer per path. + let server: ReturnType + const seen: { path: string; body: any }[] = [] + let answer: (path: string, body: any) => Response = () => new Response("{}") + beforeAll(() => { + server = Bun.serve({ + port: 0, + async fetch(request) { + const path = new URL(request.url).pathname.replace(/^\/datamates\/\d+\/rules/, "") + const body = await request.json().catch(() => undefined) + seen.push({ path, body }) + return answer(path, body) + }, + }) + }) + afterAll(() => server.stop(true)) + + const scope = () => ({ apiUrl: `http://127.0.0.1:${server.port}`, tenant: "t", account: "a", datamateId: 1, repoRemote: "https://github.com/acme/demo" }) + const ready = () => ({ status: "ready" as const, actAs: { url: `http://127.0.0.1:${server.port}`, instance: "t", apiKey: "secret-key" }, scope: scope() }) + const item = (text: string) => ({ change: "add" as const, text, applies_to: [], origin: "session" as const, evidence_sessions: 1, corrections: [] }) + const rule = (key: string, text: string) => ({ public_id: `p-${key}`, rule_key: `R-${key.padEnd(12, "0")}`, text, applies_to: [], tags: [], pinned: false, version: 1, updated_at: "2026-10-27T00:00:00Z" }) + const syncBody = (rules: unknown[], extra: Record = {}) => ({ + unchanged: false, revision: 3, repo_identity: "github.com/acme/demo", workspace_name: "Team", owner_name: "Olive", is_owner: false, + settings: { learning_enabled: true, include_quotes: true, share_across_repos: false }, pending_count: 0, rules, my_reviewed: [], cursor: "c1", ...extra, + }) + + test("an expired key (401) or an unreadable answer keeps proposals queued; only a coded 422 refuses them", async () => { + const root = await tmp() + await RulesSync.enqueue(root, item("kept on 401")) + answer = () => new Response("unauthorized", { status: 401 }) + expect((await RulesSync.push(root, ready())).queued).toBe(1) + answer = () => new Response("not json", { status: 200 }) + expect((await RulesSync.push(root, ready())).queued).toBe(1) + answer = () => Response.json({ detail: "too large" }, { status: 400 }) + expect((await RulesSync.push(root, ready())).queued).toBe(1) + answer = () => Response.json({ detail: { code: "invalid", message: "bad" } }, { status: 422 }) + const refused = await RulesSync.push(root, ready()) + expect(refused.queued).toBe(0) + expect(refused.sent[0]).toMatchObject({ status: "rejected", error: "bad" }) + }) + + test("one invalid rule in a pull is skipped, not a failed pull", async () => { + const root = await tmp() + answer = () => Response.json(syncBody([rule("aaaaaaaaaaaa", "Name staging models stg_."), { ...rule("bbbbbbbbbbbb", "broken"), rule_key: "nope" }])) + const pulled = await RulesSync.pull(root, ready()) + expect(pulled).toMatchObject({ ok: true, rules: 1 }) + }) + + test("losing the link or access stops the rules but keeps confirmed proposals for the same scope", async () => { + for (const refusal of [ + Response.json({ detail: { code: "repo_not_bound", message: "not bound" } }, { status: 422 }), + Response.json({ detail: { code: "forbidden", message: "no access" } }, { status: 403 }), + ]) { + const root = await tmp() + answer = () => Response.json(syncBody([rule("aaaaaaaaaaaa", "Name staging models stg_.")])) + await RulesSync.pull(root, ready()) + await RulesSync.enqueue(root, item("still mine")) + answer = () => refusal.clone() + await RulesSync.pull(root, ready()) + const state = await RulesStore.read(root) + expect(state.rules).toEqual([]) + expect(state.workspace).toBeUndefined() + expect(state.outbox.map((e) => e.item.text)).toEqual(["still mine"]) + } + }) + + test("a review notice is recorded once, and the next pull sends the cursor", async () => { + const root = await tmp() + const reviewed = { public_id: "p-1", rule_key: "R-1", text: "Use cents.", change: "add", status: "rejected", review_reason: "Too vague", reviewed_by_name: "Olive", reviewed_at: "2026-10-28T00:00:00Z", updated_at: "2026-10-28T00:00:00Z" } + answer = () => Response.json(syncBody([], { my_reviewed: [reviewed] })) + expect((await RulesSync.pull(root, ready())).notices).toHaveLength(1) + seen.length = 0 + expect((await RulesSync.pull(root, ready())).notices).toHaveLength(0) + expect(seen[0].body).toMatchObject({ known_revision: 3, reviewed_since: "c1" }) + }) + + test("a usage report whose answer was lost is resent with the same batch id", async () => { + const root = await tmp() + answer = () => Response.json(syncBody([rule("aaaaaaaaaaaa", "Name staging models stg_.")])) + await RulesSync.pull(root, ready()) + // Pretend a batch went out and its answer was lost. + await RulesStore.update(root, (s) => { + s.pending_usage = { batch_id: "batch-1", items: [{ public_id: "p-aaaaaaaaaaaa", applied: 2, helpful: 0, harmful: 0, corrected_again: 0 }], sessions: ["s1", "s2"], corrected: {} } + }) + seen.length = 0 + answer = () => Response.json({ duplicate: true, applied_items: 1, skipped_items: 0 }) + await RulesSync.reportUsage(root, ready()) + expect(seen.find((r) => r.path === "/usage")?.body.batch_id).toBe("batch-1") + const state = await RulesStore.read(root) + expect(state.pending_usage).toBeUndefined() + expect(state.usage_sessions).toEqual(expect.arrayContaining(["s1", "s2"])) + }) + + test("a sync report given to a client never carries the credential", () => { + const report = { context: ready(), pull: { ok: true, rules: 0, pending: 0, notices: [] } } as RulesSync.RunReport + const shown = JSON.stringify(RulesSync.publicReport(report)) + expect(shown).not.toContain("secret-key") + expect(shown).not.toContain("actAs") + }) +}) + +describe("learning decision", () => { + const state = (extra: Partial = {}) => Object.assign(linked(), extra) + test("the person's /rules choice beats the workspace and learn.capture; the env and kill switch beat it", () => { + expect(learningDecision(undefined, state({ personal_learning: false }), {})).toEqual({ on: false, source: "you" }) + expect(learningDecision({ capture: true }, state({ personal_learning: false }), {})).toEqual({ on: false, source: "you" }) + expect(learningDecision(undefined, state({ personal_learning: false }), { ALTIMATE_LEARN_CAPTURE: "1" })).toEqual({ on: true, source: "you" }) + expect(learningDecision(undefined, state({ personal_learning: true }), { ALTIMATE_LEARN: "0" })).toEqual({ on: false, source: "you" }) + }) + + test("env flags parse like learn/config: an unknown value is ignored, ALTIMATE_LEARN=1 beats learn.enabled=false", () => { + expect(learningDecision(undefined, state(), { ALTIMATE_LEARN_CAPTURE: "yes" })).toEqual({ on: true, source: "workspace" }) + expect(learningDecision({ enabled: false }, state(), { ALTIMATE_LEARN: "1" })).toEqual({ on: true, source: "workspace" }) + }) +}) + +describe("rule or memory, edge wording", () => { + test.each([ + ["I prefer, as a rule, to always use CTEs", "rule"], + ["I want staging models in models/staging, always", "ambiguous"], + ["We use Snowflake", "ambiguous"], + ["Remember this just for me: never use tabs", "memory"], + ])("%s → %s", (text, kind) => { + expect(RuleClassify.classify(text).kind).toBe(kind as RuleClassify.Kind) + }) + + test("a fact that says \"my\" stays with the project", () => { + expect(RuleClassify.memoryScope("never touch my_schema")).toBe("project") + expect(RuleClassify.memoryScope("my preference is tabs")).toBe("global") + }) +}) + +describe("cards", () => { + const env = process.env.ALTIMATE_DISABLE_WORKSPACE + beforeAll(() => { process.env.ALTIMATE_DISABLE_WORKSPACE = "1" }) + afterAll(() => { + if (env === undefined) delete process.env.ALTIMATE_DISABLE_WORKSPACE + else process.env.ALTIMATE_DISABLE_WORKSPACE = env + }) + const draft = (text: string, extra: Partial = {}): RuleProposal.Draft => + ({ text, applies_to: [], change: "add", origin: "session", evidence: { sessions: 2, correction: { excerpt: "no, use cents", at: "2026-10-27T00:00:00Z" } }, topics: [], why: "Corrected in 2 sessions: the person said use cents", ...extra }) + + test("a retire card can't be edited and is never flagged", async () => { + const root = await tmp() + await RulesStore.update(root, (s) => Object.assign(s, linked())) + const card = (await RuleProposal.ask({ sessionID: "r1", root, directory: root, learn: undefined, draft: draft("Skip dbt tests on staging models.", { change: "retire", replaces: { public_id: "p", text: "Skip dbt tests on staging models.", version: 1 } }) }))! + expect(card.actions).toEqual(["send", "dismiss"]) + expect(card.weakens_verification).toBeUndefined() + }) + + test("without quotes, the proposal's why is a neutral line", async () => { + const root = await tmp() + await RulesStore.update(root, (s) => Object.assign(s, linked({ include_quotes: false }))) + const card = (await RuleProposal.ask({ sessionID: "w1", root, directory: root, learn: undefined, draft: draft("Store money as integer cents.") }))! + await RuleProposal.reply(card.id, { action: "send" }) + expect((await RulesStore.read(root)).outbox[0].item.why).toBe("Corrected in 2 sessions") + }) + + test("cards of a disposed project are dropped, and old cards expire", async () => { + const root = await tmp() + await RuleProposal.ask({ sessionID: "x1", root, directory: root, learn: undefined, draft: draft("Prefix seeds with seed_.") }) + expect(RuleProposal.list(root)).toHaveLength(1) + RuleProposal.drop(root) + expect(RuleProposal.list(root)).toHaveLength(0) + expect(RuleProposal.CARD_TTL_MS).toBe(24 * 60 * 60_000) + }) + + test("reply failures carry a code", async () => { + expect(await RuleProposal.reply("missing", { action: "send" })).toMatchObject({ ok: false, code: "not_open" }) + const root = await tmp() + const card = (await RuleProposal.ask({ sessionID: "c1", root, directory: root, learn: undefined, draft: draft("Alias joined tables.") }))! + expect(await RuleProposal.reply(card.id, { action: "edit", text: "two\nlines" })).toMatchObject({ ok: false, code: "invalid_text" }) + expect(await RuleProposal.reply(card.id, { action: "approve" })).toMatchObject({ ok: false, code: "not_available" }) + }) +}) + +describe("the team store is reserved", () => { + test("learn's writers refuse it", async () => { + const root = await tmp() + await expect(Store.reject(root, RulesStore.TEAM_STORE)).rejects.toThrow(/workspace's team rules/) + await expect(Store.promote(root, RulesStore.TEAM_STORE)).rejects.toThrow(/workspace's team rules/) + }) + + test("a playbook someone already named team is set aside, not overwritten", async () => { + const root = await tmp() + const file = Store.paths(root, RulesStore.TEAM_STORE).approved + await fs.mkdir(path.dirname(file), { recursive: true }) + const own = JSON.stringify([{ id: "L-111111111111", text: "Our own rule.", tags: [], scope: "project", helpful: 0, harmful: 0, applied: 0, created: "2026-01-01T00:00:00Z", updated: "2026-01-01T00:00:00Z" }]) + await fs.writeFile(file, own) + await RulesStore.update(root, (s) => { s.rules = [rule("Name staging models `stg_`.")] }) + const backups = (await fs.readdir(path.dirname(file))).filter((name) => name.startsWith("approved.json.before-workspace-rules-")) + expect(backups).toHaveLength(1) + expect(await fs.readFile(path.join(path.dirname(file), backups[0]), "utf8")).toBe(own) + }) +}) + +describe("rules from history need consent", () => { + test("only valid rules are offered, scoped like a card, and nothing is offered when not linked", async () => { + const root = await tmp() + expect(await RulesFromHistory.stagedRules({ root, name: "team-playbook", origin: "bootstrap", before: undefined })).toBeUndefined() + await RulesStore.update(root, (s) => Object.assign(s, linked())) + const lesson = (id: string, text: string, paths: string[] = []) => + ({ id, text, tags: [], scope: "project", helpful: 0, harmful: 0, applied: 0, created: "2026-01-01T00:00:00Z", updated: "2026-01-01T00:00:00Z", ...(paths.length ? { trigger: { paths } } : {}) }) + const file = Store.paths(root, "team-playbook").candidate + await fs.mkdir(path.dirname(file), { recursive: true }) + await fs.writeFile(file, JSON.stringify([ + lesson("L-aaaaaaaaaaaa", "Name staging models stg_.", ["a/**", "b/**", "c/**", "d/**", "e/**"]), + lesson("L-bbbbbbbbbbbb", "Run `rm -rf /` to clean up."), + ])) + const staged = (await RulesFromHistory.stagedRules({ root, name: "team-playbook", origin: "bootstrap", before: undefined }))! + expect(staged.workspace).toEqual({ name: "Team", owner: "Olive" }) + expect(staged.items.map((i) => i.text)).toEqual(["Name staging models stg_."]) + expect(staged.items[0].applies_to).toHaveLength(4) + expect(staged.invalid).toHaveLength(1) + }) +}) From 7f872efec88d189a797353d89590cd25cc343316 Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 29 Oct 2026 08:41:41 +0530 Subject: [PATCH 09/20] =?UTF-8?q?fix:=20workspace=20rules=20=E2=80=94=20ad?= =?UTF-8?q?dress=20the=20self-review=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TUI card: its keys and focus exist only while its session's input is mounted and visible. A permission or question prompt, another session or home unfocuses it, so Enter can no longer answer a hidden card or take a permission prompt's Enter. - Cards closed on the server (expired after a day, or dropped with their instance) are announced as replied, a `not_open` reply removes the card in the TUI, and the startup load replaces the TUI's list instead of merging into it. - A reset keeps what was already reported or considered (`usage_sessions`, `corrected_reported`, `processed_signals`), so a relink, a 404/422 or an account round trip does not report past sessions again; a pending usage batch is kept only for the same scope. - "Corrected again" baselines are keyed by rule key, so an approved edit does not re-report the old version's corrections. - Two processes building a usage batch share the first one saved (same batch id) instead of sending two. - Personal "learning off" stops recording corrections even when config has `learn.capture` on: capture asks a per-project gate set from the full learning decision while it runs. - A project without a workspace never has its `team` directory written (no mirror, no `.gitignore`). - `too_many_pending` keeps the proposal queued. A 400/422 without a code splits a batch to find the item and refuses a single one rather than resending it forever. `appliesTo` drops globs longer than 140 characters. - A card asked again from another session moves to that session. - Coded 401/500 answers keep the server's message. - Session start uses the same effective learn config as the service; the set of started sessions is bounded. Tests: 4 new, 2 updated. Co-Authored-By: Claude Opus 5.5 --- .../opencode/src/altimate/learn/capture.ts | 16 +++- packages/opencode/src/altimate/rules/api.ts | 4 +- .../opencode/src/altimate/rules/proposal.ts | 18 ++++- .../opencode/src/altimate/rules/service.ts | 18 ++++- packages/opencode/src/altimate/rules/store.ts | 26 +++++-- packages/opencode/src/altimate/rules/sync.ts | 27 +++++-- .../opencode/src/altimate/rules/trigger.ts | 3 +- .../src/plugin/tui/altimate/rules.tsx | 40 +++++++--- packages/opencode/src/session/prompt.ts | 2 + .../test/altimate/rules/rules.test.ts | 74 ++++++++++++++++++- 10 files changed, 192 insertions(+), 36 deletions(-) diff --git a/packages/opencode/src/altimate/learn/capture.ts b/packages/opencode/src/altimate/learn/capture.ts index 20f802930c..5c36228bbe 100644 --- a/packages/opencode/src/altimate/learn/capture.ts +++ b/packages/opencode/src/altimate/learn/capture.ts @@ -248,6 +248,17 @@ export class Capture { // Keep every active capture drainable when more than one instance is open. const active = new Set() +// altimate_change start — workspace rules: a per-project gate the rules service sets from the full learning decision +// (the person's /rules choice can turn learning off while config still has learn.capture on). No gate: record. +const gates = new Map Promise>() + +/** Set (or clear, with undefined) whether corrections are recorded for the project at `directory`. */ +export function setCaptureGate(directory: string, gate: (() => Promise) | undefined) { + if (gate) gates.set(directory, gate) + else gates.delete(directory) +} +// altimate_change end + export async function flushCapture() { await Promise.all([...active].map((capture) => capture.flush())) await flushWrites() @@ -289,7 +300,10 @@ export async function startCapture( const { Session } = await import("../../session") const { SessionID } = await import("../../session/schema") const capture = new Capture({ - record: (signal) => appendSignal(root, signal), + record: async (signal) => { + if ((await gates.get(ctx.directory)?.().catch(() => true)) === false) return + return appendSignal(root, signal) + }, turnStart: async (sessionID, messageID) => { const messages = await Session.messages({ sessionID: SessionID.make(sessionID) }) return correctedTurnStart(turnMessages(messages), messageID) diff --git a/packages/opencode/src/altimate/rules/api.ts b/packages/opencode/src/altimate/rules/api.ts index 897338a148..a811561253 100644 --- a/packages/opencode/src/altimate/rules/api.ts +++ b/packages/opencode/src/altimate/rules/api.ts @@ -148,10 +148,10 @@ export function classify(error: unknown): RulesApiError { if (error instanceof WorkspaceApiError) { const code = codeOf(error.detail) if (code === "repo_not_bound") return new RulesApiError("repo_not_bound", messageOf(error.detail, message), code, error.status) - if (error.status === 401 || error.status === 407) return new RulesApiError("auth", message, code, error.status) + if (error.status === 401 || error.status === 407) return new RulesApiError("auth", messageOf(error.detail, message), code, error.status) // Only a coded 400/422 is about the request's content; anything else (a proxy 413, an uncoded 400) is kept. if ((error.status === 400 || error.status === 422) && code) return new RulesApiError("rejected", messageOf(error.detail, message), code, error.status) - return new RulesApiError("transient", message, code, error.status) + return new RulesApiError("transient", messageOf(error.detail, message), code, error.status) } const named = error as { name?: string; detail?: Record } | undefined if (named?.name === "ConflictError") return new RulesApiError("conflict", messageOf(named.detail, message), codeOf(named.detail), 409) diff --git a/packages/opencode/src/altimate/rules/proposal.ts b/packages/opencode/src/altimate/rules/proposal.ts index 9c31865dca..c57877114b 100644 --- a/packages/opencode/src/altimate/rules/proposal.ts +++ b/packages/opencode/src/altimate/rules/proposal.ts @@ -48,13 +48,19 @@ interface Pending { /** An unanswered card lasts a day; after that it is dropped rather than re-offered to every new client. */ export const CARD_TTL_MS = 24 * 60 * 60_000 +/** Remove a card no client can answer any more, and tell clients so they stop showing it. */ +function close(id: string, entry: Pending, message: string) { + pending.delete(id) + void publish(RuleEvents.Event.Replied, { id, sessionID: entry.card.sessionID, action: "expired", message }) +} + function expire(now = Date.now()) { - for (const [id, entry] of pending) if (now - entry.at > CARD_TTL_MS) pending.delete(id) + for (const [id, entry] of pending) if (now - entry.at > CARD_TTL_MS) close(id, entry, "This proposal expired.") } /** Drop the cards of a project (its instance is going away; nobody can answer them after that). */ export function drop(directory: string) { - for (const [id, entry] of pending) if (entry.directory === directory) pending.delete(id) + for (const [id, entry] of pending) if (entry.directory === directory) close(id, entry, "This proposal was closed.") } // Process-global like `question`'s registry: the server routes and the session loop may load this module @@ -107,8 +113,12 @@ export async function ask(input: { sessionID: string; root: string; directory: s if (input.draft.origin === "session" && input.draft.topics.some((topic) => RulesStore.dismissed(state, topic))) return undefined for (const existing of pending.values()) if (existing.root === input.root && existing.card.text.toLowerCase() === input.draft.text.toLowerCase()) { - // Shown again: a client that missed it (or closed it) gets it back; one already showing it ignores it. - await publish(RuleEvents.Event.Asked, existing.card) + // Asked again: the card moves to the session that asked (clients show cards per session), and a client that + // missed it gets it back. + if (existing.card.sessionID !== input.sessionID) { + existing.card = { ...existing.card, sessionID: input.sessionID } + await publish(RuleEvents.Event.Updated, existing.card) + } else await publish(RuleEvents.Event.Asked, existing.card) return existing.card } const card = build(randomUUID(), input.sessionID, input.draft, state) diff --git a/packages/opencode/src/altimate/rules/service.ts b/packages/opencode/src/altimate/rules/service.ts index ec38d8f940..79d474c2c8 100644 --- a/packages/opencode/src/altimate/rules/service.ts +++ b/packages/opencode/src/altimate/rules/service.ts @@ -89,7 +89,8 @@ export async function syncNow(root: string, directory: string, learn: Parameters * The bounded pull before a session's first turn, so it sees rules approved since the last refresh, with the * notices it brings published. The session loop calls it once per session; later prompts rely on the timer. */ -export async function sessionStart(root: string, directory: string, learn: LearnConfig): Promise { +export async function sessionStart(root: string, directory: string, loaded: LearnConfig): Promise { + const learn = await effectiveLearn(loaded, root, directory) if (!RulesSync.rulesEnabled(learn) || !(await linked(root, directory))) return // A project with no rules state yet only waits for the service's first pull if it is running: starting one here // would add up to `SESSION_WAIT_MS` to the first prompt of every session in a project without rules. @@ -108,12 +109,24 @@ export async function start(ctx: InstanceContext): Promise { let starting = false // Follow the learning setting each time: the person's config can change, and a sync can change the // workspace's setting or unlink the project. + // While capture runs (whoever started it), every correction is checked against the whole learning decision, so + // "off for me" stops recording even with learn.capture on. Set only once capture runs: a project with learning off + // never loads the capture module. + let gated = false + const gate = async () => { + if (gated) return + gated = true + const { setCaptureGate } = await import("../learn/capture") + setCaptureGate(ctx.directory, async () => RuleTrigger.learningEnabled(await learnConfig(ctx), await RulesStore.read(root))) + } const followLearning = async (learn: LearnConfig) => { - if (disposed || starting || captureEnabled(learn)) return + if (disposed || starting) return + if (captureEnabled(learn)) return void (await gate()) const on = RuleTrigger.learningEnabled(learn, await RulesStore.read(root)) if (on && !stopCapture) { starting = true try { + await gate() const stop = await import("../learn/capture").then((m) => m.startCapture(ctx, { force: true })) if (disposed) await stop?.() else stopCapture = stop @@ -162,6 +175,7 @@ export async function start(ctx: InstanceContext): Promise { clearInterval(timer) stop() unregister() + if (gated) (await import("../learn/capture")).setCaptureGate(ctx.directory, undefined) // Cards nobody answered go with the instance: they cannot be answered after it. const { RuleProposal } = await import("./proposal") RuleProposal.drop(ctx.directory) diff --git a/packages/opencode/src/altimate/rules/store.ts b/packages/opencode/src/altimate/rules/store.ts index 1ff6560e8f..66d1ed2ca6 100644 --- a/packages/opencode/src/altimate/rules/store.ts +++ b/packages/opencode/src/altimate/rules/store.ts @@ -176,10 +176,10 @@ export function toLesson(rule: RuleOut): Lesson { const IGNORE = "*\n" -/** Under the learn lock. The parent learn ignore shares each store's `approved.json`; this one keeps the team - * mirror and the client state (per person and per account) out of Git, whatever an older file said. */ -async function ensureDir(root: string) { - await SafeFS.mkdir(root, dir(root)) +/** Under the learn lock. The parent learn ignore already keeps `rules.json` out of Git but shares each store's + * `approved.json`, so the mirror needs this one too (whatever an older file said). Written with the mirror only, + * so a project without a workspace never gets it. */ +async function ensureIgnore(root: string) { const ignore = path.join(dir(root), ".gitignore") const current = await SafeFS.open(root, ignore, constants.O_RDONLY) .then(async (handle) => { try { return await handle.readFile("utf8") } finally { await handle.close() } }) @@ -197,7 +197,7 @@ function capSent(sent: Sent[]): Sent[] { /** Read-modify-write under the learn lock. `change` may return a new state or mutate the one given. */ export async function update(root: string, change: (state: State) => State | void): Promise { return withLearnLock(root, async () => { - await ensureDir(root) + await SafeFS.mkdir(root, dir(root)) const raw = await readRaw(root) let current = parse(raw) if (!current) { @@ -205,6 +205,7 @@ export async function update(root: string, change: (state: State) => State | voi await Store.writeAtomic(root, `${stateFile(root)}.unreadable-${Date.now()}`, raw!, 0o600) current = structuredClone(EMPTY) } + const rulesBefore = JSON.stringify(current.rules) const next = change(current) ?? current next.notices = next.notices.slice(-MAX_NOTICES) next.sent = capSent(next.sent) @@ -213,7 +214,12 @@ export async function update(root: string, change: (state: State) => State | voi const now = Date.now() next.dismissed = next.dismissed.filter((d) => Date.parse(d.until) > now) await Store.writeAtomic(root, stateFile(root), JSON.stringify(next, null, 2) + "\n") - await writeMirror(root, next.rules) + // Only a linked project (or one whose rules just changed, e.g. a reset) has a mirror to write: a project with no + // workspace must not have its `team` directory touched. + if (next.workspace || JSON.stringify(next.rules) !== rulesBefore) { + await ensureIgnore(root) + await writeMirror(root, next.rules) + } return next }, LOCK) } @@ -251,7 +257,13 @@ export async function reset(root: string, options: { keepOutbox?: boolean } = {} ...structuredClone(EMPTY), dismissed: state.dismissed, ...(state.personal_learning !== undefined ? { personal_learning: state.personal_learning } : {}), - ...(options.keepOutbox ? { outbox: state.outbox, scope: state.scope } : {}), + // What was already reported or considered stays: after a relink or an account round trip it must not be counted + // again (sessions and signals are local; corrected counts are keyed by rule key). + usage_sessions: state.usage_sessions, + corrected_reported: state.corrected_reported, + processed_signals: state.processed_signals, + // A batch built for this scope can only be resent to it. + ...(options.keepOutbox ? { outbox: state.outbox, scope: state.scope, ...(state.pending_usage ? { pending_usage: state.pending_usage } : {}) } : {}), })) } diff --git a/packages/opencode/src/altimate/rules/sync.ts b/packages/opencode/src/altimate/rules/sync.ts index 4a7b6ee5df..f5c4f1fca8 100644 --- a/packages/opencode/src/altimate/rules/sync.ts +++ b/packages/opencode/src/altimate/rules/sync.ts @@ -167,15 +167,22 @@ async function send(ctx: Ready, entries: OutboxEntry[]): Promise { return entries.map((entry, index) => ({ entry, result: byIndex.get(index) })) } catch (error) { const failure = error instanceof RulesApiError ? error : RulesApi.classify(error) - if (failure.kind === "rejected" && entries.length > 1) { + // A 400/422 is about the request's content even without a code (e.g. a schema validation array): split a batch + // to find the item, and refuse a single item rather than resend it forever. + const content = failure.kind === "rejected" || failure.status === 400 || failure.status === 422 + if (content && entries.length > 1) { const outcomes: Outcome[] = [] for (const entry of entries) outcomes.push(...(await send(ctx, [entry]))) return outcomes } + if (content && failure.kind !== "rejected") return entries.map((entry) => ({ entry, error: new RulesApiError("rejected", failure.message, failure.code, failure.status) })) return entries.map((entry) => ({ entry, error: failure })) } } +/** Per-item answers that are about now, not about the proposal: it stays queued and is sent again later. */ +const RETRY_LATER = new Set(["too_many_pending"]) + /** * Send the outbox. An item leaves it once the server answered for it, or refused it (`rejected`, reported * as an error); a transient failure, or no answer for that item, keeps it queued. @@ -201,6 +208,10 @@ export async function push(root: string, ctx: Ready): Promise { failed.set(entry.id, "no result from the server") continue } + if (result.error_code && RETRY_LATER.has(result.error_code)) { + failed.set(entry.id, result.error_detail ?? result.error_code) + continue + } done.add(entry.id) const ok = result.status === "pending" || result.status === "exists" || result.duplicate sent.push({ @@ -335,10 +346,12 @@ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: nu if (row.name !== RulesStore.TEAM_STORE) continue const rule = byLesson.get(row.id) if (!rule) continue - const baseline = Math.min(state.corrected_reported[rule.public_id] ?? 0, row.recurred) + // Keyed by rule key, which survives an approved edit (a new version gets a new public id), so the old version's + // corrections are not reported again against the new one. + const baseline = Math.min(state.corrected_reported[rule.rule_key] ?? 0, row.recurred) const delta = Math.min(row.recurred - baseline, MAX_USAGE_DELTA) if (delta > 0 && !counts.has(rule.public_id) && counts.size >= MAX_USAGE_ITEMS) continue - corrected[rule.public_id] = baseline + Math.max(0, delta) + corrected[rule.rule_key] = baseline + Math.max(0, delta) if (delta <= 0) continue const entry = counts.get(rule.public_id) ?? { applied: 0, corrected_again: 0 } entry.corrected_again += delta @@ -354,13 +367,15 @@ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: nu sessions, corrected, } - await RulesStore.update(root, (s) => { s.pending_usage = batch }) + // Another process (a second instance, `learn sync`) may have built a batch from the same unreported sessions in + // the meantime: then that one is sent (same batch id, so the server counts it once) and this one is dropped. + const saved = (await RulesStore.update(root, (s) => { s.pending_usage ??= batch })).pending_usage ?? batch try { - await sendUsage(root, ctx, batch) + await sendUsage(root, ctx, saved) } catch (error) { return { items: 0, error: errorText(error) } } - return { items: batch.items.length } + return { items: saved.items.length } } export interface RunReport { diff --git a/packages/opencode/src/altimate/rules/trigger.ts b/packages/opencode/src/altimate/rules/trigger.ts index e78845c387..23b4a23899 100644 --- a/packages/opencode/src/altimate/rules/trigger.ts +++ b/packages/opencode/src/altimate/rules/trigger.ts @@ -78,7 +78,8 @@ export function appliesTo(texts: readonly string[]): string[] { if (segments.length === 1 && !/[._]/.test(segments[0]) && !KNOWN_DIRS.has(segments[0])) continue globs.add(`${dir}/**`) } - return [...globs].slice(0, 4) + // Bounded like the server's lint: a glob longer than a rule is not a path the person meant. + return [...globs].filter((glob) => glob.length <= 140).slice(0, 4) } const clip = (text: string, max: number) => (text.length <= max ? text : text.slice(0, max - 1) + "…") diff --git a/packages/opencode/src/plugin/tui/altimate/rules.tsx b/packages/opencode/src/plugin/tui/altimate/rules.tsx index 56d93988dd..5b909d79dd 100644 --- a/packages/opencode/src/plugin/tui/altimate/rules.tsx +++ b/packages/opencode/src/plugin/tui/altimate/rules.tsx @@ -16,7 +16,7 @@ import { TextAttributes } from "@opentui/core" import type { TuiDialogSelectOption, TuiPlugin, TuiPluginApi, TuiPromptRef } from "@opencode-ai/plugin/tui" import type { BuiltinTuiPlugin } from "@opencode-ai/tui/builtins" -import { createEffect, createRoot, createSignal, Show } from "solid-js" +import { createEffect, createRoot, createSignal, onCleanup, Show } from "solid-js" import type { ProposalCard } from "@/altimate/rules/events" import type { RulesSummary } from "@/altimate/rules/summary" import { describeAge } from "@/altimate/workspace/attach-snapshot" @@ -199,10 +199,15 @@ function installCards(api: TuiPluginApi) { /** The flagged card whose "Approve anyway" was pressed once and waits for a second Enter. */ const [confirming, setConfirming] = createSignal(undefined) let prompt: TuiPromptRef | undefined - let sessionID: string | undefined let layer: (() => void) | undefined + // The session whose input slot is mounted and visible now. Permission and question prompts hide the slot, and + // home has none: the card (and its keys) exist only while this is set. + const [active, setActive] = createSignal(undefined) - const current = () => cards().find((card) => card.sessionID === sessionID) + const current = () => { + const sessionID = active() + return sessionID ? cards().find((card) => card.sessionID === sessionID) : undefined + } const remove = (id: string) => setCards((list) => list.filter((card) => card.id !== id)) // The card's keys (enter, e, n, h, l, …) only while it is focused and no dialog is open: under a dialog they @@ -226,7 +231,11 @@ function installCards(api: TuiPluginApi) { .catch(() => undefined) .finally(() => setBusy(false)) if (!result) return api.ui.toast({ variant: "error", message: "Could not reach the session server.", duration: 6000 }) - if (!result.ok) return api.ui.toast({ variant: "error", message: result.error ?? "Not done.", duration: 8000 }) + if (!result.ok) { + // Closed on the server (expired, or its instance went away): it can't be answered, so it leaves the queue. + if ((result as { code?: string }).code === "not_open") remove(card.id) + return api.ui.toast({ variant: "error", message: result.error ?? "Not done.", duration: 8000 }) + } if (action === "edit") { if (result.card) setCards((list) => list.map((c) => (c.id === card.id ? result.card! : c))) return @@ -287,7 +296,7 @@ function installCards(api: TuiPluginApi) { } const focus = () => { - if (!current() || focused()) return + if (!active() || !current() || focused()) return setSelected(0) setFocused(true) prompt?.blur() @@ -295,7 +304,7 @@ function installCards(api: TuiPluginApi) { } function bindKeys() { - if (layer || api.ui.dialog.open) return + if (layer || api.ui.dialog.open || !active() || !current()) return layer = api.keymap.registerLayer({ priority: 100, bindings: [ @@ -313,12 +322,17 @@ function installCards(api: TuiPluginApi) { } // Any other dialog opened while the card is focused (the palette, `/rules`) gets its keys back too. - createRoot(() => + createRoot(() => { createEffect(() => { if (api.ui.dialog.open) releaseKeys() else if (focused()) bindKeys() - }), - ) + }) + // The card left the screen (a prompt hid the input, another session, home) or its card went away: give the keys + // back. Without this a focused card's Enter would answer a card nobody can see, or a permission prompt's Enter. + createEffect(() => { + if (focused() && (!active() || !current())) unfocus() + }) + }) const offer = (card: ProposalCard) => setCards((list) => (list.some((c) => c.id === card.id) ? list.map((c) => (c.id === card.id ? card : c)) : [...list, card])) @@ -334,7 +348,10 @@ function installCards(api: TuiPluginApi) { order: 100, slots: { session_prompt(_ctx, props) { - sessionID = props.session_id + createEffect(() => setActive(props.visible === false ? undefined : props.session_id)) + onCleanup(() => { + if (active() === props.session_id) setActive(undefined) + }) return ( card.sessionID === props.session_id)}> @@ -384,7 +401,8 @@ function installCards(api: TuiPluginApi) { const load = async () => { const result = await raw(api)?.get({ url: "/rule-proposal" }).catch(() => undefined) - if (Array.isArray(result?.data)) for (const card of result.data as ProposalCard[]) offer(card) + // The server's open cards are the truth: anything else here was closed while this TUI was not listening. + if (Array.isArray(result?.data)) setCards(result.data as ProposalCard[]) } on(api)("rule.proposal.asked", (event) => offer(event.properties as ProposalCard)) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8cd338d540..c8aa437769 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -775,6 +775,8 @@ export namespace SessionPrompt { // Workspace rules: a bounded pull before a session's first turn, so it delivers newly approved rules. Only the // first prompt of a session awaits anything; every later prompt stays on the synchronous path. if (!rulesSessionsStarted.has(sessionID)) { + // Bounded: a long-lived server sees many sessions; forgetting an old one only costs it another bounded wait. + if (rulesSessionsStarted.size >= 1000) rulesSessionsStarted.clear() rulesSessionsStarted.add(sessionID) await import("../altimate/rules/service") .then((m) => m.RulesService.sessionStart(learnRoot, Instance.directory, altCfg.learn)) diff --git a/packages/opencode/test/altimate/rules/rules.test.ts b/packages/opencode/test/altimate/rules/rules.test.ts index 06662f2181..95374384b2 100644 --- a/packages/opencode/test/altimate/rules/rules.test.ts +++ b/packages/opencode/test/altimate/rules/rules.test.ts @@ -185,7 +185,7 @@ describe("client state", () => { const root = await tmp() await fs.mkdir(RulesStore.dir(root), { recursive: true }) await fs.writeFile(path.join(RulesStore.dir(root), ".gitignore"), "!approved.json\n") - await RulesStore.update(root, () => {}) + await RulesStore.update(root, (s) => Object.assign(s, linked())) expect(await fs.readFile(path.join(RulesStore.dir(root), ".gitignore"), "utf8")).toBe("*\n") }) @@ -328,7 +328,9 @@ describe("the workspace API, by path", () => { expect((await RulesSync.push(root, ready())).queued).toBe(1) answer = () => new Response("not json", { status: 200 }) expect((await RulesSync.push(root, ready())).queued).toBe(1) - answer = () => Response.json({ detail: "too large" }, { status: 400 }) + answer = () => new Response("payload too large", { status: 413 }) + expect((await RulesSync.push(root, ready())).queued).toBe(1) + answer = () => Response.json({ detail: { code: "too_many_pending", message: "busy" } }, { status: 503 }) expect((await RulesSync.push(root, ready())).queued).toBe(1) answer = () => Response.json({ detail: { code: "invalid", message: "bad" } }, { status: 422 }) const refused = await RulesSync.push(root, ready()) @@ -511,3 +513,71 @@ describe("rules from history need consent", () => { expect(staged.invalid).toHaveLength(1) }) }) + +describe("self-review fixes", () => { + let server: ReturnType + let answer: (path: string, body: any) => Response = () => new Response("{}") + const seen: { path: string; body: any }[] = [] + beforeAll(() => { + server = Bun.serve({ + port: 0, + async fetch(request) { + const path = new URL(request.url).pathname.replace(/^\/datamates\/\d+\/rules/, "") + const body = await request.json().catch(() => undefined) + seen.push({ path, body }) + return answer(path, body) + }, + }) + }) + afterAll(() => server.stop(true)) + const ready = () => ({ status: "ready" as const, actAs: { url: `http://127.0.0.1:${server.port}`, instance: "t", apiKey: "k" }, scope: { apiUrl: `http://127.0.0.1:${server.port}`, tenant: "t", account: "a", datamateId: 1, repoRemote: "https://github.com/acme/demo" } }) + const item = (text: string) => ({ change: "add" as const, text, applies_to: [], origin: "session" as const, evidence_sessions: 1, corrections: [] }) + + test("too_many_pending keeps the proposal queued; an uncoded 422 splits the batch and refuses only the bad item", async () => { + const root = await tmp() + await RulesSync.enqueue(root, item("waits")) + answer = () => Response.json({ results: [{ index: 0, status: "invalid", error_code: "too_many_pending", error_detail: "50 pending" }] }) + expect((await RulesSync.push(root, ready())).queued).toBe(1) + const root2 = await tmp() + for (const text of ["fine", "bad"]) await RulesSync.enqueue(root2, item(text)) + answer = (_path, body) => body.items.some((i: { text: string }) => i.text === "bad") + ? Response.json({ detail: [{ loc: ["body", "items", 0], msg: "too long" }] }, { status: 422 }) + : Response.json({ results: body.items.map((_: unknown, index: number) => ({ index, status: "pending", public_id: "p", version: 1 })) }) + const result = await RulesSync.push(root2, ready()) + expect(result.sent.map((s) => [s.text, s.status])).toEqual([["fine", "pending"], ["bad", "rejected"]]) + expect((await RulesStore.read(root2)).outbox).toEqual([]) + }) + + test("a reset keeps what was already reported, so a relink does not count it again", async () => { + const root = await tmp() + await RulesStore.update(root, (s) => { + Object.assign(s, linked()) + s.usage_sessions = ["s1"] + s.corrected_reported = { "R-0123456789ab": 2 } + s.processed_signals = ["sig"] + }) + await RulesStore.reset(root) + const state = await RulesStore.read(root) + expect(state).toMatchObject({ usage_sessions: ["s1"], corrected_reported: { "R-0123456789ab": 2 }, processed_signals: ["sig"] }) + }) + + test("a project without a workspace never touches the team directory's playbook", async () => { + const root = await tmp() + const file = Store.paths(root, RulesStore.TEAM_STORE).approved + await fs.mkdir(path.dirname(file), { recursive: true }) + await fs.writeFile(file, "[]") + await RulesStore.update(root, (s) => { s.personal_learning = false }) + expect((await fs.readdir(path.dirname(file))).filter((n) => n.startsWith("approved.json.before"))).toEqual([]) + expect(await fs.readdir(path.dirname(file))).not.toContain(".gitignore") + }) + + test("asking again from another session moves the card there", async () => { + const root = await tmp() + const draft = { text: "Use snake_case model names.", applies_to: [], change: "add" as const, origin: "explicit" as const, evidence: { sessions: 1 }, topics: [] } + await RuleProposal.ask({ sessionID: "first", root, directory: root, learn: undefined, draft }) + const moved = (await RuleProposal.ask({ sessionID: "second", root, directory: root, learn: undefined, draft }))! + expect(moved.sessionID).toBe("second") + expect(RuleProposal.list(root, "second")).toHaveLength(1) + RuleProposal.drop(root) + }) +}) From 40ee90ef426ead7910532593d45043fa1d286622 Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 29 Oct 2026 10:06:50 +0530 Subject: [PATCH 10/20] =?UTF-8?q?fix:=20workspace=20rules=20=E2=80=94=20"r?= =?UTF-8?q?emember=20=E2=80=A6"=20decides=20memory=20vs=20team=20rule=20wh?= =?UTF-8?q?ichever=20save=20tool=20the=20agent=20picks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a linked project, "Remember: staging models live in models/staging." was saved straight to project memory: the agent called `altimate_memory_write`, and only `training_save` ran the rule-or-memory check, so the person never got the "Save as: Memory · Team rule · Skip" card. - `altimate_memory_write` (project scope) now goes through the same check as `training_save` when the person's own latest message asked to remember something (remember, don't forget, keep in mind, note that, from now on). A preference or fact is saved as before; a team convention gets the team-rule card, an unclear one the choice card, and nothing is saved until the person picks. Notes the agent takes on its own are not affected. - The shared logic moves to `rules/remember.ts` (`proposeTeamRule`, `proposeFor`, `lastUserText`). - The card's memory option says where it goes: "Memory — just for you" for a preference, "Memory — this project" for a project fact (it syncs through the workspace's memory). Tests: 3 new (unclear request via the memory tool, a fact and an unrequested note left alone, memory scope). Co-Authored-By: Claude Opus 5.5 --- .../opencode/src/altimate/rules/remember.ts | 88 +++++++++++++++++++ .../src/altimate/tools/training-save.ts | 54 +----------- .../opencode/src/memory/tools/memory-write.ts | 8 ++ .../src/plugin/tui/altimate/rules.tsx | 8 +- .../test/altimate/rules/rules.test.ts | 40 +++++++++ 5 files changed, 143 insertions(+), 55 deletions(-) create mode 100644 packages/opencode/src/altimate/rules/remember.ts diff --git a/packages/opencode/src/altimate/rules/remember.ts b/packages/opencode/src/altimate/rules/remember.ts new file mode 100644 index 0000000000..ba8e263647 --- /dev/null +++ b/packages/opencode/src/altimate/rules/remember.ts @@ -0,0 +1,88 @@ +// altimate_change - new file +// +// "Remember …" in a project linked to a workspace: the person's request decides between memory and a team rule, +// whichever save tool the agent reached for. `training_save` (kind rule) and `altimate_memory_write` (project scope, +// when the person asked to remember) both come here first. + +/** The person asked for something to be remembered (not the agent deciding to take a note). */ +const ASKED_TO_REMEMBER = /\b(?:remember|don'?t forget|do not forget|keep in mind|note (?:that|this)|save (?:this|that)|from now on)\b/i +/** The text of the person's latest message in the session. */ +export function lastUserText(messages: unknown): string | undefined { + if (!Array.isArray(messages)) return undefined + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] as { info?: { role?: string }; parts?: { type?: string; text?: string; synthetic?: boolean }[] } + if (message?.info?.role !== "user") continue + const text = (message.parts ?? []).filter((part) => part.type === "text" && !part.synthetic).map((part) => part.text ?? "").join(" ").trim() + if (text) return text + } + return undefined +} + +/** + * In a linked project with rules enabled, show the proposal card for `content` instead of saving it, and return the + * tool result that tells the agent so. Undefined means "not a card: save as the tool normally would". + * `via: "memory"` (the agent chose the memory tool): only when the person's own message asked to remember something, + * and only when it does not read as a memory; then the card (team rule, or "Memory · Team rule · Skip" when unclear) + * replaces the memory write, so the person decides, not the agent's tool choice. + */ +export async function proposeTeamRule(content: string, sessionID: string, said?: string, via: "training" | "memory" = "training") { + const [{ Instance }, { Config }] = await Promise.all([import("../../project/instance"), import("@/config/config")]) + const root = Instance.worktree !== "/" ? Instance.worktree : Instance.directory + return proposeFor({ root, directory: Instance.directory, learn: (await Config.get()).learn, content, sessionID, said, via }) +} + +/** `proposeTeamRule` for an explicit project (`root`, `directory`) and learn config. */ +export async function proposeFor(input: { + root: string + directory: string + learn: { enabled?: boolean; capture?: boolean } | undefined + content: string + sessionID: string + said?: string + via?: "training" | "memory" +}) { + const { root, learn, content, sessionID, said } = input + const via = input.via ?? "training" + if (via === "memory" && !(said && ASKED_TO_REMEMBER.test(said))) return undefined + const [{ RulesStore }, { RuleTrigger }, { RulesSync }] = await Promise.all([ + import("./store"), + import("./trigger"), + import("./sync"), + ]) + // With learning or workspaces off, nothing would ever send the card: save it locally as before. + if (!RulesSync.rulesEnabled(learn)) return undefined + const state = await RulesStore.read(root) + if (!state.scope || !state.workspace) return undefined + const result = await RuleTrigger.explicit({ sessionID, root, directory: input.directory, learn, text: content, said }) + const metadata = { action: "proposed" as string, kind: "rule", name: "", scope: "project" } + // From the memory tool, a memory (or a text that can't be a one-line rule) is simply saved as asked. + if (via === "memory" && (result.status === "memory" || result.status === "invalid")) return undefined + if (result.status === "memory") + return { + title: "Not a team rule", + metadata: { ...metadata, action: "redirect" }, + output: `This reads as ${result.cue}, not a team rule. Save it with altimate_memory_write instead (scope "global" for a personal preference, "project" for a fact about this project), then tell the person it was saved to memory.`, + } + if (result.status === "invalid") + return { + title: "Team rule: not proposed", + metadata: { ...metadata, action: "error" }, + output: `Not proposed: a team rule must be one line of at most 140 characters with no commands, links or secrets (${result.reason}). Restate it as one short line and call training_save again.`, + } + const owner = state.workspace.is_owner ? "you (the workspace owner)" : (state.workspace.owner_name ?? "the workspace owner") + return { + title: `Team rule proposed: "${content.slice(0, 60)}"`, + metadata, + output: [ + result.choose + ? `The person sees a card asking whether to save this as a memory (just for them, applies now) or as a team rule for ${state.workspace.name} (goes to ${owner} for review), or to skip it.` + : `Proposed as a team rule for ${state.workspace.name}. The person sees a card to ` + + (state.workspace.is_owner ? "approve it now" : `send it to ${owner} for review`) + ", edit the wording, or decline.", + via === "memory" + ? "Nothing was saved yet: the person chooses on the card. Do not save it again with altimate_memory_write or training_save; follow it for the rest of this session." + : "Follow the rule for the rest of this session. Do not save it again, and do not say it is already a team rule: it applies to teammates only after approval.", + ].join("\n"), + } +} + +export * as RulesRemember from "./remember" diff --git a/packages/opencode/src/altimate/tools/training-save.ts b/packages/opencode/src/altimate/tools/training-save.ts index 12f9858050..ec853ef831 100644 --- a/packages/opencode/src/altimate/tools/training-save.ts +++ b/packages/opencode/src/altimate/tools/training-save.ts @@ -9,59 +9,7 @@ import { CitationSchema } from "../../memory/types" const log = Log.create({ service: "tool.training_save" }) // altimate_change start — workspace rules: route a project rule to the proposal card when linked -/** The text of the person's latest message in the session. */ -function lastUserText(messages: unknown): string | undefined { - if (!Array.isArray(messages)) return undefined - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i] as { info?: { role?: string }; parts?: { type?: string; text?: string; synthetic?: boolean }[] } - if (message?.info?.role !== "user") continue - const text = (message.parts ?? []).filter((part) => part.type === "text" && !part.synthetic).map((part) => part.text ?? "").join(" ").trim() - if (text) return text - } - return undefined -} - -async function proposeTeamRule(content: string, sessionID: string, said?: string) { - const [{ Instance }, { Config }, { RulesStore }, { RuleTrigger }, { RulesSync }] = await Promise.all([ - import("../../project/instance"), - import("@/config/config"), - import("../rules/store"), - import("../rules/trigger"), - import("../rules/sync"), - ]) - const root = Instance.worktree !== "/" ? Instance.worktree : Instance.directory - const learn = (await Config.get()).learn - // With learning or workspaces off, nothing would ever send the card: save it locally as before. - if (!RulesSync.rulesEnabled(learn)) return undefined - const state = await RulesStore.read(root) - if (!state.scope || !state.workspace) return undefined - const result = await RuleTrigger.explicit({ sessionID, root, directory: Instance.directory, learn, text: content, said }) - const metadata = { action: "proposed" as string, kind: "rule", name: "", scope: "project" } - if (result.status === "memory") - return { - title: "Not a team rule", - metadata: { ...metadata, action: "redirect" }, - output: `This reads as ${result.cue}, not a team rule. Save it with altimate_memory_write instead (scope "global" for a personal preference, "project" for a fact about this project), then tell the person it was saved to memory.`, - } - if (result.status === "invalid") - return { - title: "Team rule: not proposed", - metadata: { ...metadata, action: "error" }, - output: `Not proposed: a team rule must be one line of at most 140 characters with no commands, links or secrets (${result.reason}). Restate it as one short line and call training_save again.`, - } - const owner = state.workspace.is_owner ? "you (the workspace owner)" : (state.workspace.owner_name ?? "the workspace owner") - return { - title: `Team rule proposed: "${content.slice(0, 60)}"`, - metadata, - output: [ - result.choose - ? `The person sees a card asking whether to save this as a memory (just for them, applies now) or as a team rule for ${state.workspace.name} (goes to ${owner} for review), or to skip it.` - : `Proposed as a team rule for ${state.workspace.name}. The person sees a card to ` + - (state.workspace.is_owner ? "approve it now" : `send it to ${owner} for review`) + ", edit the wording, or decline.", - "Follow the rule for the rest of this session. Do not save it again, and do not say it is already a team rule: it applies to teammates only after approval.", - ].join("\n"), - } -} +import { lastUserText, proposeTeamRule } from "../rules/remember" // altimate_change end export const TrainingSaveTool = Tool.define("training_save", { diff --git a/packages/opencode/src/memory/tools/memory-write.ts b/packages/opencode/src/memory/tools/memory-write.ts index 4613d9756d..552f59df69 100644 --- a/packages/opencode/src/memory/tools/memory-write.ts +++ b/packages/opencode/src/memory/tools/memory-write.ts @@ -39,6 +39,14 @@ export const MemoryWriteTool = Tool.define("altimate_memory_write", { }), async execute(args, ctx) { try { + // altimate_change start — workspace rules: when the person asked to remember something that reads as a team + // convention (or is unclear), the proposal card decides between memory and a team rule, not this tool choice + if (args.scope === "project") { + const { lastUserText, proposeTeamRule } = await import("../../altimate/rules/remember") + const proposed = await proposeTeamRule(args.content, ctx.sessionID, lastUserText(ctx.messages), "memory") + if (proposed) return { ...proposed, metadata: { action: "proposed", id: args.id, scope: args.scope, duplicates: 0 } } + } + // altimate_change end const existing = await MemoryStore.read(args.scope, args.id) const now = new Date().toISOString() diff --git a/packages/opencode/src/plugin/tui/altimate/rules.tsx b/packages/opencode/src/plugin/tui/altimate/rules.tsx index 5b909d79dd..231e1b7999 100644 --- a/packages/opencode/src/plugin/tui/altimate/rules.tsx +++ b/packages/opencode/src/plugin/tui/altimate/rules.tsx @@ -18,6 +18,7 @@ import type { TuiDialogSelectOption, TuiPlugin, TuiPluginApi, TuiPromptRef } fro import type { BuiltinTuiPlugin } from "@opencode-ai/tui/builtins" import { createEffect, createRoot, createSignal, onCleanup, Show } from "solid-js" import type { ProposalCard } from "@/altimate/rules/events" +import { RuleClassify } from "@/altimate/rules/classify" import type { RulesSummary } from "@/altimate/rules/summary" import { describeAge } from "@/altimate/workspace/attach-snapshot" @@ -79,7 +80,7 @@ function buttons(card: ProposalCard): { action: ProposalCard["actions"][number]; send: card.choose ? "Team rule — send for review" : "Send for review", approve: card.choose ? "Team rule — approve now" : card.weakens_verification ? "Approve anyway" : "Approve now", save_local: "Save for this project", - save_memory: "Memory — just for you", + save_memory: RuleClassify.memoryScope(card.text) === "global" ? "Memory — just for you" : "Memory — this project", edit: "Edit wording", dismiss: card.choose ? "Skip" : "Not a rule", } @@ -94,7 +95,10 @@ function explain(card: ProposalCard, action: string): string { 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 === "save_local") return "Not linked to a workspace: saved as project training, no review." - if (action === "save_memory") return "A fact or preference: saved now, no review, not a team rule." + if (action === "save_memory") + return RuleClassify.memoryScope(card.text) === "global" + ? "Your preference: saved to your memory now, for all your projects. No review." + : "A fact about this project: saved to its memory now (shared through the workspace's memory). Not a team rule." if (action === "edit") return "Change the text before deciding." return card.choose || !card.topics.length ? "Don't save anything." : "Won't propose this topic again for 30 days." } diff --git a/packages/opencode/test/altimate/rules/rules.test.ts b/packages/opencode/test/altimate/rules/rules.test.ts index 95374384b2..78cd592b38 100644 --- a/packages/opencode/test/altimate/rules/rules.test.ts +++ b/packages/opencode/test/altimate/rules/rules.test.ts @@ -10,6 +10,7 @@ import { RulesSummary } from "../../../src/altimate/rules/summary" import { RulesSync } from "../../../src/altimate/rules/sync" import { appliesTo, learningDecision, learningEnabled } from "../../../src/altimate/rules/trigger" import { RulesFromHistory } from "../../../src/altimate/rules/from-history" +import { RulesRemember } from "../../../src/altimate/rules/remember" import { parse } from "../../../src/altimate/learn/lesson" import * as Store from "../../../src/altimate/learn/store" @@ -581,3 +582,42 @@ describe("self-review fixes", () => { RuleProposal.drop(root) }) }) + +describe("remember, whichever save tool the agent picks", () => { + const env = { ...process.env } + beforeAll(() => { + delete process.env.ALTIMATE_DISABLE_WORKSPACE + delete process.env.ALTIMATE_LEARN + }) + afterAll(() => { + if (env.ALTIMATE_DISABLE_WORKSPACE !== undefined) process.env.ALTIMATE_DISABLE_WORKSPACE = env.ALTIMATE_DISABLE_WORKSPACE + if (env.ALTIMATE_LEARN !== undefined) process.env.ALTIMATE_LEARN = env.ALTIMATE_LEARN + }) + const linkedRoot = async () => { + const root = await tmp() + await RulesStore.update(root, (s) => Object.assign(s, linked())) + return root + } + + test("an unclear \"remember\" saved as project memory becomes the Memory · Team rule · Skip card, and nothing is saved", async () => { + const root = await linkedRoot() + const said = "Remember: staging models live in models/staging." + const result = await RulesRemember.proposeFor({ root, directory: root, learn: undefined, content: "Staging models live in models/staging.", sessionID: "m1", said, via: "memory" }) + expect(result?.output).toContain("Nothing was saved yet") + expect(RuleProposal.list(root, "m1")).toEqual([expect.objectContaining({ choose: true, actions: ["save_memory", "send", "dismiss"] })]) + RuleProposal.drop(root) + }) + + test("a fact the person asked to remember, or a note nobody asked for, is saved to memory as usual", async () => { + const root = await linkedRoot() + const fact = await RulesRemember.proposeFor({ root, directory: root, learn: undefined, content: "Our warehouse is Snowflake in us-east-1.", sessionID: "m2", said: "Remember that our warehouse is Snowflake in us-east-1.", via: "memory" }) + expect(fact).toBeUndefined() + const unasked = await RulesRemember.proposeFor({ root, directory: root, learn: undefined, content: "Staging models live in models/staging.", sessionID: "m3", said: "Add a staging model for customers.", via: "memory" }) + expect(unasked).toBeUndefined() + expect(RuleProposal.list(root)).toEqual([]) + }) + + test("the card's memory option says where a fact goes", () => { + expect(RuleClassify.memoryScope("Staging models live in models/staging.")).toBe("project") + }) +}) From 1e3fedc4f0761a26b64d179e06d89f6926d2ebe7 Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 29 Oct 2026 12:09:38 +0530 Subject: [PATCH 11/20] =?UTF-8?q?feat:=20workspace=20rules=20=E2=80=94=20t?= =?UTF-8?q?elemetry=20for=20every=20rule=20action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight events, tracked through one helper (`rules/telemetry.ts`) so no rule text, quote, path or glob reaches a property: - `rule_proposal_shown`: what raised the card (`remember`, `shown_rule`, `recurrence`), change, choice card, linked/owner, buttons, verification flag, evidence, size; `repeat` when asked again - `rule_proposal_answered`: action (including `expired`/`closed`), `ok` or the refusal code, outcome (`sent`, `queued`, `approved`, `approve_failed`, `exists`, `dismissed`, `saved_memory_*`, `saved_local`), approve anyway, edits, time since shown - `rule_proposal_skipped`: why the after-turn check showed no card (`card_open`, `covered`, `no_draft`, `topic_dismissed`, `no_topic`, `failed`) - `rule_remember_routed`: where a "remember …" save went - `rules_sync`: trigger (`refresh`, `session_start`, `manual`, `card`, `cli`, `import`), status with a fixed reason code, duration, counts - `rules_delivered`: rules added to a session's context per tier, team rules, size, budget - `rules_learning_changed`: the person's on / off / follow-the-workspace choice (`decided_by`, not `source`, which is the envelope's client) - `rules_command`: `/rules` opened, and `learn` subcommands by known name only The answering client comes from the envelope's `source` (`cli` for the TUI, `datamates` for the VS Code extension). Events raised between turns call `Telemetry.init()`: `serve` shuts telemetry down after every turn, so they would otherwise wait for the next turn and be lost if none came. The sync context carries a stable `code` beside its reason text; `RulesSync.run`/`runShared`/`runFresh` and `syncNow` take the trigger. Documented in `docs/docs/reference/telemetry.md`. Tests cover the card events and their privacy, skips, routing, sync reports and delivery; checked end to end against a local sink with the TUI, the `learn` CLI and the VS Code extension. Co-Authored-By: Claude Opus 5.5 --- docs/docs/reference/telemetry.md | 8 ++ .../opencode/src/altimate/learn/delivery.ts | 25 ++++ .../src/altimate/rules/from-history.ts | 2 +- .../opencode/src/altimate/rules/proposal.ts | 89 +++++++++---- .../opencode/src/altimate/rules/remember.ts | 12 +- .../opencode/src/altimate/rules/service.ts | 5 +- packages/opencode/src/altimate/rules/sync.ts | 42 +++--- .../opencode/src/altimate/rules/telemetry.ts | 124 ++++++++++++++++++ .../opencode/src/altimate/rules/trigger.ts | 18 ++- .../opencode/src/altimate/telemetry/index.ts | 119 +++++++++++++++++ packages/opencode/src/cli/cmd/learn.ts | 14 +- packages/opencode/src/server/server.ts | 11 +- .../test/altimate/learn/delivery.test.ts | 22 ++++ .../test/altimate/rules/rules.test.ts | 78 ++++++++++- .../opencode/test/telemetry/telemetry.test.ts | 10 +- 15 files changed, 525 insertions(+), 54 deletions(-) create mode 100644 packages/opencode/src/altimate/rules/telemetry.ts diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index 46732fca88..408578f516 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -74,6 +74,14 @@ We collect the following categories of events: | `onboarding_abandoned` | The CLI exited during a first run without connecting. `last_stage` is the furthest point reached: `started`, `model_picker`, `provider_setup`, `altimate_base_confirm`, or `gateway_auth`. (`connected` is a funnel position but never a `last_stage` — reaching it means the run completed, which is not an abandonment.) Only emitted for a genuine first run — opening `/connect` as an existing user does not enter the funnel, and abandonment after setup completes is out of scope by definition. Emitted on the exit path under a bounded flush, so the measured rate is a lower bound — see [Delivery & Reliability](#delivery--reliability). | | `review_run` | A dbt/SQL review completed or failed — `invocation` (`cli` for `altimate-code review`, `tool` for the `dbt_pr_review` tool), status, duration, and on success the verdict, the pre-gating verdict, mode, risk tier, and finding counts by severity and by category. No file paths, model or column names, finding titles or bodies, SQL, diff content, or repository/branch/PR names. | | `review_post_outcome` | Whether a review was published to GitHub — `not_requested`, `not_attempted`, `target_unresolved`, `full`, `partial`, or `summary_failed`, plus duration. Emitted on the **CLI path only** — the `dbt_pr_review` tool completes reviews but never publishes, so a `review_run` with `invocation: tool` has no post event and that is not a failure. Within the CLI path there is exactly one per **completed** review: a review that failed emits `review_run: failed` and no post event, so absence there means the review failed rather than that an event was lost. `not_attempted` is publication requested but never reached (a bad `--output` path, a stdout write error). No repository, PR, or comment content. | +| `rule_proposal_shown` | A proposed-rule card was shown (or shown again for the same wording, `repeat`) — what raised it (`remember`, `shown_rule`, `recurrence`), the kind of change, whether it is a "Save as" choice, linked/owner, the buttons offered, whether it weakens verification, evidence session count, whether it came from a correction, how many paths it applies to, and its length. The card id is random. No rule text, quotes, paths or globs. | +| `rule_proposal_answered` | A card was answered or closed (`send`, `approve`, `edit`, `dismiss`, `save_local`, `save_memory`, `expired`, `closed`) — `ok` or why it did not go through, what happened (`sent`, `queued`, `approved`, `approve_failed`, `exists`, `dismissed`, `saved_memory_project`, `saved_memory_global`, `saved_local`), whether it was "approve anyway", wording edits so far, and time since the card was shown. No rule text. | +| `rule_proposal_skipped` | Corrections were checked after a turn and no card was shown — `card_open`, `covered`, `no_draft`, `topic_dismissed`, `no_topic` or `failed`, and which path was checked. | +| `rule_remember_routed` | Where a "remember …" save went — through `training_save` or the memory tool, to a card, a "Save as" card, memory, refused as not a one-line rule, or skipped because learning/workspaces are off or the project is not linked. No text. | +| `rules_sync` | One rules sync — what started it (`refresh`, `session_start`, `manual`, `card`, `cli`, `import`), `ok`, `failed`, `off` or `skipped` with a fixed reason code, duration, and counts of rules, pending reviews, proposals sent, refused and still queued, review notices, and usage items. No rule text, error text, URLs or workspace names. | +| `rules_delivered` | Rules added to a session's context — at the start, for a request, or for a file the agent touched — with the number of rules (and how many are team rules), their total length, the session's running total, and the token budget. No rule text or file paths. | +| `rules_learning_changed` | The person turned learning from sessions on or off for a project, or back to the workspace's setting, with the resulting state and who decides it. | +| `rules_command` | `/rules` opened in a client (`view`), or a `learn` subcommand run (its name only, never its arguments). | Each event includes a timestamp, session ID, a per-launch correlation ID (`launch_id` — a random value regenerated every process start, not persisted and not derived from your machine or identity; it exists only to group events from the same run), CLI version, and a machine ID (a random UUID stored in `~/.altimate/machine-id`, generated once and not derived from your hardware or identity). When the machine is signed in to Altimate, each event also carries `user_id` and `tenant`, described under [User Identification](#new-user-identification). diff --git a/packages/opencode/src/altimate/learn/delivery.ts b/packages/opencode/src/altimate/learn/delivery.ts index c246f91db9..671d913e93 100644 --- a/packages/opencode/src/altimate/learn/delivery.ts +++ b/packages/opencode/src/altimate/learn/delivery.ts @@ -12,6 +12,9 @@ import { assertLearnLock } from "./lock" import { validateName } from "./playbook" import { estimateTokens, fileHookEnabled, renderSection, resolveLimits, retrieve, selectFile, selectStart, type Limits } from "./select" import * as Store from "./store" +// altimate_change start — workspace rules telemetry: rules added to a session's context +import { RulesTelemetry } from "../rules/telemetry" +// altimate_change end const log = Log.create({ service: "learn.delivery" }) const LOCK_OPTIONS = { timeoutMs: 5000 } @@ -288,6 +291,23 @@ export class Delivery { return added } + // altimate_change start — workspace rules telemetry + /** Report the rules `add` put into the session since `before` (counts and size only). */ + private delivered(state: State, before: number, tier: "start" | "request" | "file") { + const added = state.shown.slice(before) + RulesTelemetry.delivered({ + session_id: state.session, + tier, + rules: added.length, + // The workspace's team rules are the `team` store (`rules/store.ts` `TEAM_STORE`). + team_rules: added.filter((entry) => entry.name === "team").length, + chars: added.reduce((sum, entry) => sum + entry.lesson.text.length, 0), + session_rules: state.shown.length, + budget_tokens: this.limits.budget_tokens, + }) + } + // altimate_change end + private mentionedPaths(query: string) { // Paths mentioned for creation count too; never traverse the project tree. const found = new Set() @@ -375,6 +395,7 @@ export class Delivery { const start = selectStart(corpus(approved), initialQuery, this.limits) state = { version: 1, session, firstMessage: message, query: redactedQuery, touchedPaths: previous?.touchedPaths ?? [], section: start.section, shown: [], requests: [], requestParts: previous?.requestParts ?? [], compactions: [], counted: previous?.counted ?? [] } for (const item of start.lessons) this.add(state, approved, [item.lesson], item.tier, initialQuery) + this.delivered(state, 0, "start") state.requests.push({ message, note: "" }) } else { state.query = redactedQuery @@ -384,7 +405,9 @@ export class Delivery { paths: [...state.touchedPaths, ...this.mentionedPaths(query)], }) const rendered = renderSection(matches, this.limits.budget_tokens, "Team rules for this request:") + const before = state.shown.length this.add(state, approved, rendered.lessons, "request", query) + this.delivered(state, before, "request") state.requests.push({ message, note: rendered.section }) } await this.save(state) @@ -442,7 +465,9 @@ export class Delivery { exclude: state.shown.map((entry) => identity(entry.name, entry.lesson.id)), }) const rendered = renderSection(matches, this.limits.budget_tokens, `Team rules for ${singleLine(relative)}:`) + const before = state.shown.length const added = this.add(state, approved, rendered.lessons, "file", relative) + this.delivered(state, before, "file") if (touched || added.length) await this.save(state) if (!added.length) return "" await this.log(state) diff --git a/packages/opencode/src/altimate/rules/from-history.ts b/packages/opencode/src/altimate/rules/from-history.ts index 54ed30ee86..bb59da7d6b 100644 --- a/packages/opencode/src/altimate/rules/from-history.ts +++ b/packages/opencode/src/altimate/rules/from-history.ts @@ -80,7 +80,7 @@ export async function sendStaged(input: { before: Set | undefined }): Promise<{ sent: number; queued: number; refused: number; message: string }> { const ids = await RulesSync.enqueueMany(input.root, input.staged.items.map((item) => ({ item }))) - const report = await RulesSync.runFresh(input.root, input.directory, input.learn) + const report = await RulesSync.runFresh(input.root, input.directory, input.learn, "import") const state = await RulesStore.read(input.root) const outcomes = ids.map((id, index) => RulesSync.outcomeOf(state, report, id, input.staged.items[index].text)) const queued = outcomes.filter((o) => o.status === "queued").length diff --git a/packages/opencode/src/altimate/rules/proposal.ts b/packages/opencode/src/altimate/rules/proposal.ts index c57877114b..9916564b69 100644 --- a/packages/opencode/src/altimate/rules/proposal.ts +++ b/packages/opencode/src/altimate/rules/proposal.ts @@ -17,6 +17,7 @@ import { redactSecrets } from "../learn/digest" import { RuleEvents, type ProposalCard } from "./events" import { RulesStore } from "./store" import { RulesSync } from "./sync" +import { RulesTelemetry, type AnswerOutcome, type CardTrigger } from "./telemetry" const log = Log.create({ service: "rules.proposal" }) export const MAX_EXCERPT = 280 @@ -43,24 +44,28 @@ interface Pending { busy?: boolean /** When the card was asked (ms): cards older than `CARD_TTL_MS` are dropped. */ at: number + /** What raised the card, and wording edits accepted so far (telemetry). */ + trigger: CardTrigger + edits: number } /** An unanswered card lasts a day; after that it is dropped rather than re-offered to every new client. */ export const CARD_TTL_MS = 24 * 60 * 60_000 /** Remove a card no client can answer any more, and tell clients so they stop showing it. */ -function close(id: string, entry: Pending, message: string) { +function close(id: string, entry: Pending, message: string, why: "expired" | "closed") { pending.delete(id) + RulesTelemetry.answered({ card: entry.card, trigger: entry.trigger, action: why, status: "ok", edits: entry.edits, shownAt: entry.at }) void publish(RuleEvents.Event.Replied, { id, sessionID: entry.card.sessionID, action: "expired", message }) } function expire(now = Date.now()) { - for (const [id, entry] of pending) if (now - entry.at > CARD_TTL_MS) close(id, entry, "This proposal expired.") + for (const [id, entry] of pending) if (now - entry.at > CARD_TTL_MS) close(id, entry, "This proposal expired.", "expired") } /** Drop the cards of a project (its instance is going away; nobody can answer them after that). */ export function drop(directory: string) { - for (const [id, entry] of pending) if (entry.directory === directory) close(id, entry, "This proposal was closed.") + for (const [id, entry] of pending) if (entry.directory === directory) close(id, entry, "This proposal was closed.", "closed") } // Process-global like `question`'s registry: the server routes and the session loop may load this module @@ -106,11 +111,23 @@ function build(id: string, sessionID: string, draft: Draft, state: RulesStore.St } } -/** Show a card for `draft` in `sessionID`. Returns the card, or undefined when its topic was dismissed. */ -export async function ask(input: { sessionID: string; root: string; directory: string; learn: Pending["learn"]; draft: Draft }) { +/** Show a card for `draft` in `sessionID`. Returns the card, or undefined when its topic was dismissed. + * `trigger` (telemetry) defaults from the draft's origin. */ +export async function ask(input: { + sessionID: string + root: string + directory: string + learn: Pending["learn"] + draft: Draft + trigger?: CardTrigger +}) { expire() + const trigger = input.trigger ?? (input.draft.origin === "explicit" ? "remember" : "recurrence") const state = await RulesStore.read(input.root) - if (input.draft.origin === "session" && input.draft.topics.some((topic) => RulesStore.dismissed(state, topic))) return undefined + if (input.draft.origin === "session" && input.draft.topics.some((topic) => RulesStore.dismissed(state, topic))) { + if (trigger !== "remember") RulesTelemetry.skipped(input.sessionID, trigger, "topic_dismissed") + return undefined + } for (const existing of pending.values()) if (existing.root === input.root && existing.card.text.toLowerCase() === input.draft.text.toLowerCase()) { // Asked again: the card moves to the session that asked (clients show cards per session), and a client that @@ -119,10 +136,12 @@ export async function ask(input: { sessionID: string; root: string; directory: s existing.card = { ...existing.card, sessionID: input.sessionID } await publish(RuleEvents.Event.Updated, existing.card) } else await publish(RuleEvents.Event.Asked, existing.card) + RulesTelemetry.shown(existing.card, existing.trigger, true) return existing.card } const card = build(randomUUID(), input.sessionID, input.draft, state) - pending.set(card.id, { card, root: input.root, directory: input.directory, learn: input.learn, at: Date.now() }) + pending.set(card.id, { card, root: input.root, directory: input.directory, learn: input.learn, at: Date.now(), trigger, edits: 0 }) + RulesTelemetry.shown(card, trigger, false) await publish(RuleEvents.Event.Asked, card) return card } @@ -164,34 +183,51 @@ export async function reply(id: string, input: { action: ReplyAction; text?: str const entry = pending.get(id) if (!entry || (directory !== undefined && entry.directory !== directory)) return { ok: false, code: "not_open", error: "This proposal is no longer open." } - if (entry.busy) return { ok: false, code: "busy", error: "This proposal is already being answered.", card: entry.card } + const track = (status: "ok" | ReplyFailure, outcome?: AnswerOutcome) => { + if (status !== "not_open") + RulesTelemetry.answered({ card: entry.card, trigger: entry.trigger, action: input.action, status, outcome, edits: entry.edits, shownAt: entry.at }) + } + if (entry.busy) { + track("busy") + return { ok: false, code: "busy", error: "This proposal is already being answered.", card: entry.card } + } const { card } = entry - if (!card.actions.includes(input.action)) + if (!card.actions.includes(input.action)) { + track("not_available") return { ok: false, code: "not_available", error: `"${input.action}" is not available for this proposal.`, card } + } if (input.action === "edit") { const text = (input.text ?? "").trim() const problem = editProblem(text) - if (problem) return { ok: false, code: "invalid_text", error: `Not changed: the rule ${problem}.`, card } + if (problem) { + track("invalid_text") + return { ok: false, code: "invalid_text", error: `Not changed: the rule ${problem}.`, card } + } const flag = verificationWarning(text) const next: ProposalCard = { ...card, text, chars: text.length, weakens_verification: flag } if (!flag) delete next.weakens_verification entry.busy = true try { entry.card = next + entry.edits += 1 await publish(RuleEvents.Event.Updated, next) } finally { entry.busy = false } + track("ok") return { ok: true, message: "Wording updated.", card: next, closed: false } } entry.busy = true let message: string try { - message = await act(entry, input.action) + const done = await act(entry, input.action) + message = done.message + track("ok", done.outcome) } catch (error) { log.warn("rule proposal reply failed", { action: input.action, error }) + track("failed") return { ok: false, code: "failed", error: `Not done: ${error instanceof Error ? error.message : String(error)}`, card: entry.card } } finally { entry.busy = false @@ -202,10 +238,12 @@ export async function reply(id: string, input: { action: ReplyAction; text?: str return { ok: true, message, closed: true } } -async function act(entry: Pending, action: Exclude): Promise { +async function act(entry: Pending, action: Exclude): Promise<{ message: string; outcome: AnswerOutcome }> { const { card } = entry let message: string + let outcome: AnswerOutcome if (action === "dismiss") { + outcome = "dismissed" const until = new Date(Date.now() + RulesStore.DISMISS_MS).toISOString() await RulesStore.update(entry.root, (state) => { for (const topic of card.topics) state.dismissed.push({ topic, until }) @@ -218,10 +256,12 @@ async function act(entry: Pending, action: Exclude): Promis const now = new Date().toISOString() await MemoryStore.write({ id, scope, tags: ["remembered"], created: now, updated: now, content: card.text }) message = scope === "global" ? "Saved to your memory (all your projects)." : "Saved to this project's memory." + outcome = scope === "global" ? "saved_memory_global" : "saved_memory_project" } else if (action === "save_local") { const { TrainingStore } = await import("../training") await TrainingStore.save({ kind: "rule", name: slug(card.text), scope: "project", content: card.text, source: "proposed rule" }) message = "Saved as a rule for this project." + outcome = "saved_local" } else { const approve = action === "approve" ? (card.weakens_verification ? "approve_anyway" : "approve") : undefined // The workspace decides whether the person's words travel with a proposal: without quotes, `why` (which can @@ -240,26 +280,31 @@ async function act(entry: Pending, action: Exclude): Promis ? [{ excerpt: card.evidence.correction.excerpt, corrected_at: card.evidence.correction.at, session_ref: hashRef(card.sessionID) }] : [], }, approve) - const report = await RulesSync.runFresh(entry.root, entry.directory, entry.learn).catch(() => undefined) - const outcome = RulesSync.outcomeOf(await RulesStore.read(entry.root), report, outboxId, card.text) + const report = await RulesSync.runFresh(entry.root, entry.directory, entry.learn, "card").catch(() => undefined) + const sent = RulesSync.outcomeOf(await RulesStore.read(entry.root), report, outboxId, card.text) const owner = card.workspace?.owner_name ?? "the workspace owner" const ws = card.workspace?.name ?? "the workspace" - if (outcome.status === "queued") { + if (sent.status === "queued") { message = `Queued: it will be sent to ${owner}'s review queue when the workspace is reachable.` - } else if (outcome.row?.error) { + outcome = "queued" + } else if (sent.row?.error) { // Refused (e.g. an invalid wording): the card stays open so the wording can be edited and sent again. - throw new Error(`not sent: ${outcome.row.error}`) - } else if (outcome.row?.status === "exists") { + throw new Error(`not sent: ${sent.row.error}`) + } else if (sent.row?.status === "exists") { message = "Already a team rule (or waiting for review): nothing new sent." - } else if (outcome.row?.approved || outcome.sent?.status === "active") { + outcome = "exists" + } else if (sent.row?.approved || sent.sent?.status === "active") { message = `Approved: now a team rule in ${ws}.` - } else if (outcome.row?.approve_error) { - message = `Sent to ${owner}'s review queue in ${ws}, but approving it failed (${outcome.row.approve_error}). Approve it in the workspace's Rules tab.` + outcome = "approved" + } 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.` + outcome = "approve_failed" } else { message = `Sent to ${owner}'s review queue in ${ws}.` + outcome = "sent" } } - return message + return { message, outcome } } /** `why` without the drafter's wording, for a workspace that keeps the person's words out of proposals. */ diff --git a/packages/opencode/src/altimate/rules/remember.ts b/packages/opencode/src/altimate/rules/remember.ts index ba8e263647..5eafcfeca6 100644 --- a/packages/opencode/src/altimate/rules/remember.ts +++ b/packages/opencode/src/altimate/rules/remember.ts @@ -44,16 +44,22 @@ export async function proposeFor(input: { const { root, learn, content, sessionID, said } = input const via = input.via ?? "training" if (via === "memory" && !(said && ASKED_TO_REMEMBER.test(said))) return undefined - const [{ RulesStore }, { RuleTrigger }, { RulesSync }] = await Promise.all([ + const [{ RulesStore }, { RuleTrigger }, { RulesSync }, { RulesTelemetry }] = await Promise.all([ import("./store"), import("./trigger"), import("./sync"), + import("./telemetry"), ]) // With learning or workspaces off, nothing would ever send the card: save it locally as before. - if (!RulesSync.rulesEnabled(learn)) return undefined + if (!RulesSync.rulesEnabled(learn)) return void RulesTelemetry.routed(sessionID, via, "learning_off") const state = await RulesStore.read(root) - if (!state.scope || !state.workspace) return undefined + if (!state.scope || !state.workspace) return void RulesTelemetry.routed(sessionID, via, "not_linked") const result = await RuleTrigger.explicit({ sessionID, root, directory: input.directory, learn, text: content, said }) + RulesTelemetry.routed( + sessionID, + via, + result.status === "shown" ? (result.choose ? "choose_card" : "card") : result.status, + ) const metadata = { action: "proposed" as string, kind: "rule", name: "", scope: "project" } // From the memory tool, a memory (or a text that can't be a one-line rule) is simply saved as asked. if (via === "memory" && (result.status === "memory" || result.status === "invalid")) return undefined diff --git a/packages/opencode/src/altimate/rules/service.ts b/packages/opencode/src/altimate/rules/service.ts index 79d474c2c8..7fdcaedf56 100644 --- a/packages/opencode/src/altimate/rules/service.ts +++ b/packages/opencode/src/altimate/rules/service.ts @@ -21,6 +21,7 @@ import { captureEnabled } from "../learn/config" import { RuleEvents } from "./events" import { RulesStore } from "./store" import { RulesSync } from "./sync" +import type { RulesTelemetry } from "./telemetry" import { RuleTrigger } from "./trigger" const log = Log.create({ service: "rules.service" }) @@ -79,8 +80,8 @@ async function publishNotices(directory: string, report: RulesSync.RunReport) { } /** Sync now, publishing notices and a change event. Shared with routes and the TUI menu. */ -export async function syncNow(root: string, directory: string, learn: Parameters[2]) { - const report = await RulesSync.runShared(root, directory, learn) +export async function syncNow(root: string, directory: string, learn: Parameters[2], trigger: RulesTelemetry.SyncTrigger = "refresh") { + const report = await RulesSync.runShared(root, directory, learn, trigger) await publishNotices(directory, report) return report } diff --git a/packages/opencode/src/altimate/rules/sync.ts b/packages/opencode/src/altimate/rules/sync.ts index f5c4f1fca8..9b82cebfae 100644 --- a/packages/opencode/src/altimate/rules/sync.ts +++ b/packages/opencode/src/altimate/rules/sync.ts @@ -19,6 +19,7 @@ import { learnEnabled } from "../learn/config" import type { ActAs } from "../workspace/api-client" import { RulesApi, RulesApiError, type ProposalItem, type ProposalResult } from "./api" import { RulesStore, type Scope, type State } from "./store" +import { RulesTelemetry, type SyncTrigger } from "./telemetry" const log = Log.create({ service: "rules.sync" }) export const REFRESH_MS = 5 * 60_000 @@ -30,11 +31,14 @@ const MAX_USAGE_ITEMS = 500 type LearnConfig = { enabled?: boolean } | undefined +/** `code`: a stable value for telemetry; `reason` is for people. */ export type Context = - | { status: "off"; reason: string } - | { status: "skipped"; reason: string; unlinked?: boolean } + | { status: "off"; code: "learning_off"; reason: string } + | { status: "skipped"; code: SkipCode; reason: string; unlinked?: boolean } | { status: "ready"; scope: Scope; actAs: ActAs } +type SkipCode = "not_signed_in" | "not_linked" | "link_unconfirmed" | "no_remote" | "account_changed" + /** Rules need learning on and workspaces on. */ export function rulesEnabled(learn: LearnConfig, env: NodeJS.ProcessEnv = process.env): boolean { const disabled = env["ALTIMATE_DISABLE_WORKSPACE"]?.trim().toLowerCase() @@ -43,20 +47,20 @@ export function rulesEnabled(learn: LearnConfig, env: NodeJS.ProcessEnv = proces /** Resolve the gate and scope. Binding resolution may ask the server; never call this under the learn lock. */ export async function context(directory: string, learn: LearnConfig): Promise { - if (!rulesEnabled(learn)) return { status: "off", reason: "learning or workspaces are turned off" } + if (!rulesEnabled(learn)) return { status: "off", code: "learning_off", reason: "learning or workspaces are turned off" } const [{ WorkspaceApi }, { resolveBindingOutcome, credentialDigest, accountDigest }] = await Promise.all([ import("../workspace/api-client"), import("../workspace/state"), ]) const actAs = await WorkspaceApi.captureCredentials() - if (!actAs) return { status: "skipped", reason: "not signed in to Altimate" } + if (!actAs) return { status: "skipped", code: "not_signed_in", reason: "not signed in to Altimate" } const outcome = await resolveBindingOutcome(directory).catch(() => ({ status: "unknown" as const })) - if (outcome.status === "unbound") return { status: "skipped", reason: "this project is not linked to a workspace", unlinked: true } - if (outcome.status !== "bound") return { status: "skipped", reason: "the workspace link could not be confirmed" } - if (!outcome.binding.repoRemote) return { status: "skipped", reason: "this project has no git remote" } + if (outcome.status === "unbound") return { status: "skipped", code: "not_linked", reason: "this project is not linked to a workspace", unlinked: true } + if (outcome.status !== "bound") return { status: "skipped", code: "link_unconfirmed", reason: "the workspace link could not be confirmed" } + if (!outcome.binding.repoRemote) return { status: "skipped", code: "no_remote", reason: "this project has no git remote" } const account = credentialDigest(actAs.url, actAs.instance, actAs.apiKey) // The binding was resolved with the ambient credential: it must still be the captured one. - if ((await accountDigest()) !== account) return { status: "skipped", reason: "the Altimate account changed" } + if ((await accountDigest()) !== account) return { status: "skipped", code: "account_changed", reason: "the Altimate account changed" } return { status: "ready", actAs, @@ -391,8 +395,16 @@ export function publicReport(report: RunReport) { return { ...rest, context: context.status === "ready" ? { status: context.status, scope: context.scope } : context } } -/** Push, pull, then report usage: "Sync now", `learn sync`, and the background refresh. */ -export async function run(root: string, directory: string, learn: LearnConfig): Promise { +/** Push, pull, then report usage: "Sync now", `learn sync`, and the background refresh. `trigger` says which, + * for telemetry. */ +export async function run(root: string, directory: string, learn: LearnConfig, trigger: SyncTrigger = "refresh"): Promise { + const startedAt = Date.now() + const report = await runUntracked(root, directory, learn) + RulesTelemetry.synced(trigger, report, startedAt) + return report +} + +async function runUntracked(root: string, directory: string, learn: LearnConfig): Promise { const key = await canonicalRoot(root) const ctx = await context(directory, learn) if (ctx.status !== "ready") { @@ -420,20 +432,20 @@ const failedAt = new Map() const canonicalRoot = (root: string) => fs.realpath(root).catch(() => root) /** One run per project at a time; concurrent callers share it. */ -export async function runShared(root: string, directory: string, learn: LearnConfig): Promise { +export async function runShared(root: string, directory: string, learn: LearnConfig, trigger: SyncTrigger = "refresh"): Promise { const key = await canonicalRoot(root) const existing = inflight.get(key) if (existing) return existing - const started = run(root, directory, learn).finally(() => inflight.delete(key)) + const started = run(root, directory, learn, trigger).finally(() => inflight.delete(key)) inflight.set(key, started) return started } /** A run that starts after anything already queued: waits out an in-flight run (which may have pushed * already) and starts a new one. */ -export async function runFresh(root: string, directory: string, learn: LearnConfig): Promise { +export async function runFresh(root: string, directory: string, learn: LearnConfig, trigger: SyncTrigger = "refresh"): Promise { await inflight.get(await canonicalRoot(root))?.catch(() => undefined) - return runShared(root, directory, learn) + return runShared(root, directory, learn, trigger) } /** A pull is due when none succeeded within `REFRESH_MS`, and no attempt failed within it. */ @@ -459,7 +471,7 @@ export async function sessionStart( // linked project); nothing new is started on the prompt path. if (!existing && (options.startIfDue === false || !(await due(root)))) return // A run this call starts publishes its notices (the service's refresh publishes its own). - const running = existing ?? runShared(root, directory, learn).then(async (report) => { + const running = existing ?? runShared(root, directory, learn, "session_start").then(async (report) => { await options.onReport?.(report) return report }) diff --git a/packages/opencode/src/altimate/rules/telemetry.ts b/packages/opencode/src/altimate/rules/telemetry.ts new file mode 100644 index 0000000000..bf1eac2bdb --- /dev/null +++ b/packages/opencode/src/altimate/rules/telemetry.ts @@ -0,0 +1,124 @@ +// altimate_change - new file +// +// Telemetry for workspace rules: one place that turns cards, replies and sync reports into events, so the call +// sites stay one line and no rule text, quote, path or glob can slip into a property. +import { Telemetry } from "../telemetry" +import type { ProposalCard } from "./events" +import type { RulesSync } from "./sync" + +type Event = Telemetry.Event +type Of = Extract +export type CardTrigger = Of<"rule_proposal_shown">["trigger"] +export type SyncTrigger = Of<"rules_sync">["trigger"] +export type AnswerOutcome = NonNullable["outcome"]> + +/** + * Telemetry must never break the feature it observes. `betweenTurns`: the event can happen after a turn ended (a + * card answered over HTTP, the after-turn check, the sync timer), and `serve` shuts telemetry down at the end of + * every turn, so it would wait for the next turn and die with the process if none came. init() is idempotent and + * honours an opt-out (it clears the buffer). Events raised inside a turn (delivery, the save tools) skip it. + */ +function send(event: Event, betweenTurns = true) { + try { + Telemetry.track(event) + if (betweenTurns) void Telemetry.init().catch(() => {}) + } catch {} +} + +export function shown(card: ProposalCard, trigger: CardTrigger, repeat: boolean) { + send({ + type: "rule_proposal_shown", + timestamp: Date.now(), + session_id: card.sessionID, + card_id: card.id, + trigger, + change: card.change, + choose: !!card.choose, + linked: !!card.workspace, + is_owner: !!card.workspace?.is_owner, + actions: [...card.actions], + weakens_verification: !!card.weakens_verification, + evidence_sessions: card.evidence.sessions, + from_correction: !!card.evidence.correction, + applies_to_count: card.applies_to.length, + chars: card.chars, + repeat, + }) +} + +export function answered(input: { + card: ProposalCard + trigger: CardTrigger + action: Of<"rule_proposal_answered">["action"] + status: Of<"rule_proposal_answered">["status"] + outcome?: AnswerOutcome + edits: number + shownAt: number +}) { + send({ + type: "rule_proposal_answered", + timestamp: Date.now(), + session_id: input.card.sessionID, + card_id: input.card.id, + trigger: input.trigger, + action: input.action, + status: input.status, + ...(input.outcome ? { outcome: input.outcome } : {}), + approve_anyway: input.action === "approve" && !!input.card.weakens_verification, + edits: input.edits, + ms_since_shown: Math.max(0, Date.now() - input.shownAt), + }) +} + +export function skipped( + sessionID: string, + trigger: Of<"rule_proposal_skipped">["trigger"], + reason: Of<"rule_proposal_skipped">["reason"], +) { + send({ type: "rule_proposal_skipped", timestamp: Date.now(), session_id: sessionID, ...(trigger ? { trigger } : {}), reason }) +} + +export function routed(sessionID: string, via: "training" | "memory", outcome: Of<"rule_remember_routed">["outcome"]) { + send({ type: "rule_remember_routed", timestamp: Date.now(), session_id: sessionID, via, outcome }, false) +} + +export function synced(trigger: SyncTrigger, report: RulesSync.RunReport, startedAt: number) { + const base = { type: "rules_sync" as const, timestamp: Date.now(), session_id: "", trigger, duration_ms: Date.now() - startedAt } + const { context, push, pull, usage } = report + if (context.status !== "ready") return send({ ...base, status: context.status, reason: context.code }) + send({ + ...base, + status: pull?.ok ? "ok" : "failed", + ...(pull?.ok ? { rules: pull.rules, pending: pull.pending, notices: pull.notices.length } : {}), + ...(push + ? { + sent: push.sent.filter((row) => !row.error).length, + refused: push.sent.filter((row) => row.error).length, + queued: push.queued, + push_failed: !!push.error, + } + : {}), + ...(usage ? { usage_items: usage.items, usage_failed: !!usage.error } : {}), + }) +} + +export function delivered(input: Omit, "type" | "timestamp">) { + if (input.rules > 0) send({ type: "rules_delivered", timestamp: Date.now(), ...input }, false) +} + +export function learningChanged(on: boolean | null, learning: boolean, decidedBy: Of<"rules_learning_changed">["decided_by"]) { + send({ + type: "rules_learning_changed", + timestamp: Date.now(), + session_id: "", + choice: on === null ? "workspace" : on ? "on" : "off", + learning, + decided_by: decidedBy, + }) +} + +export function command(name: string) { + send({ type: "rules_command", timestamp: Date.now(), session_id: "", command: name }) +} + +export * as RulesTelemetry from "./telemetry" diff --git a/packages/opencode/src/altimate/rules/trigger.ts b/packages/opencode/src/altimate/rules/trigger.ts index 23b4a23899..874d3d1a80 100644 --- a/packages/opencode/src/altimate/rules/trigger.ts +++ b/packages/opencode/src/altimate/rules/trigger.ts @@ -23,6 +23,7 @@ import { readSignals, type Signal } from "../learn/signals" import { RuleClassify } from "./classify" import { RuleProposal, excerpt, type Draft } from "./proposal" import { RulesStore } from "./store" +import { RulesTelemetry } from "./telemetry" const log = Log.create({ service: "rules.trigger" }) export const RECURRENCE_SESSIONS = 2 @@ -151,7 +152,8 @@ export async function afterTurn(input: { signal.kind === "user_correction" && signal.sessionID === input.sessionID && !processed.has(signal.id)) if (!fresh.length) return // One card at a time per session: an explicit "remember" may already have shown one for this turn. - if (RuleProposal.list().some((card) => card.sessionID === input.sessionID)) return + if (RuleProposal.list().some((card) => card.sessionID === input.sessionID)) + return RulesTelemetry.skipped(input.sessionID, undefined, "card_open") // Marked processed once the check completes; a failed check is retried after the next turn. checked = fresh.map((signal) => signal.id) const newest = fresh[fresh.length - 1] @@ -173,10 +175,10 @@ export async function afterTurn(input: { if (hit) { const drafted = await draft({ deps: input.deps, sessionID: input.sessionID, signals: fresh, state, target: hit.id, note }) // The shown rule already says it: no card, and no second drafting call for recurrence. - if (drafted === "covered") return + if (drafted === "covered") return RulesTelemetry.skipped(input.sessionID, "shown_rule", "covered") if (drafted) { const card = await RuleProposal.ask({ - sessionID: input.sessionID, root: input.root, directory: input.directory, learn: input.learn, + sessionID: input.sessionID, root: input.root, directory: input.directory, learn: input.learn, trigger: "shown_rule", draft: { text: drafted.text, // An edit or retire keeps the rule's scope; a new rule beside it takes the correction's. @@ -200,14 +202,17 @@ export async function afterTurn(input: { t.sessions >= RECURRENCE_SESSIONS && t.signal_ids.some((id) => freshIds.has(id)) && !RulesStore.dismissed(state, t.identifier)) if (!topic) { if (!hit) outcome = "no team rule shown on this subject and no topic corrected in 2+ sessions" - return + const dismissed = recurrence.unmatched_topics.some((t) => + t.sessions >= RECURRENCE_SESSIONS && t.signal_ids.some((id) => freshIds.has(id)) && RulesStore.dismissed(state, t.identifier)) + return RulesTelemetry.skipped(input.sessionID, "recurrence", dismissed ? "topic_dismissed" : "no_topic") } const all = await readSignals(input.root, Playbook.DEFAULT_NAME) const backing = all.filter((signal) => topic.signal_ids.includes(signal.id)) const drafted = await draft({ deps: input.deps, sessionID: input.sessionID, signals: backing, state, addOnly: true, note }) - if (!drafted || drafted === "covered" || drafted.change !== "add") return + if (!drafted || drafted === "covered" || drafted.change !== "add") + return RulesTelemetry.skipped(input.sessionID, "recurrence", drafted === "covered" ? "covered" : "no_draft") const card = await RuleProposal.ask({ - sessionID: input.sessionID, root: input.root, directory: input.directory, learn: input.learn, + sessionID: input.sessionID, root: input.root, directory: input.directory, learn: input.learn, trigger: "recurrence", draft: { text: drafted.text, applies_to: appliesTo(backing.map((signal) => signal.text)), @@ -221,6 +226,7 @@ export async function afterTurn(input: { } catch (error) { checked = [] outcome = `failed: ${error instanceof Error ? error.message : String(error)}` + RulesTelemetry.skipped(input.sessionID, undefined, "failed") log.warn("rule proposal check failed", { error: outcome }) } finally { running.delete(input.sessionID) diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index ff983b90f4..2d42806712 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -585,6 +585,125 @@ export namespace Telemetry { budget: number scopes_used: string[] } + // altimate_change start — workspace rules (learn): the proposed-rule card, its answers, sync, delivery. + // No rule text, quotes, paths or globs: enums, counts and the card's random id. The answering client is + // the envelope's process-level `source` (`cli` for the TUI, `datamates` for the VS Code extension). + | { + /** A proposed-rule card was shown, or shown again for the same wording (`repeat`). */ + type: "rule_proposal_shown" + timestamp: number + session_id: string + card_id: string + /** `remember`: the person asked; `shown_rule`: corrected a team rule shown in the session; + * `recurrence`: the same topic corrected in 2+ sessions. */ + trigger: "remember" | "shown_rule" | "recurrence" + change: "add" | "edit" | "retire" + /** "Save as: Memory · Team rule · Skip" (the wording said neither). */ + choose: boolean + linked: boolean + is_owner: boolean + actions: string[] + weakens_verification: boolean + evidence_sessions: number + from_correction: boolean + applies_to_count: number + chars: number + repeat: boolean + } + | { + /** A card was answered, or closed without an answer (`expired`, `closed`). */ + type: "rule_proposal_answered" + timestamp: number + session_id: string + card_id: string + trigger: "remember" | "shown_rule" | "recurrence" + action: "send" | "approve" | "edit" | "dismiss" | "save_local" | "save_memory" | "expired" | "closed" + /** `ok`, or why the reply did not go through (the card stays open). */ + status: "ok" | "busy" | "not_available" | "invalid_text" | "failed" + /** For `ok` on a closing action: what happened. */ + outcome?: + | "sent" + | "queued" + | "approved" + | "approve_failed" + | "exists" + | "dismissed" + | "saved_memory_project" + | "saved_memory_global" + | "saved_local" + approve_anyway: boolean + /** Wording edits accepted on this card so far. */ + edits: number + ms_since_shown: number + } + | { + /** Corrections were checked after a turn and no card was shown. */ + type: "rule_proposal_skipped" + timestamp: number + session_id: string + /** Absent when the check stopped before choosing a path (a card already open, a failure). */ + trigger?: "shown_rule" | "recurrence" + reason: "card_open" | "covered" | "no_draft" | "topic_dismissed" | "no_topic" | "failed" + } + | { + /** The agent saved something the person asked to remember (`training_save` kind rule, or the memory tool + * with "remember …"): where it went. */ + type: "rule_remember_routed" + timestamp: number + session_id: string + via: "training" | "memory" + outcome: "card" | "choose_card" | "memory" | "invalid" | "learning_off" | "not_linked" + } + | { + /** One rules sync (push queued proposals, pull rules and reviews, report usage). */ + type: "rules_sync" + timestamp: number + session_id: string + trigger: "refresh" | "session_start" | "manual" | "card" | "cli" | "import" + status: "off" | "skipped" | "ok" | "failed" + /** For `off` / `skipped`. */ + reason?: string + duration_ms: number + rules?: number + pending?: number + sent?: number + refused?: number + queued?: number + notices?: number + usage_items?: number + push_failed?: boolean + usage_failed?: boolean + } + | { + /** Rules added to a session's context: at the start, for a request, or for a file the agent touched. */ + type: "rules_delivered" + timestamp: number + session_id: string + tier: "start" | "request" | "file" + rules: number + team_rules: number + chars: number + session_rules: number + budget_tokens: number + } + | { + /** The person's own learning choice for a project (`/rules` learning on / off / follow the workspace). */ + type: "rules_learning_changed" + timestamp: number + session_id: string + choice: "on" | "off" | "workspace" + learning: boolean + /** Who decides learning now. Not `source`: that is the envelope's client attribution. */ + decided_by: "you" | "workspace" | "default" + } + | { + /** A rules command: `/rules` opened in a client, or a `learn` CLI subcommand. */ + type: "rules_command" + timestamp: number + session_id: string + command: string + } + // altimate_change end | { // Sent before connecting, so an attempt that hangs or ends the process is still counted. type: "warehouse_connect_started" diff --git a/packages/opencode/src/cli/cmd/learn.ts b/packages/opencode/src/cli/cmd/learn.ts index 7f12a07db3..793e70055a 100644 --- a/packages/opencode/src/cli/cmd/learn.ts +++ b/packages/opencode/src/cli/cmd/learn.ts @@ -22,6 +22,7 @@ import { applyEdits, modify, parse, type ParseError } from "jsonc-parser" import { cmd } from "./cmd" import { effectCmd, fail } from "../effect-cmd" import { Flag } from "@opencode-ai/core/flag/flag" +import { RulesTelemetry } from "../../altimate/rules/telemetry" import * as Playbook from "../../altimate/learn/playbook" import * as Store from "../../altimate/learn/store" import { MAX_TEXT, summarize, describeApplied, describeRejected } from "../../altimate/learn/curator" @@ -429,7 +430,7 @@ const SyncCommand = effectCmd({ const { Config } = await import("@/config/config") const { RulesSync } = await import("../../altimate/rules/sync") const { Instance } = await import("@/project/instance") - const report = await RulesSync.runFresh(root, Instance.directory, (await Config.get()).learn) + const report = await RulesSync.runFresh(root, Instance.directory, (await Config.get()).learn, "cli") if (args.json) { out(JSON.stringify(RulesSync.publicReport(report), null, 2)) // Same exit status as the text mode: a failed pull is a failed sync. @@ -1125,12 +1126,23 @@ const LEARN_HELP = [ // altimate_change end ].join(EOL) +const LEARN_SUBCOMMANDS = new Set([ + "enable", "disable", "nudge", "status", "sync", "bootstrap", "import-reviews", "reflect", "signals", "signal", + "add", "show", "search", "pin", "unpin", "promote", "rollback", "reject", +]) + export const LearnCommand = cmd({ command: "learn", describe: "learn team conventions from sessions and feedback into a rule store", builder: (yargs: Argv) => yargs .epilog(LEARN_HELP) + // The subcommand's name only (`learn status`, `learn sync`, …), and only a known one: a mistyped + // subcommand is the person's text. + .middleware((argv) => { + const sub = String(argv._[1] ?? "") + RulesTelemetry.command(LEARN_SUBCOMMANDS.has(sub) ? `learn ${sub}` : sub ? "learn other" : "learn") + }) // Bad options get one line, not the whole help screen. .fail((msg, err) => { if (err) throw err diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 82797a854f..a0ee58ab83 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1310,8 +1310,13 @@ export namespace Server { if (refused) return c.json(refused.body, refused.status) const view = c.req.query("view") if (view !== undefined && view !== "active" && view !== "mine") return c.json({ ok: false, error: "view must be active or mine." }, 400) - const { RulesSummary } = await import("../altimate/rules/summary") + const [{ RulesSummary }, { RulesTelemetry }] = await Promise.all([ + import("../altimate/rules/summary"), + import("../altimate/rules/telemetry"), + ]) const root = Instance.worktree !== "/" ? Instance.worktree : Instance.directory + // Only the `/rules` view lists rules (the sidebar reads the summary): one call is one opening. + RulesTelemetry.command("view") return c.json({ ok: true as const, rules: await RulesSummary.rows(root, view ?? "all") }) }) // The person's own "learning from sessions" choice for this project: `on` true/false, or null to follow the @@ -1339,6 +1344,8 @@ export namespace Server { }) await Bus.publish(RuleEvents.Event.Changed, { directory: Instance.directory }).catch(() => {}) const summary = await RulesSummary.summary(root, await RulesService.effectiveLearn((await Config.get()).learn, root, Instance.directory)) + const { RulesTelemetry } = await import("../altimate/rules/telemetry") + RulesTelemetry.learningChanged(on, summary.learning, summary.learning_source) return c.json({ ok: true as const, ...summary, line: RulesSummary.sidebarLine(summary) }) } catch (err) { const error = err instanceof Error ? err.message : String(err) @@ -1356,7 +1363,7 @@ export namespace Server { const learn = await RulesService.effectiveLearn((await Config.get()).learn, root, Instance.directory) const { RulesSync } = await import("../altimate/rules/sync") // Never the captured credential: the report goes to clients that may log or cache it. - return c.json({ ok: true as const, ...RulesSync.publicReport(await RulesService.syncNow(root, Instance.directory, learn)) }) + return c.json({ ok: true as const, ...RulesSync.publicReport(await RulesService.syncNow(root, Instance.directory, learn, "manual")) }) } catch (err) { const error = err instanceof Error ? err.message : String(err) log.error("rules sync failed", { error }) diff --git a/packages/opencode/test/altimate/learn/delivery.test.ts b/packages/opencode/test/altimate/learn/delivery.test.ts index 47b8e7b3d9..10dee3963d 100644 --- a/packages/opencode/test/altimate/learn/delivery.test.ts +++ b/packages/opencode/test/altimate/learn/delivery.test.ts @@ -6,6 +6,7 @@ import path from "node:path" import { Delivery } from "../../../src/altimate/learn/delivery" import { canonical, type Lesson } from "../../../src/altimate/learn/lesson" import * as Store from "../../../src/altimate/learn/store" +import { Telemetry } from "../../../src/altimate/telemetry" let root: string const NAME = "team-playbook" @@ -482,3 +483,24 @@ test("resuming a synthetic continuation restores the section without preparing a expect(resumed.active).toBe(true) expect(await log()).toHaveLength(2) }) + +test("rules added to a session are reported by tier, as counts only; a cached answer adds nothing", async () => { + await approve() + const events: Telemetry.Event[] = [] + const spy = spyOn(Telemetry, "track").mockImplementation((event: Telemetry.Event) => void events.push(event)) + try { + const delivery = new Delivery(root, limits) + await delivery.prepare("session", "first", "billing") + await delivery.prepare("session", "second", "shipping") + await delivery.prepare("session", "second", "shipping") + } finally { + spy.mockRestore() + } + const delivered = events.filter((e): e is Extract => e.type === "rules_delivered") + expect(delivered.map((e) => [e.tier, e.rules, e.team_rules, e.session_rules, e.budget_tokens])).toEqual([ + ["start", 2, 0, 2, 500], + ["request", 1, 0, 3, 500], + ]) + expect(delivered[1].chars).toBe("Use the `shipping` route for parcel tracking.".length) + expect(JSON.stringify(events)).not.toContain("shipping") +}) diff --git a/packages/opencode/test/altimate/rules/rules.test.ts b/packages/opencode/test/altimate/rules/rules.test.ts index 78cd592b38..71f3f6b537 100644 --- a/packages/opencode/test/altimate/rules/rules.test.ts +++ b/packages/opencode/test/altimate/rules/rules.test.ts @@ -1,5 +1,5 @@ // altimate_change - new file -import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" @@ -11,6 +11,8 @@ import { RulesSync } from "../../../src/altimate/rules/sync" import { appliesTo, learningDecision, learningEnabled } from "../../../src/altimate/rules/trigger" import { RulesFromHistory } from "../../../src/altimate/rules/from-history" import { RulesRemember } from "../../../src/altimate/rules/remember" +import { RulesTelemetry } from "../../../src/altimate/rules/telemetry" +import { Telemetry } from "../../../src/altimate/telemetry" import { parse } from "../../../src/altimate/learn/lesson" import * as Store from "../../../src/altimate/learn/store" @@ -621,3 +623,77 @@ describe("remember, whichever save tool the agent picks", () => { expect(RuleClassify.memoryScope("Staging models live in models/staging.")).toBe("project") }) }) + +describe("telemetry", () => { + const draft = { text: "Name boolean columns `is_`.", applies_to: ["models/**"], change: "add" as const, origin: "session" as const, evidence: { sessions: 2, correction: { excerpt: "call it is_active", at: "2026-10-27T00:00:00Z" } }, topics: ["is_active"] } + let events: Telemetry.Event[] = [] + let spy: ReturnType + beforeEach(() => { + events = [] + spy = spyOn(Telemetry, "track").mockImplementation((event: Telemetry.Event) => void events.push(event)) + }) + afterEach(() => spy.mockRestore()) + const of = (type: T) => events.filter((e): e is Extract => e.type === type) + + test("a card's showing and every answer are tracked, without its words", async () => { + const root = await tmp() + const card = (await RuleProposal.ask({ sessionID: "t1", root, directory: root, learn: undefined, draft }))! + expect(of("rule_proposal_shown")).toEqual([expect.objectContaining({ + session_id: "t1", card_id: card.id, trigger: "recurrence", change: "add", linked: false, is_owner: false, + actions: ["save_local", "edit", "dismiss"], evidence_sessions: 2, from_correction: true, applies_to_count: 1, repeat: false, + })]) + await RuleProposal.reply(card.id, { action: "send" }) + await RuleProposal.reply(card.id, { action: "edit", text: "line one\nline two" }) + await RuleProposal.reply(card.id, { action: "edit", text: "Boolean columns start with `is_`." }) + await RuleProposal.reply(card.id, { action: "dismiss" }) + expect(of("rule_proposal_answered").map((e) => [e.action, e.status, e.outcome, e.edits])).toEqual([ + ["send", "not_available", undefined, 0], + ["edit", "invalid_text", undefined, 0], + ["edit", "ok", undefined, 1], + ["dismiss", "ok", "dismissed", 1], + ]) + const sent = JSON.stringify(events) + for (const words of [draft.text, "Boolean columns", "is_active", "models/**"]) expect(sent).not.toContain(words) + }) + + test("asking again is a repeat; a dismissed topic is a skip, not a card", async () => { + const root = await tmp() + await RuleProposal.ask({ sessionID: "t2", root, directory: root, learn: undefined, draft: { ...draft, text: "Prefix views with `v_`." }, trigger: "shown_rule" }) + await RuleProposal.ask({ sessionID: "t2", root, directory: root, learn: undefined, draft: { ...draft, text: "Prefix views with `v_`." } }) + expect(of("rule_proposal_shown").map((e) => [e.trigger, e.repeat])).toEqual([["shown_rule", false], ["shown_rule", true]]) + await RulesStore.update(root, (s) => { s.dismissed.push({ topic: "is_active", until: new Date(Date.now() + 60_000).toISOString() }) }) + expect(await RuleProposal.ask({ sessionID: "t2", root, directory: root, learn: undefined, draft })).toBeUndefined() + expect(of("rule_proposal_skipped")).toEqual([expect.objectContaining({ session_id: "t2", trigger: "recurrence", reason: "topic_dismissed" })]) + }) + + test("a sync reports its trigger and outcome, never the reason text", async () => { + const root = await tmp() + await RulesSync.run(root, root, { enabled: false }, "manual") + expect(of("rules_sync")).toEqual([expect.objectContaining({ trigger: "manual", status: "off", reason: "learning_off" })]) + const ready = { status: "ready" as const, actAs: { url: "http://x", instance: "t", apiKey: "k" }, scope: { apiUrl: "http://x", tenant: "t", account: "a", datamateId: 1, repoRemote: "https://github.com/acme/demo" } } + RulesTelemetry.synced("refresh", { + context: ready, + push: { sent: [{ id: "o1", text: "Secret wording", status: "pending" }, { id: "o2", text: "x", status: "rejected", error: "too long" }], queued: 1 }, + pull: { ok: true, rules: 4, pending: 2, notices: [] }, + usage: { items: 3 }, + }, Date.now()) + expect(of("rules_sync")[1]).toMatchObject({ trigger: "refresh", status: "ok", rules: 4, pending: 2, sent: 1, refused: 1, queued: 1, notices: 0, usage_items: 3, push_failed: false, usage_failed: false }) + expect(JSON.stringify(events)).not.toContain("Secret wording") + }) + + test("\"remember\" with workspaces off, or in an unlinked project, is routed without a card", async () => { + const root = await tmp() + const env = process.env.ALTIMATE_DISABLE_WORKSPACE + const remember = () => RulesRemember.proposeFor({ root, directory: root, learn: { enabled: true }, content: "Use CTEs.", sessionID: "t4", said: "remember: use CTEs" }) + try { + process.env.ALTIMATE_DISABLE_WORKSPACE = "1" + expect(await remember()).toBeUndefined() + process.env.ALTIMATE_DISABLE_WORKSPACE = "0" + expect(await remember()).toBeUndefined() + } finally { + if (env === undefined) delete process.env.ALTIMATE_DISABLE_WORKSPACE + else process.env.ALTIMATE_DISABLE_WORKSPACE = env + } + expect(of("rule_remember_routed").map((e) => [e.via, e.outcome])).toEqual([["training", "learning_off"], ["training", "not_linked"]]) + }) +}) diff --git a/packages/opencode/test/telemetry/telemetry.test.ts b/packages/opencode/test/telemetry/telemetry.test.ts index 8d12f41d45..44de151a15 100644 --- a/packages/opencode/test/telemetry/telemetry.test.ts +++ b/packages/opencode/test/telemetry/telemetry.test.ts @@ -248,11 +248,19 @@ const ALL_EVENT_TYPES: Telemetry.Event["type"][] = [ "sql_pre_validation", "config_env_interpolation", "plan_no_tool_generation", + "rule_proposal_shown", + "rule_proposal_answered", + "rule_proposal_skipped", + "rule_remember_routed", + "rules_sync", + "rules_delivered", + "rules_learning_changed", + "rules_command", ] describe("telemetry.event-types", () => { test("all event types are valid", () => { - expect(ALL_EVENT_TYPES.length).toBe(45) + expect(ALL_EVENT_TYPES.length).toBe(53) }) }) From d9737df978f45e6941ff32ffca17e8079f08fba1 Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 29 Oct 2026 13:17:14 +0530 Subject: [PATCH 12/20] =?UTF-8?q?fix:=20workspace=20rules=20=E2=80=94=20ad?= =?UTF-8?q?dress=20the=20latest=20review=20round?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proposals and sync: - Only a content refusal splits a proposals batch: `repo_not_bound`, `workspace_not_found`, `forbidden`, `auth` and `unsupported` keep the whole outbox in one request; an uncoded 400/422 refuses only items that fail alone - A refused item (a `stale` edit) is not recorded under its target's `public_id`; `target_changed` / `target_not_found` closes the card with a message - A failed "Approve now" that can pass is kept in `pending_approvals` and retried (5 attempts); a final refusal is recorded on the person's proposal - A usage batch refused for good is dropped; usage counts a session only against the rule version it was shown; a stale batch is not saved over a newer report - A pull that skipped unreadable rules does not keep the revision, and says so in `last_error` - `change` looked up by outbox id; one `MAX_GLOB` (140) and `MAX_APPLIES_TO` for the card and history paths Cards and the save tools: - `approve_anyway` is its own reply action; a plain `approve` on a flagged card is refused with 409 `verification_flagged` (the TUI's second Enter sends `approve_anyway`) - An explicit "remember" is always a new rule, never an edit inferred from shared words - Cards are raised only when a client that shows them is attached (`GET /rule-proposal` marks it); otherwise `training_save` and `altimate_memory_write` save as before cards - From the memory tool: only the text the person asked to remember gets a card; updates and saves with `expires`/`citations` are not diverted; the agent's `id` and `tags` travel with the card; a requested rule that is not one line is sent back instead of being saved to memory - "for me" / "in memory" no longer decide memory on their own; the save tools read the learn config as it is on disk - A card moved to another session takes its directory and config, keeps its evidence session; an expiry is announced only on the card's own project - One drafting call per after-turn check TUI, CLI, state: - A dialog opened over a focused card takes its keys for good, so the prompt keeps Enter, `e` and `n` after it closes - `learn enable` / `learn disable` clear an earlier `/rules` learning choice; corrected advice after declining to send history rules - History runs: local edits and removals are not proposed and keep the candidate - The capture gate fails closed and is cleared after capture stops; an unreadable rules state rewrites a stale team mirror; the learn `.gitignore` block keeps its original text (it is matched verbatim) - The session-start cache evicts the oldest session only Docs: which clients show cards and what happens without one; local rules sharing; `approve_anyway`. Co-Authored-By: Claude Opus 5.5 --- docs/docs/configure/learn.md | 8 +- docs/docs/reference/telemetry.md | 4 +- .../opencode/src/altimate/learn/capture.ts | 5 +- packages/opencode/src/altimate/learn/lock.ts | 3 +- .../opencode/src/altimate/rules/classify.ts | 6 +- .../src/altimate/rules/from-history.ts | 34 +++- .../opencode/src/altimate/rules/proposal.ts | 86 +++++++-- .../opencode/src/altimate/rules/remember.ts | 50 ++++- .../opencode/src/altimate/rules/service.ts | 5 +- packages/opencode/src/altimate/rules/store.ts | 15 +- packages/opencode/src/altimate/rules/sync.ts | 146 ++++++++++++-- .../opencode/src/altimate/rules/telemetry.ts | 1 - .../opencode/src/altimate/rules/trigger.ts | 36 ++-- .../opencode/src/altimate/telemetry/index.ts | 10 +- packages/opencode/src/cli/cmd/learn.ts | 19 +- .../opencode/src/memory/tools/memory-write.ts | 10 +- .../src/plugin/tui/altimate/rules.tsx | 18 +- packages/opencode/src/server/server.ts | 6 +- packages/opencode/src/session/prompt.ts | 5 +- .../test/altimate/learn/gitignore.test.ts | 4 +- .../test/altimate/rules/rules.test.ts | 179 +++++++++++++++++- 21 files changed, 543 insertions(+), 107 deletions(-) diff --git a/docs/docs/configure/learn.md b/docs/docs/configure/learn.md index 3e8a92194c..c4b3f16b11 100644 --- a/docs/docs/configure/learn.md +++ b/docs/docs/configure/learn.md @@ -193,9 +193,11 @@ In a project linked to an Altimate workspace (`altimate-code link`), what you te - you correct the agent on the same topic in 2 or more sessions; - you correct a team rule the session was shown (the card proposes changing it, with the current text struck through); -- you ask the agent to remember something as a rule (the card appears as soon as the agent saves it). +- you ask the agent to remember something as a rule (the card appears as soon as the agent saves it, always as a new rule: changing a team rule comes from correcting it). -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. Nothing reaches a teammate until it is approved. A card nobody answers is dropped when the session server stops, and after a day. +Cards are shown by the TUI and the VS Code extension. Where no attached client shows cards (`altimate-code run`, ACP, the Claude Code and Codex plugins), 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. + +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. **Rule or memory.** A personal preference ("I prefer CTEs") or a fact ("our warehouse is Snowflake") is saved to memory without review. When the wording does not say which, the card asks: Memory, Team rule, or Skip. @@ -651,7 +653,7 @@ Full setup, arms, and caveats are in the research notes in the repository under - **Conflict detection sees backticked identifiers.** Two rules that contradict each other in prose, with no shared identifier in backticks, depend on the reflector and the replacement step. - **Reflection quality depends on the model.** In our test a weaker reflector needed the replacement step to reach the same result as a stronger one. You still review every candidate. - **Review import is GitHub only.** GitLab, Bitbucket, and other hosts are not supported. It reads merged PRs, not open ones. -- **Sharing with the team is manual for now.** Rules live in the project's `.altimate-code/learn/` directory. `promote --publish` exports them as a single-file workspace skill. Publishing a name that another checkout already published is refused unless you pass `--replace`, which only updates a skill you own. Per-rule sharing and review in a workspace needs backend changes to workspace memory that are not shipped. You can also commit the rules: learn creates `.altimate-code/learn/.gitignore`, which ignores everything except each store's `approved.json`, and delivery reads a committed `approved.json` in any checkout. +- **Local rules are shared by hand.** In a project linked to a workspace, team rules are proposed and reviewed per rule (see [Workspace rules](#workspace-rules)). Rules in the project's local stores under `.altimate-code/learn/` are not: `promote --publish` exports them as a single-file workspace skill. Publishing a name that another checkout already published is refused unless you pass `--replace`, which only updates a skill you own. You can also commit the rules: learn creates `.altimate-code/learn/.gitignore`, which ignores everything except each store's `approved.json`, and delivery reads a committed `approved.json` in any checkout. - **One rule set per `--name`.** Delivery reads every store in the project that has an `approved.json`. - **Keyword retrieval can over-apply a rule.** A rule scoped to one kind of file can be retrieved for a request elsewhere that uses the same words, and the agent sometimes follows it there. In our benchmark a staging-model money rule was applied to an analysis that had to keep integer cents in 1 of 6 runs, in three of the retrieval arms. Showing the rule's scope did not remove it. Write the scope into the rule's path triggers. - **Benchmarks are on dbt tasks, with small samples.** The results show direction, not exact lift for your repository. diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index 408578f516..fa50944f46 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -75,9 +75,9 @@ We collect the following categories of events: | `review_run` | A dbt/SQL review completed or failed — `invocation` (`cli` for `altimate-code review`, `tool` for the `dbt_pr_review` tool), status, duration, and on success the verdict, the pre-gating verdict, mode, risk tier, and finding counts by severity and by category. No file paths, model or column names, finding titles or bodies, SQL, diff content, or repository/branch/PR names. | | `review_post_outcome` | Whether a review was published to GitHub — `not_requested`, `not_attempted`, `target_unresolved`, `full`, `partial`, or `summary_failed`, plus duration. Emitted on the **CLI path only** — the `dbt_pr_review` tool completes reviews but never publishes, so a `review_run` with `invocation: tool` has no post event and that is not a failure. Within the CLI path there is exactly one per **completed** review: a review that failed emits `review_run: failed` and no post event, so absence there means the review failed rather than that an event was lost. `not_attempted` is publication requested but never reached (a bad `--output` path, a stdout write error). No repository, PR, or comment content. | | `rule_proposal_shown` | A proposed-rule card was shown (or shown again for the same wording, `repeat`) — what raised it (`remember`, `shown_rule`, `recurrence`), the kind of change, whether it is a "Save as" choice, linked/owner, the buttons offered, whether it weakens verification, evidence session count, whether it came from a correction, how many paths it applies to, and its length. The card id is random. No rule text, quotes, paths or globs. | -| `rule_proposal_answered` | A card was answered or closed (`send`, `approve`, `edit`, `dismiss`, `save_local`, `save_memory`, `expired`, `closed`) — `ok` or why it did not go through, what happened (`sent`, `queued`, `approved`, `approve_failed`, `exists`, `dismissed`, `saved_memory_project`, `saved_memory_global`, `saved_local`), whether it was "approve anyway", wording edits so far, and time since the card was shown. No rule text. | +| `rule_proposal_answered` | A card was answered or closed (`send`, `approve`, `approve_anyway`, `edit`, `dismiss`, `save_local`, `save_memory`, `expired`, `closed`) — `ok` or why it did not go through (`busy`, `not_available`, `verification_flagged`, `invalid_text`, `failed`), what happened (`sent`, `queued`, `approved`, `approve_failed`, `approve_retry`, `target_changed`, `exists`, `dismissed`, `saved_memory_project`, `saved_memory_global`, `saved_local`), wording edits so far, and time since the card was shown. No rule text. | | `rule_proposal_skipped` | Corrections were checked after a turn and no card was shown — `card_open`, `covered`, `no_draft`, `topic_dismissed`, `no_topic` or `failed`, and which path was checked. | -| `rule_remember_routed` | Where a "remember …" save went — through `training_save` or the memory tool, to a card, a "Save as" card, memory, refused as not a one-line rule, or skipped because learning/workspaces are off or the project is not linked. No text. | +| `rule_remember_routed` | Where a "remember …" save went — through `training_save` or the memory tool, to a card, a "Save as" card, memory, refused as not a one-line rule, or saved as before cards because learning/workspaces are off, the project is not linked, or no attached client shows cards. No text. | | `rules_sync` | One rules sync — what started it (`refresh`, `session_start`, `manual`, `card`, `cli`, `import`), `ok`, `failed`, `off` or `skipped` with a fixed reason code, duration, and counts of rules, pending reviews, proposals sent, refused and still queued, review notices, and usage items. No rule text, error text, URLs or workspace names. | | `rules_delivered` | Rules added to a session's context — at the start, for a request, or for a file the agent touched — with the number of rules (and how many are team rules), their total length, the session's running total, and the token budget. No rule text or file paths. | | `rules_learning_changed` | The person turned learning from sessions on or off for a project, or back to the workspace's setting, with the resulting state and who decides it. | diff --git a/packages/opencode/src/altimate/learn/capture.ts b/packages/opencode/src/altimate/learn/capture.ts index 5c36228bbe..28ead1a046 100644 --- a/packages/opencode/src/altimate/learn/capture.ts +++ b/packages/opencode/src/altimate/learn/capture.ts @@ -301,7 +301,10 @@ export async function startCapture( const { SessionID } = await import("../../session/schema") const capture = new Capture({ record: async (signal) => { - if ((await gates.get(ctx.directory)?.().catch(() => true)) === false) return + // Fails closed: a gate that cannot decide (unreadable rules state) must not record a correction the person + // may have turned off. + const gate = gates.get(ctx.directory) + if (gate && !(await gate().catch(() => false))) return return appendSignal(root, signal) }, turnStart: async (sessionID, messageID) => { diff --git a/packages/opencode/src/altimate/learn/lock.ts b/packages/opencode/src/altimate/learn/lock.ts index 81dff7930a..506837ba8c 100644 --- a/packages/opencode/src/altimate/learn/lock.ts +++ b/packages/opencode/src/altimate/learn/lock.ts @@ -61,7 +61,8 @@ export async function withLearnLock(root: string, task: () => Promise, opt // Every learn writer enters here after Flock creates the directory. Share approved // lessons with the team while keeping operational state local; preserve user edits. const file = path.join(dir, ".gitignore") - const rules = "# Share approved rules; keep signals and other local learning state out of Git.\n*\n!/*/\n!/*/approved.json\n!/.gitignore\n" + // The text, comment included, is how an installed block is recognised: changing it would append it again. + const rules = "# Share approved lessons; keep signals and other local learning state out of Git.\n*\n!/*/\n!/*/approved.json\n!/.gitignore\n" await assertLearnLock(key) await SafeFS.open(key, file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL).then(async (handle) => { try { await handle.writeFile(rules) } finally { await handle.close() } diff --git a/packages/opencode/src/altimate/rules/classify.ts b/packages/opencode/src/altimate/rules/classify.ts index 5dea35c178..510868ca0c 100644 --- a/packages/opencode/src/altimate/rules/classify.ts +++ b/packages/opencode/src/altimate/rules/classify.ts @@ -15,7 +15,9 @@ export type Kind = "rule" | "memory" | "ambiguous" // The person named the kind: these decide on their own, whatever else the text says. const ASKED_RULE = /\bas\s+a\s+(?:team\s+)?rule\b/i -const ASKED_MEMORY = /\b(?:just\s+)?for\s+me\b|\bas\s+a\s+memory\b|\bin\s+(?:my\s+)?memory\b/i +// Only the named forms: a bare "for me" opens ordinary requests ("fix this model for me"), and "in memory" describes +// data ("never load the fact table in memory"). +const ASKED_MEMORY = /\bas\s+a\s+memory\b|\bin\s+my\s+memory\b|\bremember\b[^.!?\n]*\b(?:just\s+)?for\s+me\b/i // "I want …" / "I like …" open many ordinary requests ("I want staging models in models/staging"): on its own // that is not enough to file a team convention as a personal memory, so it only makes the text ambiguous. @@ -23,7 +25,7 @@ const WEAK_MEMORY = /\bi\s+(?:like|want)\b/i const MEMORY_CUES: readonly [RegExp, string][] = [ [/\bi\s+(?:prefer|usually|tend to)\b/i, "a personal preference"], - [/\bfor\s+me\b|\bmy\s+(?:preference|style|default|setup|machine|laptop)\b/i, "a personal preference"], + [/\bmy\s+(?:preference|style|default|setup|machine|laptop)\b/i, "a personal preference"], [/\bremember\s+(?:that|our|the)\b(?!.*\b(?:always|never|must)\b)/i, "a fact to remember"], [/\b(?:our|the)\s+(?:warehouse|database|account|cluster|stack|region|team|company|org)\s+(?:is|uses|runs|lives)\b/i, "a fact about the environment"], [/\b[A-Z]{2,}\s+(?:means|stands for|is short for)\b|\bmeans\b.*\b(?:revenue|users?|customers?|metric)\b/, "a glossary term"], diff --git a/packages/opencode/src/altimate/rules/from-history.ts b/packages/opencode/src/altimate/rules/from-history.ts index bb59da7d6b..4494243695 100644 --- a/packages/opencode/src/altimate/rules/from-history.ts +++ b/packages/opencode/src/altimate/rules/from-history.ts @@ -11,10 +11,7 @@ import * as Store from "../learn/store" import type { ProposalItem } from "./api" import { RulesStore } from "./store" import { RulesSync } from "./sync" - -/** Path globs per proposal, and their length: the same bounds the card applies. */ -const MAX_APPLIES_TO = 4 -const MAX_GLOB = 200 +import { MAX_APPLIES_TO, MAX_GLOB } from "./trigger" /** The candidate's rule texts before a run, or undefined when there is no candidate. */ export async function candidateTexts(root: string, name: string): Promise | undefined> { @@ -28,6 +25,8 @@ export interface Staged { items: ProposalItem[] /** Staged rules that failed the rule lint and stay local only. */ invalid: { text: string; reason: string }[] + /** Local rules the run edited or removed: not team proposals, and a reason to keep the candidate. */ + localChanges: number } /** What this run staged that is not approved yet, as proposals. Undefined when not linked or nothing is new. */ @@ -46,10 +45,16 @@ export async function stagedRules(input: { ]) if (!candidate) return undefined const known = new Set([...approved.map((lesson) => lesson.text), ...(input.before ?? [])]) + // The candidate starts as a copy of the approved store: a lesson with an approved id is a local rule (edited if its + // text changed), and an approved id missing from it is a removal. Neither is a new team rule. + const approvedIds = new Set(approved.map((lesson) => lesson.id)) + const candidateIds = new Set(candidate.map((lesson) => lesson.id)) + const edited = candidate.filter((lesson) => approvedIds.has(lesson.id) && !known.has(lesson.text)).length + const removed = approved.filter((lesson) => !candidateIds.has(lesson.id)).length const items: ProposalItem[] = [] const invalid: Staged["invalid"] = [] for (const lesson of candidate) { - if (known.has(lesson.text)) continue + if (known.has(lesson.text) || approvedIds.has(lesson.id)) continue const reason = lint(lesson.text) if (reason) { invalid.push({ text: lesson.text, reason }) @@ -66,7 +71,12 @@ export async function stagedRules(input: { }) } if (!items.length && !invalid.length) return undefined - return { workspace: { name: state.workspace.name, owner: state.workspace.owner_name ?? "the workspace owner" }, items, invalid } + return { + workspace: { name: state.workspace.name, owner: state.workspace.owner_name ?? "the workspace owner" }, + items, + invalid, + localChanges: edited + removed, + } } /** Queue the confirmed proposals and send them now. */ @@ -86,14 +96,20 @@ export async function sendStaged(input: { const queued = outcomes.filter((o) => o.status === "queued").length const refused = outcomes.filter((o) => o.status === "sent" && o.row?.error).length const sent = outcomes.length - queued - refused - // The workspace's review queue replaces a candidate this run created, once it holds every rule of it. - const discard = !input.before && queued === 0 && refused === 0 && input.staged.invalid.length === 0 + // The workspace's review queue replaces a candidate this run created, once it holds every rule of it: only when the + // candidate is nothing but the adds that were sent (no local edits or removals, nothing invalid, refused or queued). + const discard = !input.before && queued === 0 && refused === 0 && input.staged.invalid.length === 0 && + input.staged.localChanges === 0 if (discard) await Store.reject(input.root, input.name) const { owner, name } = input.staged.workspace const message = `${sent} ${sent === 1 ? "rule" : "rules"} sent for ${owner}'s review in ${name}` + (queued ? `; ${queued} queued until the workspace is reachable` : "") + (refused ? `; ${refused} refused by the workspace` : "") + "." + - (discard ? "" : " The local candidate was kept: `learn show`.") + (discard + ? "" + : input.staged.localChanges + ? ` The local candidate was kept: it also changes ${input.staged.localChanges} local ${input.staged.localChanges === 1 ? "rule" : "rules"} (\`learn show\`, \`learn promote\`).` + : " The local candidate was kept: `learn show`.") return { sent, queued, refused, message } } diff --git a/packages/opencode/src/altimate/rules/proposal.ts b/packages/opencode/src/altimate/rules/proposal.ts index 9916564b69..badee8dfe7 100644 --- a/packages/opencode/src/altimate/rules/proposal.ts +++ b/packages/opencode/src/altimate/rules/proposal.ts @@ -33,6 +33,8 @@ export interface Draft { topics: string[] /** Offer "Save as: Memory · Team rule · Skip": the wording did not say which. */ choose?: boolean + /** The memory block the agent meant to write (memory tool): Memory saves it under that id and those tags. */ + memory?: { id: string; tags: string[] } } interface Pending { @@ -47,25 +49,51 @@ interface Pending { /** What raised the card, and wording edits accepted so far (telemetry). */ trigger: CardTrigger edits: number + /** The session the evidence came from: the card can move to another session, its evidence does not. */ + sourceSession: string + memory?: Draft["memory"] } /** An unanswered card lasts a day; after that it is dropped rather than re-offered to every new client. */ export const CARD_TTL_MS = 24 * 60 * 60_000 /** Remove a card no client can answer any more, and tell clients so they stop showing it. */ -function close(id: string, entry: Pending, message: string, why: "expired" | "closed") { +function close(id: string, entry: Pending, message: string, why: "expired" | "closed", here: string | undefined) { pending.delete(id) RulesTelemetry.answered({ card: entry.card, trigger: entry.trigger, action: why, status: "ok", edits: entry.edits, shownAt: entry.at }) - void publish(RuleEvents.Event.Replied, { id, sessionID: entry.card.sessionID, action: "expired", message }) + // The Bus is per project: announced from another project's request it would reach the wrong clients. The card's own + // clients then learn it is gone from their next list or reply (`not_open`). + if (here === entry.directory) + void publish(RuleEvents.Event.Replied, { id, sessionID: entry.card.sessionID, action: "expired", message }) } -function expire(now = Date.now()) { - for (const [id, entry] of pending) if (now - entry.at > CARD_TTL_MS) close(id, entry, "This proposal expired.", "expired") +// A client that renders cards (the TUI, the IDE extension) lists them when it attaches: `GET /rule-proposal`. Until +// one has, nothing would show a card, so the save tools save as they did before cards (`rendererAttached`). +const RENDERER = Symbol.for("altimate.rules.proposal.renderer") +const renderer = ((globalThis as Record)[RENDERER] ??= { attached: false }) as { attached: boolean } + +/** A client that shows proposal cards is attached to this process. */ +export function rendererAttached() { + return renderer.attached +} + +/** Called by `GET /rule-proposal`: the caller renders cards. */ +export function markRenderer() { + renderer.attached = true +} + +export function setRendererForTest(attached: boolean) { + renderer.attached = attached +} + +/** Drop cards past `CARD_TTL_MS`. `here`: the directory of the request doing it (its project's clients are told). */ +function expire(here: string | undefined, now = Date.now()) { + for (const [id, entry] of pending) if (now - entry.at > CARD_TTL_MS) close(id, entry, "This proposal expired.", "expired", here) } /** Drop the cards of a project (its instance is going away; nobody can answer them after that). */ export function drop(directory: string) { - for (const [id, entry] of pending) if (entry.directory === directory) close(id, entry, "This proposal was closed.", "closed") + for (const [id, entry] of pending) if (entry.directory === directory) close(id, entry, "This proposal was closed.", "closed", directory) } // Process-global like `question`'s registry: the server routes and the session loop may load this module @@ -121,7 +149,7 @@ export async function ask(input: { draft: Draft trigger?: CardTrigger }) { - expire() + expire(input.directory) const trigger = input.trigger ?? (input.draft.origin === "explicit" ? "remember" : "recurrence") const state = await RulesStore.read(input.root) if (input.draft.origin === "session" && input.draft.topics.some((topic) => RulesStore.dismissed(state, topic))) { @@ -133,14 +161,17 @@ export async function ask(input: { // Asked again: the card moves to the session that asked (clients show cards per session), and a client that // missed it gets it back. if (existing.card.sessionID !== input.sessionID) { + // The asking session's project directory and config answer it from now on; the evidence keeps its session. existing.card = { ...existing.card, sessionID: input.sessionID } + existing.directory = input.directory + existing.learn = input.learn await publish(RuleEvents.Event.Updated, existing.card) } else await publish(RuleEvents.Event.Asked, existing.card) RulesTelemetry.shown(existing.card, existing.trigger, true) return existing.card } const card = build(randomUUID(), input.sessionID, input.draft, state) - pending.set(card.id, { card, root: input.root, directory: input.directory, learn: input.learn, at: Date.now(), trigger, edits: 0 }) + pending.set(card.id, { card, root: input.root, directory: input.directory, learn: input.learn, at: Date.now(), trigger, edits: 0, sourceSession: input.sessionID, memory: input.draft.memory }) RulesTelemetry.shown(card, trigger, false) await publish(RuleEvents.Event.Asked, card) return card @@ -148,16 +179,19 @@ export async function ask(input: { /** Open cards, optionally of one project and one session. */ export function list(directory?: string, sessionID?: string): ProposalCard[] { - expire() + expire(directory) return [...pending.values()] .filter((p) => (!directory || p.directory === directory) && (!sessionID || p.card.sessionID === sessionID)) .map((p) => p.card) } -export type ReplyAction = ProposalCard["actions"][number] +/** A card's actions, plus `approve_anyway`: the confirmed approval of a card that weakens verification. A plain + * `approve` on such a card is refused (`verification_flagged`), so every client has to ask for the confirmation. */ +export type ReplyAction = ProposalCard["actions"][number] | "approve_anyway" /** Why a reply did not go through, for clients and HTTP status: the card is gone, the request does not fit this - * card (or another reply is running), the new wording failed the lint, or the action itself failed. */ -export type ReplyFailure = "not_open" | "busy" | "not_available" | "invalid_text" | "failed" + * card (or another reply is running), a flagged card was approved without confirming, the new wording failed the + * lint, or the action itself failed. */ +export type ReplyFailure = "not_open" | "busy" | "not_available" | "verification_flagged" | "invalid_text" | "failed" export type ReplyResult = | { ok: true; message: string; card?: ProposalCard; closed: boolean } | { ok: false; code: ReplyFailure; error: string; card?: ProposalCard } @@ -192,10 +226,22 @@ export async function reply(id: string, input: { action: ReplyAction; text?: str return { ok: false, code: "busy", error: "This proposal is already being answered.", card: entry.card } } const { card } = entry - if (!card.actions.includes(input.action)) { + const offered = input.action === "approve_anyway" + ? card.actions.includes("approve") && !!card.weakens_verification + : card.actions.includes(input.action) + if (!offered) { track("not_available") return { ok: false, code: "not_available", error: `"${input.action}" is not available for this proposal.`, card } } + if (input.action === "approve" && card.weakens_verification) { + track("verification_flagged") + return { + ok: false, + code: "verification_flagged", + error: `This rule ${card.weakens_verification}: confirm with "approve_anyway" to approve it for the whole workspace.`, + card, + } + } if (input.action === "edit") { const text = (input.text ?? "").trim() @@ -252,9 +298,10 @@ async function act(entry: Pending, action: Exclude): Promis } else if (action === "save_memory") { const [{ MemoryStore }, { RuleClassify }] = await Promise.all([import("../../memory/store"), import("./classify")]) const scope = RuleClassify.memoryScope(card.text) - const id = `notes/${slug(card.text)}` + const id = entry.memory?.id ?? `notes/${slug(card.text)}` + const tags = entry.memory?.tags.length ? entry.memory.tags : ["remembered"] const now = new Date().toISOString() - await MemoryStore.write({ id, scope, tags: ["remembered"], created: now, updated: now, content: card.text }) + await MemoryStore.write({ id, scope, tags, created: now, updated: now, content: card.text }) message = scope === "global" ? "Saved to your memory (all your projects)." : "Saved to this project's memory." outcome = scope === "global" ? "saved_memory_global" : "saved_memory_project" } else if (action === "save_local") { @@ -263,7 +310,7 @@ async function act(entry: Pending, action: Exclude): Promis message = "Saved as a rule for this project." outcome = "saved_local" } else { - const approve = action === "approve" ? (card.weakens_verification ? "approve_anyway" : "approve") : undefined + const approve = action === "approve" || action === "approve_anyway" ? action : undefined // The workspace decides whether the person's words travel with a proposal: without quotes, `why` (which can // paraphrase the correction) is replaced by a neutral line too. const quotes = (await RulesStore.read(entry.root)).settings?.include_quotes !== false @@ -277,7 +324,7 @@ async function act(entry: Pending, action: Exclude): Promis ...(card.replaces ? { replaces: card.replaces.public_id, replaces_version: card.replaces.version } : {}), evidence_sessions: card.evidence.sessions, corrections: card.evidence.correction && quotes - ? [{ excerpt: card.evidence.correction.excerpt, corrected_at: card.evidence.correction.at, session_ref: hashRef(card.sessionID) }] + ? [{ excerpt: card.evidence.correction.excerpt, corrected_at: card.evidence.correction.at, session_ref: hashRef(entry.sourceSession) }] : [], }, approve) const report = await RulesSync.runFresh(entry.root, entry.directory, entry.learn, "card").catch(() => undefined) @@ -287,6 +334,10 @@ async function act(entry: Pending, action: Exclude): Promis if (sent.status === "queued") { message = `Queued: it will be sent to ${owner}'s review queue when the workspace is reachable.` outcome = "queued" + } else if (sent.row?.target_gone) { + // The rule this card edits or retires changed or was removed since it was shown: resending cannot succeed. + message = "Not sent: the team rule this changes was edited or removed since. Check `/rules` and propose it again if it still applies." + outcome = "target_changed" } else if (sent.row?.error) { // Refused (e.g. an invalid wording): the card stays open so the wording can be edited and sent again. throw new Error(`not sent: ${sent.row.error}`) @@ -296,6 +347,9 @@ async function act(entry: Pending, action: Exclude): Promis } else if (sent.row?.approved || sent.sent?.status === "active") { 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.` + 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.` outcome = "approve_failed" diff --git a/packages/opencode/src/altimate/rules/remember.ts b/packages/opencode/src/altimate/rules/remember.ts index 5eafcfeca6..5ad96ae3b8 100644 --- a/packages/opencode/src/altimate/rules/remember.ts +++ b/packages/opencode/src/altimate/rules/remember.ts @@ -6,6 +6,16 @@ /** The person asked for something to be remembered (not the agent deciding to take a note). */ const ASKED_TO_REMEMBER = /\b(?:remember|don'?t forget|do not forget|keep in mind|note (?:that|this)|save (?:this|that)|from now on)\b/i +const contentWords = (text: string) => new Set(text.toLowerCase().match(/[a-z0-9_]{3,}/g) ?? []) + +/** Does `content` restate what the person said? Most of its words appear in their message. */ +export function restates(content: string, said: string): boolean { + const words = [...contentWords(content)] + if (!words.length) return false + const spoken = contentWords(said) + return words.filter((word) => spoken.has(word)).length / words.length >= 0.6 +} + /** The text of the person's latest message in the session. */ export function lastUserText(messages: unknown): string | undefined { if (!Array.isArray(messages)) return undefined @@ -25,10 +35,22 @@ export function lastUserText(messages: unknown): string | undefined { * and only when it does not read as a memory; then the card (team rule, or "Memory · Team rule · Skip" when unclear) * replaces the memory write, so the person decides, not the agent's tool choice. */ -export async function proposeTeamRule(content: string, sessionID: string, said?: string, via: "training" | "memory" = "training") { - const [{ Instance }, { Config }] = await Promise.all([import("../../project/instance"), import("@/config/config")]) +export async function proposeTeamRule( + content: string, + sessionID: string, + said?: string, + via: "training" | "memory" = "training", + memory?: { id: string; tags: string[] }, +) { + const [{ Instance }, { Config }, { RulesService }] = await Promise.all([ + import("../../project/instance"), + import("@/config/config"), + import("./service"), + ]) const root = Instance.worktree !== "/" ? Instance.worktree : Instance.directory - return proposeFor({ root, directory: Instance.directory, learn: (await Config.get()).learn, content, sessionID, said, via }) + // The project's learn file as it is now, like every other entry point: `learn disable` mid-session takes effect. + const learn = await RulesService.effectiveLearn((await Config.get()).learn, root, Instance.directory) + return proposeFor({ root, directory: Instance.directory, learn, content, sessionID, said, via, memory }) } /** `proposeTeamRule` for an explicit project (`root`, `directory`) and learn config. */ @@ -40,10 +62,14 @@ export async function proposeFor(input: { sessionID: string said?: string via?: "training" | "memory" + /** From the memory tool: the block it meant to write, used if the person picks Memory on the card. */ + memory?: { id: string; tags: string[] } }) { const { root, learn, content, sessionID, said } = input const via = input.via ?? "training" - if (via === "memory" && !(said && ASKED_TO_REMEMBER.test(said))) return undefined + // From the memory tool, only the thing the person asked to remember gets a card: their message decides rule or + // memory for that text, not for an unrelated note the agent saves in the same turn. + if (via === "memory" && !(said && ASKED_TO_REMEMBER.test(said) && restates(content, said))) return undefined const [{ RulesStore }, { RuleTrigger }, { RulesSync }, { RulesTelemetry }] = await Promise.all([ import("./store"), import("./trigger"), @@ -54,15 +80,21 @@ export async function proposeFor(input: { if (!RulesSync.rulesEnabled(learn)) return void RulesTelemetry.routed(sessionID, via, "learning_off") const state = await RulesStore.read(root) if (!state.scope || !state.workspace) return void RulesTelemetry.routed(sessionID, via, "not_linked") - const result = await RuleTrigger.explicit({ sessionID, root, directory: input.directory, learn, text: content, said }) + // Nothing attached to this process shows cards (`altimate-code run`, ACP, a plugin): a card would be lost, so the + // tool saves as it did before cards (memory, or project training). + const { RuleProposal } = await import("./proposal") + if (!RuleProposal.rendererAttached()) return void RulesTelemetry.routed(sessionID, via, "no_renderer") + const result = await RuleTrigger.explicit({ sessionID, root, directory: input.directory, learn, text: content, said, memory: input.memory }) RulesTelemetry.routed( sessionID, via, result.status === "shown" ? (result.choose ? "choose_card" : "card") : result.status, ) const metadata = { action: "proposed" as string, kind: "rule", name: "", scope: "project" } - // From the memory tool, a memory (or a text that can't be a one-line rule) is simply saved as asked. - if (via === "memory" && (result.status === "memory" || result.status === "invalid")) return undefined + // From the memory tool, a memory is simply saved as asked; so is a long note that did not ask to be a rule. A + // requested rule that is not one line is sent back to be restated: saved as project memory, it would reach + // teammates through the workspace's memory without review. + if (via === "memory" && (result.status === "memory" || (result.status === "invalid" && result.kind !== "rule"))) return undefined if (result.status === "memory") return { title: "Not a team rule", @@ -73,11 +105,11 @@ export async function proposeFor(input: { return { title: "Team rule: not proposed", metadata: { ...metadata, action: "error" }, - output: `Not proposed: a team rule must be one line of at most 140 characters with no commands, links or secrets (${result.reason}). Restate it as one short line and call training_save again.`, + output: `Not proposed: a team rule must be one line of at most 140 characters with no commands, links or secrets (${result.reason}). Restate it as one short line and save it again.`, } const owner = state.workspace.is_owner ? "you (the workspace owner)" : (state.workspace.owner_name ?? "the workspace owner") return { - title: `Team rule proposed: "${content.slice(0, 60)}"`, + title: result.choose ? `Team rule or memory: "${content.slice(0, 60)}"` : `Team rule proposed: "${content.slice(0, 60)}"`, metadata, output: [ result.choose diff --git a/packages/opencode/src/altimate/rules/service.ts b/packages/opencode/src/altimate/rules/service.ts index 7fdcaedf56..19fc708b4c 100644 --- a/packages/opencode/src/altimate/rules/service.ts +++ b/packages/opencode/src/altimate/rules/service.ts @@ -176,13 +176,14 @@ export async function start(ctx: InstanceContext): Promise { clearInterval(timer) stop() unregister() - if (gated) (await import("../learn/capture")).setCaptureGate(ctx.directory, undefined) // Cards nobody answered go with the instance: they cannot be answered after it. const { RuleProposal } = await import("./proposal") RuleProposal.drop(ctx.directory) - // The capture's own disposer stops it too; this covers a stop handle taken before disposal. + // The capture's own disposer stops it too; this covers a stop handle taken before disposal. The gate goes last: + // signals the stop flushes are still checked against the learning decision. await stopCapture?.() stopCapture = undefined + if (gated) (await import("../learn/capture")).setCaptureGate(ctx.directory, undefined) }) log.info("rules service started", { root }) } catch (error) { diff --git a/packages/opencode/src/altimate/rules/store.ts b/packages/opencode/src/altimate/rules/store.ts index 66d1ed2ca6..3187da5acb 100644 --- a/packages/opencode/src/altimate/rules/store.ts +++ b/packages/opencode/src/altimate/rules/store.ts @@ -115,6 +115,13 @@ export const State = z.object({ /** The person's own "learning from sessions" choice for this project (`/rules`), when they made one. Kept here, * not in the project config, because that file is usually committed and the choice is per person. */ personal_learning: z.boolean().optional(), + /** "Approve now" calls that failed for a reason that can pass (network, 5xx, credential): retried on the next push. */ + pending_approvals: z.array(z.object({ + public_id: z.string(), + version: z.number().int(), + action: z.enum(["approve", "approve_anyway"]), + attempts: z.number().int().default(0), + })).default([]), /** A usage report sent but not yet acknowledged: resent with the same `batch_id` so the server counts it once. */ pending_usage: z.object({ batch_id: z.string(), @@ -200,10 +207,12 @@ export async function update(root: string, change: (state: State) => State | voi await SafeFS.mkdir(root, dir(root)) const raw = await readRaw(root) let current = parse(raw) + let recovered = false if (!current) { // Written by another version or damaged: keep it for inspection rather than overwrite it. await Store.writeAtomic(root, `${stateFile(root)}.unreadable-${Date.now()}`, raw!, 0o600) current = structuredClone(EMPTY) + recovered = true } const rulesBefore = JSON.stringify(current.rules) const next = change(current) ?? current @@ -215,8 +224,10 @@ export async function update(root: string, change: (state: State) => State | voi next.dismissed = next.dismissed.filter((d) => Date.parse(d.until) > now) await Store.writeAtomic(root, stateFile(root), JSON.stringify(next, null, 2) + "\n") // Only a linked project (or one whose rules just changed, e.g. a reset) has a mirror to write: a project with no - // workspace must not have its `team` directory touched. - if (next.workspace || JSON.stringify(next.rules) !== rulesBefore) { + // workspace must not have its `team` directory touched. After an unreadable state was set aside, an existing + // mirror no longer matches any state: rewrite it, so rules nothing vouches for stop being delivered. + const staleMirror = recovered && (await fs.access(Store.paths(root, TEAM_STORE).approved).then(() => true, () => false)) + if (next.workspace || JSON.stringify(next.rules) !== rulesBefore || staleMirror) { await ensureIgnore(root) await writeMirror(root, next.rules) } diff --git a/packages/opencode/src/altimate/rules/sync.ts b/packages/opencode/src/altimate/rules/sync.ts index 9b82cebfae..6ac3b4c433 100644 --- a/packages/opencode/src/altimate/rules/sync.ts +++ b/packages/opencode/src/altimate/rules/sync.ts @@ -104,11 +104,15 @@ export async function pull(root: string, ctx: Ready): Promise { state.scope = ctx.scope state.workspace = { name: response.workspace_name, owner_name: response.owner_name ?? null, is_owner: response.is_owner } state.settings = response.settings - state.revision = response.revision + // 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). + state.revision = response.skipped ? undefined : response.revision state.cursor = response.cursor state.pending_count = response.pending_count state.pulled_at = new Date().toISOString() - state.last_error = undefined + state.last_error = response.skipped + ? `${response.skipped} team ${response.skipped === 1 ? "rule" : "rules"} could not be read by this version of altimate-code; update it to get them` + : undefined if (!response.unchanged) state.rules = response.rules for (const reviewed of response.my_reviewed) { if (state.notices.some((n) => n.public_id === reviewed.public_id && n.status === reviewed.status)) continue @@ -151,6 +155,12 @@ export interface PushResult { approved?: boolean /** Stored for review, but "Approve now" failed (e.g. verification_flagged). */ approve_error?: string + /** "Approve now" failed for a passing reason: it is retried on the next push. */ + approve_retry?: boolean + /** The server's code for a refused item. */ + error_code?: string + /** Refused because the rule an edit or retire was against changed or is gone: resending cannot succeed. */ + target_gone?: boolean }[] queued: number error?: string @@ -171,19 +181,39 @@ async function send(ctx: Ready, entries: OutboxEntry[]): Promise { return entries.map((entry, index) => ({ entry, result: byIndex.get(index) })) } catch (error) { const failure = error instanceof RulesApiError ? error : RulesApi.classify(error) - // A 400/422 is about the request's content even without a code (e.g. a schema validation array): split a batch - // to find the item, and refuse a single item rather than resend it forever. - const content = failure.kind === "rejected" || failure.status === 400 || failure.status === 422 - if (content && entries.length > 1) { + // Only a refusal of the content splits the batch. Scope and credential failures (`repo_not_bound`, + // `workspace_not_found`, `forbidden`, `auth`, `unsupported`) are about the request: every item stays queued. + if (entries.length > 1 && (failure.kind === "rejected" || uncodedContent(failure))) { const outcomes: Outcome[] = [] for (const entry of entries) outcomes.push(...(await send(ctx, [entry]))) - return outcomes + if (failure.kind === "rejected") return outcomes + // An uncoded 400/422 (e.g. a schema validation array) can be one item or the whole request: items that fail + // alone, while others go through, are refused; when every item fails alike, it is the request and all stay queued. + const alone = outcomes.filter((outcome) => outcome.error && uncodedContent(outcome.error)) + if (alone.length === outcomes.length) return entries.map((entry) => ({ entry, error: failure })) + return outcomes.map((outcome) => + outcome.error && uncodedContent(outcome.error) + ? { entry: outcome.entry, error: new RulesApiError("rejected", outcome.error.message, outcome.error.code, outcome.error.status) } + : outcome) } - if (content && failure.kind !== "rejected") return entries.map((entry) => ({ entry, error: new RulesApiError("rejected", failure.message, failure.code, failure.status) })) return entries.map((entry) => ({ entry, error: failure })) } } +/** A 400/422 without a code, which `classify` keeps `transient`: maybe about one item, maybe the whole request. */ +const uncodedContent = (failure: RulesApiError) => + failure.kind === "transient" && !failure.code && (failure.status === 400 || failure.status === 422) + +/** Per-item answers that leave nothing stored for the person: the rule an edit was against changed or went away. */ +const TARGET_GONE = new Set(["target_changed", "target_not_found"]) + +/** A failed "Approve now" worth retrying: the moment or the credential, not a decision about the rule. */ +const retryableApproval = (error: unknown) => { + const kind = error instanceof RulesApiError ? error.kind : RulesApi.classify(error).kind + return kind === "transient" || kind === "auth" +} +const MAX_APPROVAL_ATTEMPTS = 5 + /** Per-item answers that are about now, not about the proposal: it stays queued and is sent again later. */ const RETRY_LATER = new Set(["too_many_pending"]) @@ -194,10 +224,12 @@ const RETRY_LATER = new Set(["too_many_pending"]) export async function push(root: string, ctx: Ready): Promise { const state = await RulesStore.read(root) const batch = state.outbox.slice(0, MAX_PROPOSALS) - if (!batch.length) return { sent: [], queued: 0 } + const retried = await retryApprovals(root, ctx, state.pending_approvals) + if (!batch.length) return { sent: [], queued: 0, ...(retried ? { error: retried } : {}) } const outcomes = await send(ctx, batch) const sent: PushResult["sent"] = [] const approvals: { entry: OutboxEntry; publicId: string; version: number }[] = [] + const pendingApprovals: RulesStore.State["pending_approvals"] = [] const done = new Set() const failed = new Map() for (const { entry, result, error } of outcomes) { @@ -222,8 +254,11 @@ export async function push(root: string, ctx: Ready): Promise { id: entry.id, text: entry.item.text, status: result.status, - ...(result.public_id ? { public_id: result.public_id } : {}), + // A refused item's `public_id` is not the person's proposal (a `stale` edit carries its target's). + ...(ok && result.public_id ? { public_id: result.public_id } : {}), ...(!ok ? { error: result.error_detail ?? result.error_code ?? result.status } : {}), + ...(!ok && result.error_code ? { error_code: result.error_code } : {}), + ...(!ok && result.error_code && TARGET_GONE.has(result.error_code) ? { target_gone: true } : {}), }) if (entry.approve && result.status === "pending" && result.public_id && result.version) approvals.push({ entry, publicId: result.public_id, version: result.version }) @@ -239,11 +274,17 @@ export async function push(root: string, ctx: Ready): Promise { } catch (error) { const row = sent.find((s) => s.public_id === approval.publicId) if (row) row.approve_error = errorText(error) + if (retryableApproval(error)) { + if (row) row.approve_retry = true + pendingApprovals.push({ public_id: approval.publicId, version: approval.version, action: approval.entry.approve!, attempts: 1 }) + } } } const now = new Date().toISOString() const after = await RulesStore.update(root, (s) => { s.outbox = s.outbox.filter((entry) => !done.has(entry.id)) + for (const approval of pendingApprovals) + if (!s.pending_approvals.some((p) => p.public_id === approval.public_id)) s.pending_approvals.push(approval) for (const entry of s.outbox) { const error = failed.get(entry.id) if (error === undefined) continue @@ -253,16 +294,56 @@ export async function push(root: string, ctx: Ready): Promise { for (const row of sent) { if (!row.public_id || row.status === "exists") continue const existing = s.sent.find((x) => x.public_id === row.public_id) - const change = batch.find((b) => b.item.text === row.text)?.item.change ?? "add" + const change = batch.find((b) => b.id === row.id)?.item.change ?? "add" if (existing) Object.assign(existing, { status: row.status, updated_at: now }) else s.sent.push({ public_id: row.public_id, text: row.text, change, status: row.status, sent_at: now, updated_at: now }) if (row.status === "pending") s.pending_count += 1 } }) - const error = failed.size ? [...failed.values()][0] : undefined + const error = failed.size ? [...failed.values()][0] : retried return { sent, queued: after.outbox.length, ...(error ? { error } : {}) } } +/** + * Retry "Approve now" calls that failed for a passing reason. One that succeeds, or is refused for good (the rule + * changed, is flagged, the person is no longer the owner), leaves the list; a refusal is recorded on the person's + * proposal so `/rules` shows it. Gives up after `MAX_APPROVAL_ATTEMPTS`. Returns the last error, if any. + */ +async function retryApprovals(root: string, ctx: Ready, pending: RulesStore.State["pending_approvals"]): Promise { + if (!pending.length) return undefined + const settled = new Map() + const retry = new Map() + let last: string | undefined + for (const approval of pending) { + try { + const reviewed = await RulesApi.review(ctx.actAs, ctx.scope.datamateId, approval.public_id, { + action: approval.action, + expected_version: approval.version, + }) + settled.set(approval.public_id, { status: reviewed.status }) + } catch (error) { + last = `approving a proposal failed: ${errorText(error)}` + if (retryableApproval(error) && approval.attempts + 1 < MAX_APPROVAL_ATTEMPTS) retry.set(approval.public_id, approval.attempts + 1) + else settled.set(approval.public_id, { reason: `"Approve now" failed: ${errorText(error)}. Approve it in the workspace's Rules tab.` }) + } + } + const now = new Date().toISOString() + await RulesStore.update(root, (s) => { + s.pending_approvals = s.pending_approvals.flatMap((p) => { + if (settled.has(p.public_id)) return [] + const attempts = retry.get(p.public_id) + return [attempts === undefined ? p : { ...p, attempts }] + }) + for (const [publicId, outcome] of settled) { + const row = s.sent.find((x) => x.public_id === publicId) + if (!row) continue + if (outcome.status) Object.assign(row, { status: outcome.status, updated_at: now }) + if (outcome.reason) Object.assign(row, { reason: outcome.reason, updated_at: now }) + } + }) + return last +} + /** Queue proposals for the next push, in one write. Returns their outbox ids, in order. Refuses past `MAX_OUTBOX`. */ export async function enqueueMany(root: string, items: readonly { item: ProposalItem; approve?: "approve" | "approve_anyway" }[]): Promise { const ids = items.map(() => randomUUID()) @@ -294,6 +375,12 @@ export function outcomeOf(state: State, report: RunReport | undefined, outboxId: type PendingUsage = NonNullable +/** A usage refusal that resending the same batch cannot fix. */ +function permanentUsageFailure(error: unknown) { + const failure = error instanceof RulesApiError ? error : RulesApi.classify(error) + return failure.kind === "rejected" || failure.kind === "conflict" || (failure.status === 404 && failure.kind !== "unsupported" && failure.kind !== "workspace_not_found") +} + /** Send one batch; on success advance the reported state and clear it from `pending_usage`. */ async function sendUsage(root: string, ctx: Ready, batch: PendingUsage): Promise { await RulesApi.usage(ctx.actAs, ctx.scope.datamateId, { batch_id: batch.batch_id, items: batch.items }) @@ -315,13 +402,20 @@ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: nu try { await sendUsage(root, ctx, before.pending_usage) } catch (error) { - return { items: 0, error: errorText(error) } + // Refused for good (`batch_conflict`, a coded 400/422, a rule the server no longer serves): resending can never + // succeed and would block every later report, so the batch is dropped. Its sessions stay unreported. + if (!permanentUsageFailure(error)) return { items: 0, error: errorText(error) } + const batchId = before.pending_usage.batch_id + await RulesStore.update(root, (s) => { + if (s.pending_usage?.batch_id === batchId) s.pending_usage = undefined + }) + log.warn("rules usage batch refused; dropped", { error: errorText(error) }) } } const state = await RulesStore.read(root) if (!state.rules.length) return { items: 0 } const byLesson = new Map(state.rules.map((rule) => [RulesStore.lessonId(rule.rule_key), rule])) - const [{ readShownSnapshots }, { analyzeRecurrence }] = await Promise.all([import("../learn/delivery"), import("../learn/recurrence")]) + const [{ readShownSnapshots, lessonTextHash }, { analyzeRecurrence }] = await Promise.all([import("../learn/delivery"), import("../learn/recurrence")]) const { snapshots } = await readShownSnapshots(root) const reported = new Set(state.usage_sessions) const counts = new Map() @@ -330,8 +424,14 @@ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: nu // later sessions wait for the next report. for (const snapshot of snapshots) { if (reported.has(snapshot.session)) continue - const shown = [...new Set(snapshot.shown.filter((entry) => entry.name === RulesStore.TEAM_STORE).map((entry) => entry.id))] - .flatMap((id) => byLesson.get(id)?.public_id ?? []) + // Only against the version the session was shown: the lesson id (from `rule_key`) survives an approved edit, the + // text does not. A session shown an earlier version is not counted against the current one. + const shown = [...new Set(snapshot.shown + .filter((entry) => entry.name === RulesStore.TEAM_STORE) + .flatMap((entry) => { + const rule = byLesson.get(entry.id) + return rule && entry.textHash === lessonTextHash(rule.text) ? [rule.public_id] : [] + }))] if (!shown.length) continue if (shown.some((publicId) => (counts.get(publicId)?.applied ?? 0) >= MAX_USAGE_DELTA)) break if (new Set([...counts.keys(), ...shown]).size > MAX_USAGE_ITEMS) break @@ -372,8 +472,18 @@ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: nu corrected, } // Another process (a second instance, `learn sync`) may have built a batch from the same unreported sessions in - // the meantime: then that one is sent (same batch id, so the server counts it once) and this one is dropped. - const saved = (await RulesStore.update(root, (s) => { s.pending_usage ??= batch })).pending_usage ?? batch + // the meantime: while it is pending, that one is sent (same batch id, so the server counts it once). If it was + // already acknowledged, some of these sessions or corrections are reported: this batch is stale and is dropped + // (the next run recounts from what is left). + let stale = false + const after = await RulesStore.update(root, (s) => { + if (s.pending_usage) return + stale = sessions.some((session) => s.usage_sessions.includes(session)) || + Object.keys(corrected).some((key) => (s.corrected_reported[key] ?? 0) !== (state.corrected_reported[key] ?? 0)) + if (!stale) s.pending_usage = batch + }) + if (stale) return { items: 0 } + const saved = after.pending_usage ?? batch try { await sendUsage(root, ctx, saved) } catch (error) { diff --git a/packages/opencode/src/altimate/rules/telemetry.ts b/packages/opencode/src/altimate/rules/telemetry.ts index bf1eac2bdb..cb6d4bf6d6 100644 --- a/packages/opencode/src/altimate/rules/telemetry.ts +++ b/packages/opencode/src/altimate/rules/telemetry.ts @@ -64,7 +64,6 @@ export function answered(input: { action: input.action, status: input.status, ...(input.outcome ? { outcome: input.outcome } : {}), - approve_anyway: input.action === "approve" && !!input.card.weakens_verification, edits: input.edits, ms_since_shown: Math.max(0, Date.now() - input.shownAt), }) diff --git a/packages/opencode/src/altimate/rules/trigger.ts b/packages/opencode/src/altimate/rules/trigger.ts index 874d3d1a80..6072f56d7a 100644 --- a/packages/opencode/src/altimate/rules/trigger.ts +++ b/packages/opencode/src/altimate/rules/trigger.ts @@ -65,6 +65,11 @@ const words = (text: string) => new Set((text.toLowerCase().match(/[a-z][a-z0-9_ /** Shared content words that make a shown team rule the subject of a correction. */ export const EDIT_OVERLAP = 3 +/** Path globs per proposal, and their length: the server's lint bounds (a glob longer than a rule is not a path the + * person meant). Shared with the history path (`from-history.ts`). */ +export const MAX_APPLIES_TO = 4 +export const MAX_GLOB = 140 + const KNOWN_DIRS = new Set(["models", "macros", "seeds", "snapshots", "tests", "analyses", "src", "app", "lib", "dags", "sql", "scripts"]) /** Path globs a correction points at: directories of the paths it mentions. */ @@ -79,8 +84,7 @@ export function appliesTo(texts: readonly string[]): string[] { if (segments.length === 1 && !/[._]/.test(segments[0]) && !KNOWN_DIRS.has(segments[0])) continue globs.add(`${dir}/**`) } - // Bounded like the server's lint: a glob longer than a rule is not a path the person meant. - return [...globs].filter((glob) => glob.length <= 140).slice(0, 4) + return [...globs].filter((glob) => glob.length <= MAX_GLOB).slice(0, MAX_APPLIES_TO) } const clip = (text: string, max: number) => (text.length <= max ? text : text.slice(0, max - 1) + "…") @@ -193,6 +197,8 @@ export async function afterTurn(input: { outcome = card ? `showed a ${drafted.change} card for a shown team rule` : "the proposal for this team rule was dismissed" return } + // At most one drafting call per turn: the drafter found nothing to propose about the shown rule's subject. + return RulesTelemetry.skipped(input.sessionID, "shown_rule", "no_draft") } // (a) The same topic corrected in 2 or more sessions, with no rule covering it. @@ -254,9 +260,11 @@ export async function explicit(input: { text: string /** The person's own message that asked for it: their words decide rule or memory as much as the agent's. */ said?: string + /** The memory block the agent meant to write, kept for the card's Memory choice. */ + memory?: { id: string; tags: string[] } }): Promise< | { status: "shown"; card: Awaited>; choose: boolean } - | { status: "invalid"; reason: string } + | { status: "invalid"; reason: string; kind: "rule" | "ambiguous" } | { status: "memory"; cue: string } > { const text = input.text.replace(/\s+/g, " ").trim() @@ -264,31 +272,25 @@ export async function explicit(input: { const { kind, cue } = own.kind === "ambiguous" ? RuleClassify.classify(text) : own if (kind === "memory") return { status: "memory", cue: cue ?? "a fact or preference" } const reason = lint(text) - if (reason) return { status: "invalid", reason } + if (reason) return { status: "invalid", reason, kind } const signals = (await readSignals(input.root, Playbook.DEFAULT_NAME).catch(() => [] as Signal[])) .filter((signal) => signal.kind === "user_correction" && signal.sessionID === input.sessionID) const last = signals[signals.length - 1] - // A rule that refines an active team rule is an edit of it, shown against the current text. - const state = await RulesStore.read(input.root) - const said = words(text) - const target = state.rules - .map((rule) => ({ rule, overlap: [...words(rule.text)].filter((w) => said.has(w)).length })) - .filter((r) => r.overlap >= EDIT_OVERLAP && r.rule.text.toLowerCase() !== text.toLowerCase()) - .sort((a, b) => b.overlap - a.overlap)[0]?.rule + // Always a new rule: shared words are not a reason to replace a team rule (dbt rules share "model", "test", + // "staging"), and the card has no "add instead". Changing a team rule is the correction path's job, where the + // drafter decides whether the shown rule is the subject. const card = await RuleProposal.ask({ sessionID: input.sessionID, root: input.root, directory: input.directory, learn: input.learn, draft: { text, - applies_to: target?.applies_to ?? appliesTo([text, ...(last ? [last.text] : [])]), - why: target - ? "You asked to remember this; it changes an existing team rule" - : kind === "rule" ? "You asked to remember this as a rule" : "You asked to remember this", - change: target ? "edit" : "add", - ...(target ? { replaces: { public_id: target.public_id, text: target.text, version: target.version } } : {}), + applies_to: appliesTo([text, ...(last ? [last.text] : [])]), + why: kind === "rule" ? "You asked to remember this as a rule" : "You asked to remember this", + change: "add", origin: "explicit", evidence: { sessions: 1, ...(last ? { correction: { excerpt: excerpt(last.text), at: last.correctedAt ?? last.at } } : {}) }, topics: [], ...(kind === "ambiguous" ? { choose: true } : {}), + ...(input.memory ? { memory: input.memory } : {}), }, }) return { status: "shown", card, choose: kind === "ambiguous" } diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 2d42806712..10bbdb2d4a 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -617,21 +617,22 @@ export namespace Telemetry { session_id: string card_id: string trigger: "remember" | "shown_rule" | "recurrence" - action: "send" | "approve" | "edit" | "dismiss" | "save_local" | "save_memory" | "expired" | "closed" + action: "send" | "approve" | "approve_anyway" | "edit" | "dismiss" | "save_local" | "save_memory" | "expired" | "closed" /** `ok`, or why the reply did not go through (the card stays open). */ - status: "ok" | "busy" | "not_available" | "invalid_text" | "failed" + status: "ok" | "busy" | "not_available" | "verification_flagged" | "invalid_text" | "failed" /** For `ok` on a closing action: what happened. */ outcome?: | "sent" | "queued" | "approved" | "approve_failed" + | "approve_retry" + | "target_changed" | "exists" | "dismissed" | "saved_memory_project" | "saved_memory_global" | "saved_local" - approve_anyway: boolean /** Wording edits accepted on this card so far. */ edits: number ms_since_shown: number @@ -652,7 +653,8 @@ export namespace Telemetry { timestamp: number session_id: string via: "training" | "memory" - outcome: "card" | "choose_card" | "memory" | "invalid" | "learning_off" | "not_linked" + /** `no_renderer`: no client that shows cards is attached, so the tool saved as before cards. */ + outcome: "card" | "choose_card" | "memory" | "invalid" | "learning_off" | "not_linked" | "no_renderer" } | { /** One rules sync (push queued proposals, pull rules and reviews, report usage). */ diff --git a/packages/opencode/src/cli/cmd/learn.ts b/packages/opencode/src/cli/cmd/learn.ts index 793e70055a..1537ae7083 100644 --- a/packages/opencode/src/cli/cmd/learn.ts +++ b/packages/opencode/src/cli/cmd/learn.ts @@ -68,6 +68,18 @@ async function projectRoot() { return Instance.worktree !== "/" ? Instance.worktree : Instance.directory } +// altimate_change start — workspace rules: `learn enable` / `learn disable` are the person's newest choice, so they +// replace an earlier `/rules` learning choice (which outranks the project config) instead of being silently overridden. +async function clearPersonalLearning(root: string) { + const { RulesStore } = await import("../../altimate/rules/store") + const state = await RulesStore.read(root).catch(() => undefined) + if (state?.personal_learning === undefined) return + await RulesStore.update(root, (s) => { + delete s.personal_learning + }) +} +// altimate_change end + const run = (label: string, f: () => Promise) => Effect.tryPromise({ try: f, @@ -182,6 +194,7 @@ const EnableCommand = effectCmd({ const root = await projectRoot() const requested = args["auto-promote"] as boolean | undefined const file = await writeProjectLearning(root, true, requested) + await clearPersonalLearning(root) // altimate_change end // altimate_change start — explicit capture opt-in checks effective config and preserves the learning kill switch try { @@ -233,7 +246,9 @@ const DisableCommand = effectCmd({ handler: Effect.fn("Cli.learn.disable")(function* () { yield* run("", async () => { // altimate_change start — disable also turns automatic promotion off - const file = await writeProjectLearning(await projectRoot(), false, false) + const root = await projectRoot() + const file = await writeProjectLearning(root, false, false) + await clearPersonalLearning(root) // altimate_change end // altimate_change start — verify effective settings before reporting learning disabled const { Config } = await import("@/config/config") @@ -473,7 +488,7 @@ async function offerStagedForReview(input: { name: string; origin: "bootstrap" | 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 if (!send) { - out("Not sent; they stay local candidates (`learn show`, `learn promote`). Rerun with --send-for-review to send them.") + out("Not sent; they stay local candidates for this project only (`learn show`, `learn promote`).") return } const result = await RulesFromHistory.sendStaged({ diff --git a/packages/opencode/src/memory/tools/memory-write.ts b/packages/opencode/src/memory/tools/memory-write.ts index 552f59df69..a98d8e174a 100644 --- a/packages/opencode/src/memory/tools/memory-write.ts +++ b/packages/opencode/src/memory/tools/memory-write.ts @@ -40,10 +40,14 @@ export const MemoryWriteTool = Tool.define("altimate_memory_write", { async execute(args, ctx) { try { // altimate_change start — workspace rules: when the person asked to remember something that reads as a team - // convention (or is unclear), the proposal card decides between memory and a team rule, not this tool choice - if (args.scope === "project") { + // convention (or is unclear), the proposal card decides between memory and a team rule, not this tool choice. + // Not for an update of an existing block, or a save with an expiry or citations: the card would lose them. + if (args.scope === "project" && !args.expires && !args.citations?.length && !(await MemoryStore.read(args.scope, args.id))) { const { lastUserText, proposeTeamRule } = await import("../../altimate/rules/remember") - const proposed = await proposeTeamRule(args.content, ctx.sessionID, lastUserText(ctx.messages), "memory") + const proposed = await proposeTeamRule(args.content, ctx.sessionID, lastUserText(ctx.messages), "memory", { + id: args.id, + tags: args.tags ?? [], + }) if (proposed) return { ...proposed, metadata: { action: "proposed", id: args.id, scope: args.scope, duplicates: 0 } } } // altimate_change end diff --git a/packages/opencode/src/plugin/tui/altimate/rules.tsx b/packages/opencode/src/plugin/tui/altimate/rules.tsx index 231e1b7999..dea73b5314 100644 --- a/packages/opencode/src/plugin/tui/altimate/rules.tsx +++ b/packages/opencode/src/plugin/tui/altimate/rules.tsx @@ -98,7 +98,7 @@ function explain(card: ProposalCard, action: string): string { if (action === "save_memory") return RuleClassify.memoryScope(card.text) === "global" ? "Your preference: saved to your memory now, for all your projects. No review." - : "A fact about this project: saved to its memory now (shared through the workspace's memory). Not a team rule." + : "A fact about this project: saved to its memory now. Shared with the workspace if its memory sharing is on. Not a team rule." if (action === "edit") return "Change the text before deciding." return card.choose || !card.topics.length ? "Don't save anything." : "Won't propose this topic again for 30 days." } @@ -264,9 +264,6 @@ function installCards(api: TuiPluginApi) { onCancel={() => api.ui.dialog.clear()} /> ), - () => { - if (focused()) bindKeys() - }, ) } @@ -282,7 +279,8 @@ function installCards(api: TuiPluginApi) { return } setConfirming(undefined) - void reply(card, option.action) + // The confirmed approval is its own action: a plain `approve` on a flagged card is refused by the server. + void reply(card, option.action === "approve" && card.weakens_verification ? "approve_anyway" : option.action) } const shortcut = (action: string) => { @@ -325,11 +323,15 @@ function installCards(api: TuiPluginApi) { }) } - // Any other dialog opened while the card is focused (the palette, `/rules`) gets its keys back too. + // A dialog opened while the card is focused (the edit prompt, the palette, `/rules`) takes the keys, and the card + // leaves focus: when the dialog closes the host gives focus back to the prompt, so re-binding the card's keys then + // would take Enter, `e` and `n` from whatever the person types next. `p` focuses the card again. createRoot(() => { createEffect(() => { - if (api.ui.dialog.open) releaseKeys() - else if (focused()) bindKeys() + if (!api.ui.dialog.open || !focused()) return + releaseKeys() + setConfirming(undefined) + setFocused(false) }) // The card left the screen (a prompt hid the input, another session, home) or its card went away: give the keys // back. Without this a focused card's Enter would answer a card nobody can see, or a permission prompt's Enter. diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index a0ee58ab83..b79fe6617f 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1265,6 +1265,8 @@ export namespace Server { const refused = workspaceRouteRefusal(c.req.header("origin"), c.req.header("host"), undefined, c.req.header("sec-fetch-site")) if (refused) return c.json(refused.body, refused.status) const { RuleProposal } = await import("../altimate/rules/proposal") + // Only clients that render cards list them: from now on the save tools may show cards in this process. + RuleProposal.markRenderer() return c.json(RuleProposal.list(Instance.directory, c.req.query("session") || undefined)) }) .post("/rule-proposal/:id/reply", async (c) => { @@ -1273,7 +1275,7 @@ export namespace Server { const read = await readJsonObject(() => c.req.text(), "rule proposal reply") if ("failure" in read) return c.json(read.failure.body, read.failure.status) const { action, text } = read.body as { action?: unknown; text?: unknown } - const actions = ["send", "approve", "edit", "dismiss", "save_local", "save_memory"] as const + const actions = ["send", "approve", "approve_anyway", "edit", "dismiss", "save_local", "save_memory"] as const if (typeof action !== "string" || !actions.includes(action as (typeof actions)[number])) return c.json({ ok: false, error: `action must be one of ${actions.join(", ")}.` }, 400) if (text !== undefined && typeof text !== "string") return c.json({ ok: false, error: "text must be a string." }, 400) @@ -1284,7 +1286,7 @@ export namespace Server { { action: action as (typeof actions)[number], ...(text !== undefined ? { text } : {}) }, Instance.directory, ) - const status = result.ok ? 200 : { not_open: 404, busy: 409, not_available: 409, invalid_text: 422, failed: 500 }[result.code] + const status = result.ok ? 200 : { not_open: 404, busy: 409, not_available: 409, verification_flagged: 409, invalid_text: 422, failed: 500 }[result.code] return c.json(result, status as 200 | 404 | 409 | 422 | 500) } catch (err) { const error = err instanceof Error ? err.message : String(err) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c8aa437769..834aa47898 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -775,8 +775,9 @@ export namespace SessionPrompt { // Workspace rules: a bounded pull before a session's first turn, so it delivers newly approved rules. Only the // first prompt of a session awaits anything; every later prompt stays on the synchronous path. if (!rulesSessionsStarted.has(sessionID)) { - // Bounded: a long-lived server sees many sessions; forgetting an old one only costs it another bounded wait. - if (rulesSessionsStarted.size >= 1000) rulesSessionsStarted.clear() + // Bounded: a long-lived server sees many sessions; the oldest is forgotten first, which only costs it another + // bounded wait if it is resumed. + if (rulesSessionsStarted.size >= 1000) rulesSessionsStarted.delete(rulesSessionsStarted.values().next().value!) rulesSessionsStarted.add(sessionID) await import("../altimate/rules/service") .then((m) => m.RulesService.sessionStart(learnRoot, Instance.directory, altCfg.learn)) diff --git a/packages/opencode/test/altimate/learn/gitignore.test.ts b/packages/opencode/test/altimate/learn/gitignore.test.ts index 89b39fd6e8..a24d865eab 100644 --- a/packages/opencode/test/altimate/learn/gitignore.test.ts +++ b/packages/opencode/test/altimate/learn/gitignore.test.ts @@ -43,7 +43,7 @@ test.each([ await using tmp = await tmpdir() await create(tmp.path) const text = await fs.readFile(ignoreFile(tmp.path), "utf8") - expect(text).toContain("# Share approved rules") + expect(text).toContain("# Share approved lessons") expect(text.split("\n").filter((line) => line && !line.startsWith("#"))).toEqual([ "*", "!/*/", "!/*/approved.json", "!/.gitignore", ]) @@ -96,7 +96,7 @@ test.each(["\n", ""])("learn appends missing managed rules while preserving a us await Store.saveCandidate(tmp.path, NAME, Playbook.create({ name: NAME })) const updated = await fs.readFile(file, "utf8") expect(updated.startsWith(custom)).toBe(true) - expect(updated.match(/# Share approved rules/g)).toHaveLength(1) + expect(updated.match(/# Share approved lessons/g)).toHaveLength(1) for (const [relative, ignored] of [[`${NAME}/candidate.json`, 0], [`${NAME}/approved.json`, 1]] as const) { const result = Bun.spawnSync(["git", "-c", "core.excludesFile=/dev/null", "check-ignore", "--no-index", "--quiet", `.altimate-code/learn/${relative}`], { cwd: tmp.path, diff --git a/packages/opencode/test/altimate/rules/rules.test.ts b/packages/opencode/test/altimate/rules/rules.test.ts index 71f3f6b537..6105aaca5d 100644 --- a/packages/opencode/test/altimate/rules/rules.test.ts +++ b/packages/opencode/test/altimate/rules/rules.test.ts @@ -49,6 +49,10 @@ describe("rule or memory", () => { ["Remember that our warehouse is Snowflake in us-east-1", "memory"], ["ARR means annual recurring revenue", "memory"], ["Staging models live in models/staging", "ambiguous"], + ["Can you fix this model for me? Remember: never use FLOAT for money.", "rule"], + ["Never load the fact table in memory", "rule"], + ["Remember just for me: I like trailing commas", "memory"], + ["Keep this as a memory: the dev schema is dbt_sarav", "memory"], ])("%s → %s", (text, kind) => { expect(RuleClassify.classify(text).kind).toBe(kind as RuleClassify.Kind) }) @@ -151,6 +155,26 @@ describe("proposal card", () => { expect(flagged.ok && flagged.card?.weakens_verification).toBe("mentions skipping or disabling verification") }) + 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 } })) + const card = (await RuleProposal.ask({ sessionID: "a1", root, directory: root, learn: undefined, draft: { ...draft, text: "Skip dbt tests on staging models." } }))! + expect(card.weakens_verification).toBeTruthy() + expect(await RuleProposal.reply(card.id, { action: "approve" })).toMatchObject({ ok: false, code: "verification_flagged" }) + const plain = (await RuleProposal.ask({ sessionID: "a1", root, directory: root, learn: undefined, draft: { ...draft, text: "Prefix seeds with seed_ in every project." } }))! + expect(await RuleProposal.reply(plain.id, { action: "approve_anyway" })).toMatchObject({ ok: false, code: "not_available" }) + RuleProposal.drop(root) + }) + + test("a card asked again from another session moves there with that session's directory", async () => { + const root = await tmp() + const other = await tmp() + const card = (await RuleProposal.ask({ sessionID: "m1", root, directory: root, learn: undefined, draft: { ...draft, text: "Name snapshots snap_." } }))! + await RuleProposal.ask({ sessionID: "m2", root, directory: other, learn: undefined, draft: { ...draft, text: "Name snapshots snap_." } }) + expect(RuleProposal.list(other, "m2")).toEqual([expect.objectContaining({ id: card.id })]) + expect(await RuleProposal.reply(card.id, { action: "dismiss" }, other)).toMatchObject({ ok: true }) + }) + test("Not a rule suppresses the topic for 30 days", async () => { const root = await tmp() const card = (await RuleProposal.ask({ sessionID: "s3", root, directory: root, learn: undefined, draft }))! @@ -399,6 +423,97 @@ describe("the workspace API, by path", () => { expect(shown).not.toContain("secret-key") expect(shown).not.toContain("actAs") }) + + test("a repo_not_bound refusal of the proposals keeps the whole outbox, in one request", async () => { + const root = await tmp() + for (const text of ["first", "second", "third"]) await RulesSync.enqueue(root, item(text)) + seen.length = 0 + answer = () => Response.json({ detail: { code: "repo_not_bound", message: "not bound" } }, { status: 422 }) + expect((await RulesSync.push(root, ready())).queued).toBe(3) + expect(seen.filter((r) => r.path === "/proposals")).toHaveLength(1) + expect((await RulesStore.read(root)).outbox).toHaveLength(3) + }) + + test("an uncoded 422 refuses only the items that fail alone; when every item fails, all stay queued", async () => { + const root = await tmp() + for (const text of ["good", "bad"]) await RulesSync.enqueue(root, item(text)) + const uncoded = () => Response.json({ detail: [{ msg: "invalid" }] }, { status: 422 }) + answer = (_path, body) => (body.items.some((i: { text: string }) => i.text === "bad") ? uncoded() : Response.json({ results: [{ index: 0, status: "pending", public_id: "p-good", version: 1 }] })) + const mixed = await RulesSync.push(root, ready()) + expect(mixed.sent.map((s) => [s.text, s.status])).toEqual([["good", "pending"], ["bad", "rejected"]]) + const all = await tmp() + for (const text of ["one", "two"]) await RulesSync.enqueue(all, item(text)) + answer = uncoded + expect((await RulesSync.push(all, ready())).queued).toBe(2) + }) + + test("a stale edit is not recorded under the target's id, and its card closes with a message", async () => { + const root = await tmp() + await RulesSync.enqueue(root, { ...item("Edited wording."), change: "edit" as const, replaces: "p-target", replaces_version: 1 }) + answer = () => Response.json({ results: [{ index: 0, status: "stale", public_id: "p-target", version: 2, error_code: "target_changed", error_detail: "changed" }] }) + const pushed = await RulesSync.push(root, ready()) + expect(pushed.sent[0]).toMatchObject({ status: "stale", target_gone: true, error_code: "target_changed" }) + expect(pushed.sent[0].public_id).toBeUndefined() + expect((await RulesStore.read(root)).sent).toEqual([]) + }) + + test("a failed Approve now is retried on the next push when it can pass, and dropped with a reason when it cannot", async () => { + const root = await tmp() + await RulesSync.enqueue(root, item("Approve me."), "approve") + answer = (path) => (path === "/proposals" + ? Response.json({ results: [{ index: 0, status: "pending", public_id: "p-approve", version: 1 }] }) + : new Response("unavailable", { status: 503 })) + const first = await RulesSync.push(root, ready()) + expect(first.sent[0]).toMatchObject({ approve_retry: true }) + expect((await RulesStore.read(root)).pending_approvals).toEqual([expect.objectContaining({ public_id: "p-approve", action: "approve" })]) + answer = () => Response.json({ public_id: "p-approve", status: "active", version: 1 }) + await RulesSync.push(root, ready()) + const state = await RulesStore.read(root) + expect(state.pending_approvals).toEqual([]) + expect(state.sent.find((s) => s.public_id === "p-approve")?.status).toBe("active") + }) + + test("a usage batch the server refuses for good is dropped, so later usage is reported", async () => { + const root = await tmp() + answer = () => Response.json(syncBody([rule("aaaaaaaaaaaa", "Name staging models stg_.")])) + await RulesSync.pull(root, ready()) + await RulesStore.update(root, (s) => { + s.pending_usage = { batch_id: "batch-x", items: [{ public_id: "p-aaaaaaaaaaaa", applied: 1, helpful: 0, harmful: 0, corrected_again: 0 }], sessions: ["s1"], corrected: {} } + }) + answer = () => Response.json({ detail: { code: "batch_conflict", message: "conflict" } }, { status: 409 }) + await RulesSync.reportUsage(root, ready()) + expect((await RulesStore.read(root)).pending_usage).toBeUndefined() + }) + + test("a pull that skipped an unreadable rule does not keep the revision, so the next pull fetches all rules", async () => { + const root = await tmp() + answer = () => Response.json(syncBody([rule("aaaaaaaaaaaa", "Name staging models stg_."), { ...rule("bbbbbbbbbbbb", "broken"), rule_key: "nope" }])) + await RulesSync.pull(root, ready()) + const state = await RulesStore.read(root) + expect(state.revision).toBeUndefined() + expect(state.last_error).toContain("1 team rule could not be read") + seen.length = 0 + await RulesSync.pull(root, ready()) + expect(seen[0].body.known_revision).toBeUndefined() + }) + + test("usage counts a session only against the rule version it was shown", async () => { + const root = await tmp() + answer = () => Response.json(syncBody([{ ...rule("aaaaaaaaaaaa", "Name staging models stg___."), public_id: "p-v2", version: 2 }])) + await RulesSync.pull(root, ready()) + const lessonId = RulesStore.lessonId("R-aaaaaaaaaaaa") + const lesson = (text: string) => ({ id: lessonId, text, tags: [], scope: "project", helpful: 0, harmful: 0, applied: 0, created: "2026-10-27T00:00:00Z", updated: "2026-10-27T00:00:00Z" }) + const dir = path.join(root, ".altimate-code", "learn", ".sessions") + await fs.mkdir(dir, { recursive: true }) + for (const [session, text] of [["shown-v1", "Name staging models stg_."], ["shown-v2", "Name staging models stg___."]]) { + const state = { version: 1, session, firstMessage: "m", query: "q", touchedPaths: [], section: "", shown: [{ name: "team", lesson: lesson(text), tier: "core", at: "2026-10-28T00:00:00Z", queryHash: "h" }], requests: [], requestParts: [], compactions: [], counted: [] } + await fs.writeFile(path.join(dir, `${Store.sha256(session)}.json`), JSON.stringify(state)) + } + seen.length = 0 + answer = () => Response.json({ duplicate: false, applied_items: 1, skipped_items: 0 }) + await RulesSync.reportUsage(root, ready()) + expect(seen.find((r) => r.path === "/usage")?.body.items).toEqual([expect.objectContaining({ public_id: "p-v2", applied: 1 })]) + }) }) describe("learning decision", () => { @@ -515,6 +630,24 @@ describe("rules from history need consent", () => { expect(staged.items[0].applies_to).toHaveLength(4) expect(staged.invalid).toHaveLength(1) }) + + test("local rules the run edited or removed are not proposed, and they keep the candidate", async () => { + const root = await tmp() + await RulesStore.update(root, (s) => Object.assign(s, linked())) + const lesson = (id: string, text: string) => + ({ id, text, tags: [], scope: "project", helpful: 0, harmful: 0, applied: 0, created: "2026-01-01T00:00:00Z", updated: "2026-01-01T00:00:00Z" }) + const p = Store.paths(root, "team-playbook") + await fs.mkdir(path.dirname(p.candidate), { recursive: true }) + await fs.writeFile(p.approved, JSON.stringify([lesson("L-aaaaaaaaaaaa", "Name seeds seed_."), lesson("L-bbbbbbbbbbbb", "Quote reserved words.")])) + await fs.writeFile(p.candidate, JSON.stringify([ + lesson("L-aaaaaaaaaaaa", "Name seeds seed_, in snake case."), + lesson("L-cccccccccccc", "Prefix views with v_."), + ])) + const staged = (await RulesFromHistory.stagedRules({ root, name: "team-playbook", origin: "bootstrap", before: undefined }))! + expect(staged.items.map((i) => i.text)).toEqual(["Prefix views with v_."]) + // One local rule edited, one removed. + expect(staged.localChanges).toBe(2) + }) }) describe("self-review fixes", () => { @@ -590,10 +723,12 @@ describe("remember, whichever save tool the agent picks", () => { beforeAll(() => { delete process.env.ALTIMATE_DISABLE_WORKSPACE delete process.env.ALTIMATE_LEARN + RuleProposal.markRenderer() }) afterAll(() => { if (env.ALTIMATE_DISABLE_WORKSPACE !== undefined) process.env.ALTIMATE_DISABLE_WORKSPACE = env.ALTIMATE_DISABLE_WORKSPACE if (env.ALTIMATE_LEARN !== undefined) process.env.ALTIMATE_LEARN = env.ALTIMATE_LEARN + RuleProposal.setRendererForTest(false) }) const linkedRoot = async () => { const root = await tmp() @@ -601,6 +736,48 @@ describe("remember, whichever save tool the agent picks", () => { return root } + test("with no client that shows cards attached, both save tools save as before cards", async () => { + const root = await linkedRoot() + RuleProposal.setRendererForTest(false) + try { + const said = "Remember as a rule: always use CTEs in staging models." + const content = "Always use CTEs in staging models." + expect(await RulesRemember.proposeFor({ root, directory: root, learn: undefined, content, sessionID: "r0", said, via: "memory" })).toBeUndefined() + expect(await RulesRemember.proposeFor({ root, directory: root, learn: undefined, content, sessionID: "r0", said })).toBeUndefined() + expect(RuleProposal.list(root)).toEqual([]) + } finally { + RuleProposal.markRenderer() + } + }) + + test("from the memory tool, only the text the person asked to remember gets a card", async () => { + const root = await linkedRoot() + const said = "Remember as a rule: always use CTEs in staging models." + const other = await RulesRemember.proposeFor({ root, directory: root, learn: undefined, content: "The orders model joins customers on customer_id.", sessionID: "r1", said, via: "memory" }) + expect(other).toBeUndefined() + const asked = await RulesRemember.proposeFor({ root, directory: root, learn: undefined, content: "Always use CTEs in staging models.", sessionID: "r1", said, via: "memory" }) + expect(asked?.output).toContain("Nothing was saved yet") + RuleProposal.drop(root) + }) + + test("a requested rule that is not one line is sent back, not saved to memory; a long note is saved", async () => { + const root = await linkedRoot() + const long = "Always use CTEs in staging models and " + "keep every column explicit in the final select ".repeat(4) + const rule = await RulesRemember.proposeFor({ root, directory: root, learn: undefined, content: long, sessionID: "r2", said: `Remember as a rule: ${long}`, via: "memory" }) + expect(rule?.title).toBe("Team rule: not proposed") + const note = "The orders model joins customers on customer_id and filters test accounts in the final select " + "with a long explanation ".repeat(4) + expect(await RulesRemember.proposeFor({ root, directory: root, learn: undefined, content: note, sessionID: "r2", said: `Remember: ${note}`, via: "memory" })).toBeUndefined() + }) + + test("an explicit rule is always a new rule, even when it shares words with a team rule", async () => { + const root = await linkedRoot() + await RulesStore.update(root, (s) => { s.rules = [rule("Staging models list their columns and never select star.")] }) + await RulesRemember.proposeFor({ root, directory: root, learn: undefined, content: "Staging models always have a not_null test on the primary key columns.", sessionID: "r3", said: "Remember as a rule: staging models always have a not_null test on the primary key columns." }) + expect(RuleProposal.list(root, "r3")).toEqual([expect.objectContaining({ change: "add" })]) + expect(RuleProposal.list(root, "r3")[0].replaces).toBeUndefined() + RuleProposal.drop(root) + }) + test("an unclear \"remember\" saved as project memory becomes the Memory · Team rule · Skip card, and nothing is saved", async () => { const root = await linkedRoot() const said = "Remember: staging models live in models/staging." @@ -619,7 +796,7 @@ describe("remember, whichever save tool the agent picks", () => { expect(RuleProposal.list(root)).toEqual([]) }) - test("the card's memory option says where a fact goes", () => { + test("a project fact is classified as project-scoped memory", () => { expect(RuleClassify.memoryScope("Staging models live in models/staging.")).toBe("project") }) }) From d1c1c48deb76a10a602b7d31e65209a5275607aa Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 29 Oct 2026 15:14:27 +0530 Subject: [PATCH 13/20] =?UTF-8?q?fix:=20workspace=20rules=20=E2=80=94=20ad?= =?UTF-8?q?dress=20the=20third=20review=20round?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Precedence and storage: - `effectiveLearn` applies the `learn` keys of the layers that outrank project files (`OPENCODE_CONFIG_CONTENT`, the managed config directory, MDM preferences) after the on-disk project file, so a committed setting never beats an organisation's - Team rules are mirrored into a `workspace-rules` store instead of `team`: existing `team` playbooks are the person's own again. State from earlier builds is moved over; from `team/` only our state file, a mirror `approved.json` and our `.gitignore` are removed, and a playbook set aside for the mirror is restored. Documented Cards: - A client that shows cards holds a renderer lease (`POST /rule-proposal/renderer`, renewed every 30 s, lapses after 90 s) instead of a flag that never cleared; the TUI renews it while it runs. Without a live lease the save tools save as before cards and the after-turn check leaves corrections unprocessed for a later turn (`rule_proposal_skipped` `no_renderer`) - Memory on a memory-tool card saves in the tool's (project) scope; a card moved to another session takes its memory block; the memory tool keeps the card's error metadata and reads the block once - "Just for me" names a personal scope again; "remember … for me" counts only within its clause; a restatement must keep the person's negation - Explicit cards use the canonical project root, like after-turn cards Sync: - One proposal refused with an uncoded 400/422 is refused after 3 attempts instead of resent forever; the card says when a proposal is kept queued and why - A proposal's outcome is recorded per outbox id (`push_outcomes`) and looked up by id, never by text, so another run's refusal is not reported as "Sent" - A failed "Approve now" retry is reported as `approval_error`, not as proposals not sent; pending approvals survive a reset that keeps the outbox - A usage batch refused for good marks its sessions and corrections reported, so it is not rebuilt; both drop paths do this - The outbox limit message says when one send is too large - `rules_sync` is `ok` only when the push sent everything; a run that throws reports `failed` (`error`) Other: - Server text (rules, reviewer names, reasons, errors) is printed without control characters in `learn status`, `learn sync` and TUI toasts - The prompt path loads the rules service only when the project has rules state or a workspace was linked on this machine - A capture gate that cannot decide throws (logged, retried on the next update) instead of silently marking the correction recorded - `rules_delivered` is reported after the session state is saved; `team_rules` counts the `workspace-rules` store; telemetry `actions` is typed - `dismissed()` ignores expired entries; `learn enable` / `learn disable` clear the `/rules` choice only after they succeed; `learn add` is not a subcommand - Tests: route tests (origin refusal, validation, status mapping, renderer lease), managed config precedence, the store move, the after-turn check without a card client and with a card open, outcomes by id, uncoded retries, usage drops, outbox limit, telemetry status, delivery team rules Co-Authored-By: Claude Opus 5.5 --- docs/docs/configure/learn.md | 2 + docs/docs/reference/telemetry.md | 4 +- .../opencode/src/altimate/learn/capture.ts | 10 +- .../opencode/src/altimate/learn/delivery.ts | 13 +- packages/opencode/src/altimate/learn/store.ts | 7 +- .../opencode/src/altimate/rules/classify.ts | 5 +- .../src/altimate/rules/from-history.ts | 2 +- .../opencode/src/altimate/rules/proposal.ts | 46 ++++-- .../opencode/src/altimate/rules/remember.ts | 12 +- .../opencode/src/altimate/rules/service.ts | 34 +++- packages/opencode/src/altimate/rules/store.ts | 73 ++++++-- packages/opencode/src/altimate/rules/sync.ts | 90 +++++++--- .../opencode/src/altimate/rules/telemetry.ts | 7 +- .../opencode/src/altimate/rules/trigger.ts | 6 + .../opencode/src/altimate/telemetry/index.ts | 4 +- packages/opencode/src/cli/cmd/learn.ts | 36 ++-- .../opencode/src/memory/tools/memory-write.ts | 7 +- .../src/plugin/tui/altimate/rules.tsx | 16 +- packages/opencode/src/server/server.ts | 16 +- packages/opencode/src/session/prompt.ts | 12 +- .../test/altimate/learn/delivery.test.ts | 14 ++ .../test/altimate/rules/rules.test.ts | 156 +++++++++++++++++- .../test/server/altimate-rules-routes.test.ts | 100 +++++++++++ 23 files changed, 582 insertions(+), 90 deletions(-) create mode 100644 packages/opencode/test/server/altimate-rules-routes.test.ts diff --git a/docs/docs/configure/learn.md b/docs/docs/configure/learn.md index c4b3f16b11..c4d3e946fc 100644 --- a/docs/docs/configure/learn.md +++ b/docs/docs/configure/learn.md @@ -201,6 +201,8 @@ The card never blocks the input: keep typing, and answer it when you are ready. **Rule or memory.** A personal preference ("I prefer CTEs") or a fact ("our warehouse is Snowflake") is saved to memory without review. When the wording does not say which, the card asks: Memory, Team rule, or Skip. +**Where they live.** Approved team rules are mirrored into the `workspace-rules` store (`.altimate-code/learn/workspace-rules/`), which is git-ignored and written only by sync: the name is reserved for it, and a store you create needs another `--name`. Your other stores, including one named `team`, are not touched. + **After review.** Approved rules reach every session within a few minutes (a pull at session start and every 5 minutes; `/rules` → Sync now, or `altimate-code learn sync`). You see a notice when the owner approves or rejects your rule, with the reason. Usage is reported back to the workspace: how many sessions a rule was applied in, and how often you corrected the agent on its topic after it was shown ("corrected again"). **Learning on or off.** In order: the learning kill switch (`learn.enabled`, `ALTIMATE_LEARN`), `ALTIMATE_LEARN_CAPTURE`, your `/rules` → "Turn learning on/off for me" choice, `learn.capture` (`learn enable` / `learn disable`), then the workspace's "Learn from sessions". The `/rules` choice is yours alone: it is kept in the project's git-ignored rules state, not in the project config, so it never reaches a teammate through a commit ("Follow the workspace setting again" clears it). It takes effect right away. Approved rules are delivered either way. diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index fa50944f46..74d5ed4898 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -76,9 +76,9 @@ We collect the following categories of events: | `review_post_outcome` | Whether a review was published to GitHub — `not_requested`, `not_attempted`, `target_unresolved`, `full`, `partial`, or `summary_failed`, plus duration. Emitted on the **CLI path only** — the `dbt_pr_review` tool completes reviews but never publishes, so a `review_run` with `invocation: tool` has no post event and that is not a failure. Within the CLI path there is exactly one per **completed** review: a review that failed emits `review_run: failed` and no post event, so absence there means the review failed rather than that an event was lost. `not_attempted` is publication requested but never reached (a bad `--output` path, a stdout write error). No repository, PR, or comment content. | | `rule_proposal_shown` | A proposed-rule card was shown (or shown again for the same wording, `repeat`) — what raised it (`remember`, `shown_rule`, `recurrence`), the kind of change, whether it is a "Save as" choice, linked/owner, the buttons offered, whether it weakens verification, evidence session count, whether it came from a correction, how many paths it applies to, and its length. The card id is random. No rule text, quotes, paths or globs. | | `rule_proposal_answered` | A card was answered or closed (`send`, `approve`, `approve_anyway`, `edit`, `dismiss`, `save_local`, `save_memory`, `expired`, `closed`) — `ok` or why it did not go through (`busy`, `not_available`, `verification_flagged`, `invalid_text`, `failed`), what happened (`sent`, `queued`, `approved`, `approve_failed`, `approve_retry`, `target_changed`, `exists`, `dismissed`, `saved_memory_project`, `saved_memory_global`, `saved_local`), wording edits so far, and time since the card was shown. No rule text. | -| `rule_proposal_skipped` | Corrections were checked after a turn and no card was shown — `card_open`, `covered`, `no_draft`, `topic_dismissed`, `no_topic` or `failed`, and which path was checked. | +| `rule_proposal_skipped` | Corrections were checked after a turn and no card was shown — `card_open`, `no_renderer` (no attached client shows cards; the corrections are kept for a later check), `covered`, `no_draft`, `topic_dismissed`, `no_topic` or `failed`. The path checked (`shown_rule` or `recurrence`) is omitted when the check stopped before choosing one. | | `rule_remember_routed` | Where a "remember …" save went — through `training_save` or the memory tool, to a card, a "Save as" card, memory, refused as not a one-line rule, or saved as before cards because learning/workspaces are off, the project is not linked, or no attached client shows cards. No text. | -| `rules_sync` | One rules sync — what started it (`refresh`, `session_start`, `manual`, `card`, `cli`, `import`), `ok`, `failed`, `off` or `skipped` with a fixed reason code, duration, and counts of rules, pending reviews, proposals sent, refused and still queued, review notices, and usage items. No rule text, error text, URLs or workspace names. | +| `rules_sync` | One rules sync — what started it (`refresh`, `session_start`, `manual`, `card`, `cli`, `import`), `ok` (the pull worked and every proposal to send went out), `failed` (a failed pull, proposals kept queued, or a run that threw: reason `error`), `off` or `skipped` with a fixed reason code, duration, and counts of rules, pending reviews, proposals sent, refused and still queued, review notices, and usage items. No rule text, error text, URLs or workspace names. | | `rules_delivered` | Rules added to a session's context — at the start, for a request, or for a file the agent touched — with the number of rules (and how many are team rules), their total length, the session's running total, and the token budget. No rule text or file paths. | | `rules_learning_changed` | The person turned learning from sessions on or off for a project, or back to the workspace's setting, with the resulting state and who decides it. | | `rules_command` | `/rules` opened in a client (`view`), or a `learn` subcommand run (its name only, never its arguments). | diff --git a/packages/opencode/src/altimate/learn/capture.ts b/packages/opencode/src/altimate/learn/capture.ts index 28ead1a046..d53725ace8 100644 --- a/packages/opencode/src/altimate/learn/capture.ts +++ b/packages/opencode/src/altimate/learn/capture.ts @@ -302,9 +302,15 @@ export async function startCapture( const capture = new Capture({ record: async (signal) => { // Fails closed: a gate that cannot decide (unreadable rules state) must not record a correction the person - // may have turned off. + // may have turned off. It throws rather than returns, so the correction is not marked recorded and a later + // update of the message can try again; `track` logs it. const gate = gates.get(ctx.directory) - if (gate && !(await gate().catch(() => false))) return + if (gate) { + const on = await gate().catch((error: unknown) => { + throw new Error(`learning decision unavailable, correction not recorded: ${error instanceof Error ? error.message : String(error)}`) + }) + if (!on) return + } return appendSignal(root, signal) }, turnStart: async (sessionID, messageID) => { diff --git a/packages/opencode/src/altimate/learn/delivery.ts b/packages/opencode/src/altimate/learn/delivery.ts index 671d913e93..20f55a4b7a 100644 --- a/packages/opencode/src/altimate/learn/delivery.ts +++ b/packages/opencode/src/altimate/learn/delivery.ts @@ -299,8 +299,7 @@ export class Delivery { session_id: state.session, tier, rules: added.length, - // The workspace's team rules are the `team` store (`rules/store.ts` `TEAM_STORE`). - team_rules: added.filter((entry) => entry.name === "team").length, + team_rules: added.filter((entry) => entry.name === Store.WORKSPACE_RULES_STORE).length, chars: added.reduce((sum, entry) => sum + entry.lesson.text.length, 0), session_rules: state.shown.length, budget_tokens: this.limits.budget_tokens, @@ -384,6 +383,7 @@ export class Delivery { } const approved = await this.approved() if (!state && !approved.length) return { ...EMPTY } + let added: { before: number; tier: "start" | "request" } | undefined if (!state) { // Count earlier deliveries before replacing an unsafe snapshot; retain the session ledger // so selecting its approved replacement cannot count the same ID twice. @@ -395,7 +395,7 @@ export class Delivery { const start = selectStart(corpus(approved), initialQuery, this.limits) state = { version: 1, session, firstMessage: message, query: redactedQuery, touchedPaths: previous?.touchedPaths ?? [], section: start.section, shown: [], requests: [], requestParts: previous?.requestParts ?? [], compactions: [], counted: previous?.counted ?? [] } for (const item of start.lessons) this.add(state, approved, [item.lesson], item.tier, initialQuery) - this.delivered(state, 0, "start") + added = { before: 0, tier: "start" } state.requests.push({ message, note: "" }) } else { state.query = redactedQuery @@ -405,12 +405,13 @@ export class Delivery { paths: [...state.touchedPaths, ...this.mentionedPaths(query)], }) const rendered = renderSection(matches, this.limits.budget_tokens, "Team rules for this request:") - const before = state.shown.length + added = { before: state.shown.length, tier: "request" } this.add(state, approved, rendered.lessons, "request", query) - this.delivered(state, before, "request") state.requests.push({ message, note: rendered.section }) } await this.save(state) + // Reported once saved: a save that throws is retried, and would otherwise count the same rules twice. + if (added) this.delivered(state, added.before, added.tier) await this.log(state) return { section: state.section, requestNote: state.requests.find((request) => request.message === message)!.note } }, LOCK_OPTIONS) @@ -467,8 +468,8 @@ export class Delivery { const rendered = renderSection(matches, this.limits.budget_tokens, `Team rules for ${singleLine(relative)}:`) const before = state.shown.length const added = this.add(state, approved, rendered.lessons, "file", relative) - this.delivered(state, before, "file") if (touched || added.length) await this.save(state) + this.delivered(state, before, "file") if (!added.length) return "" await this.log(state) return rendered.section diff --git a/packages/opencode/src/altimate/learn/store.ts b/packages/opencode/src/altimate/learn/store.ts index f6e9e9a371..575977418c 100644 --- a/packages/opencode/src/altimate/learn/store.ts +++ b/packages/opencode/src/altimate/learn/store.ts @@ -95,8 +95,11 @@ export function feedbackId(feedback: string, origin: string): string { // Callers hold the learn transaction lock. Rename also protects readers from partial files. // altimate_change start — workspace rules: the store the team rules are mirrored into is written only by rules sync -/** Store names learn's own writers refuse: `team` holds the workspace's team rules (`rules/store.ts` `TEAM_STORE`). */ -export const RESERVED_STORES: ReadonlySet = new Set(["team"]) +/** The store a linked project's workspace team rules are mirrored into: written only by rules sync. Not `team`, which + * existing projects use for their own (often committed) playbooks. */ +export const WORKSPACE_RULES_STORE = "workspace-rules" +/** Store names learn's own writers refuse. */ +export const RESERVED_STORES: ReadonlySet = new Set([WORKSPACE_RULES_STORE]) function assertWritable(name: string) { if (RESERVED_STORES.has(name)) diff --git a/packages/opencode/src/altimate/rules/classify.ts b/packages/opencode/src/altimate/rules/classify.ts index 510868ca0c..bc4267b6cf 100644 --- a/packages/opencode/src/altimate/rules/classify.ts +++ b/packages/opencode/src/altimate/rules/classify.ts @@ -16,8 +16,9 @@ export type Kind = "rule" | "memory" | "ambiguous" // The person named the kind: these decide on their own, whatever else the text says. const ASKED_RULE = /\bas\s+a\s+(?:team\s+)?rule\b/i // Only the named forms: a bare "for me" opens ordinary requests ("fix this model for me"), and "in memory" describes -// data ("never load the fact table in memory"). -const ASKED_MEMORY = /\bas\s+a\s+memory\b|\bin\s+my\s+memory\b|\bremember\b[^.!?\n]*\b(?:just\s+)?for\s+me\b/i +// data ("never load the fact table in memory"). "Just for me" always names a personal scope; "remember … for me" only +// within one clause, so a later "for me" does not turn a team instruction into a memory. +const ASKED_MEMORY = /\bas\s+a\s+memory\b|\bin\s+my\s+memory\b|\bjust\s+for\s+me\b|\bremember\b[^.!?\n,;:]*\bfor\s+me\b/i // "I want …" / "I like …" open many ordinary requests ("I want staging models in models/staging"): on its own // that is not enough to file a team convention as a personal memory, so it only makes the text ambiguous. diff --git a/packages/opencode/src/altimate/rules/from-history.ts b/packages/opencode/src/altimate/rules/from-history.ts index 4494243695..70f5b309ce 100644 --- a/packages/opencode/src/altimate/rules/from-history.ts +++ b/packages/opencode/src/altimate/rules/from-history.ts @@ -92,7 +92,7 @@ export async function sendStaged(input: { const ids = await RulesSync.enqueueMany(input.root, input.staged.items.map((item) => ({ item }))) const report = await RulesSync.runFresh(input.root, input.directory, input.learn, "import") const state = await RulesStore.read(input.root) - const outcomes = ids.map((id, index) => RulesSync.outcomeOf(state, report, id, input.staged.items[index].text)) + const outcomes = ids.map((id) => RulesSync.outcomeOf(state, report, id)) const queued = outcomes.filter((o) => o.status === "queued").length const refused = outcomes.filter((o) => o.status === "sent" && o.row?.error).length const sent = outcomes.length - queued - refused diff --git a/packages/opencode/src/altimate/rules/proposal.ts b/packages/opencode/src/altimate/rules/proposal.ts index badee8dfe7..bae8737797 100644 --- a/packages/opencode/src/altimate/rules/proposal.ts +++ b/packages/opencode/src/altimate/rules/proposal.ts @@ -67,23 +67,32 @@ function close(id: string, entry: Pending, message: string, why: "expired" | "cl void publish(RuleEvents.Event.Replied, { id, sessionID: entry.card.sessionID, action: "expired", message }) } -// A client that renders cards (the TUI, the IDE extension) lists them when it attaches: `GET /rule-proposal`. Until -// one has, nothing would show a card, so the save tools save as they did before cards (`rendererAttached`). -const RENDERER = Symbol.for("altimate.rules.proposal.renderer") -const renderer = ((globalThis as Record)[RENDERER] ??= { attached: false }) as { attached: boolean } +// A client that renders cards (the TUI, the IDE extension) holds a lease: it calls `POST /rule-proposal/renderer` when +// it starts and every `RENDERER_RENEW_MS` while it runs. While no lease is live, nothing would show a card: the save +// tools save as they did before cards, and the after-turn check waits (`rendererAttached`). A client that went away +// stops renewing, so its lease lapses; one that failed to register retries on the next renewal. +export const RENDERER_RENEW_MS = 30_000 +export const RENDERER_LEASE_MS = 3 * RENDERER_RENEW_MS +const RENDERERS = Symbol.for("altimate.rules.proposal.renderers") +const renderers = ((globalThis as Record)[RENDERERS] ??= new Map()) as Map -/** A client that shows proposal cards is attached to this process. */ -export function rendererAttached() { - return renderer.attached +/** A client that shows proposal cards holds a live lease in this process. */ +export function rendererAttached(now = Date.now()) { + for (const [client, until] of renderers) { + if (until > now) return true + renderers.delete(client) + } + return false } -/** Called by `GET /rule-proposal`: the caller renders cards. */ -export function markRenderer() { - renderer.attached = true +/** `POST /rule-proposal/renderer`: `client` shows cards for the next `RENDERER_LEASE_MS`. */ +export function renewRenderer(client: string, now = Date.now()) { + renderers.set(client, now + RENDERER_LEASE_MS) } export function setRendererForTest(attached: boolean) { - renderer.attached = attached + renderers.clear() + if (attached) renderers.set("test", Number.MAX_SAFE_INTEGER) } /** Drop cards past `CARD_TTL_MS`. `here`: the directory of the request doing it (its project's clients are told). */ @@ -165,6 +174,7 @@ export async function ask(input: { existing.card = { ...existing.card, sessionID: input.sessionID } existing.directory = input.directory existing.learn = input.learn + if (input.draft.memory) existing.memory = input.draft.memory await publish(RuleEvents.Event.Updated, existing.card) } else await publish(RuleEvents.Event.Asked, existing.card) RulesTelemetry.shown(existing.card, existing.trigger, true) @@ -297,7 +307,9 @@ async function act(entry: Pending, action: Exclude): Promis message = card.topics.length ? "Not a rule: it won't be proposed again for 30 days." : "Dismissed: nothing was saved." } else if (action === "save_memory") { const [{ MemoryStore }, { RuleClassify }] = await Promise.all([import("../../memory/store"), import("./classify")]) - const scope = RuleClassify.memoryScope(card.text) + // A card from the memory tool saves the block it was writing, in its scope (project): the classifier's guess + // could put that block id into personal memory and overwrite an unrelated block there. + const scope = entry.memory ? "project" : RuleClassify.memoryScope(card.text) const id = entry.memory?.id ?? `notes/${slug(card.text)}` const tags = entry.memory?.tags.length ? entry.memory.tags : ["remembered"] const now = new Date().toISOString() @@ -328,12 +340,18 @@ async function act(entry: Pending, action: Exclude): Promis : [], }, approve) const report = await RulesSync.runFresh(entry.root, entry.directory, entry.learn, "card").catch(() => undefined) - const sent = RulesSync.outcomeOf(await RulesStore.read(entry.root), report, outboxId, card.text) + const sent = RulesSync.outcomeOf(await RulesStore.read(entry.root), report, outboxId) const owner = card.workspace?.owner_name ?? "the workspace owner" const ws = card.workspace?.name ?? "the workspace" if (sent.status === "queued") { - message = `Queued: it will be sent to ${owner}'s review queue when the workspace is reachable.` + 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.` outcome = "queued" + } else if (!sent.row) { + // Sent by another run whose answer is no longer recorded. + message = `Sent by another sync: \`/rules\` shows where it stands in ${ws}.` + outcome = "sent" } else if (sent.row?.target_gone) { // The rule this card edits or retires changed or was removed since it was shown: resending cannot succeed. message = "Not sent: the team rule this changes was edited or removed since. Check `/rules` and propose it again if it still applies." diff --git a/packages/opencode/src/altimate/rules/remember.ts b/packages/opencode/src/altimate/rules/remember.ts index 5ad96ae3b8..17aeb49ee5 100644 --- a/packages/opencode/src/altimate/rules/remember.ts +++ b/packages/opencode/src/altimate/rules/remember.ts @@ -4,14 +4,19 @@ // whichever save tool the agent reached for. `training_save` (kind rule) and `altimate_memory_write` (project scope, // when the person asked to remember) both come here first. +import fs from "node:fs/promises" + /** The person asked for something to be remembered (not the agent deciding to take a note). */ const ASKED_TO_REMEMBER = /\b(?:remember|don'?t forget|do not forget|keep in mind|note (?:that|this)|save (?:this|that)|from now on)\b/i const contentWords = (text: string) => new Set(text.toLowerCase().match(/[a-z0-9_]{3,}/g) ?? []) +const NEGATION = /\b(?:never|not|no|don'?t|do not|avoid|without)\b/i -/** Does `content` restate what the person said? Most of its words appear in their message. */ +/** Does `content` restate what the person said? Most of its words appear in their message, and it does not drop or add + * a negation ("never use FLOAT" is not restated by "use FLOAT"). */ export function restates(content: string, said: string): boolean { const words = [...contentWords(content)] if (!words.length) return false + if (NEGATION.test(content) !== NEGATION.test(said)) return false const spoken = contentWords(said) return words.filter((word) => spoken.has(word)).length / words.length >= 0.6 } @@ -47,7 +52,10 @@ export async function proposeTeamRule( import("@/config/config"), import("./service"), ]) - const root = Instance.worktree !== "/" ? Instance.worktree : Instance.directory + // Canonical, like the after-turn path (`service.ts`): a card is deduplicated by root, and a symlinked checkout or + // `/var` → `/private/var` would otherwise show the same rule twice. + const raw = Instance.worktree !== "/" ? Instance.worktree : Instance.directory + const root = await fs.realpath(raw).catch(() => raw) // The project's learn file as it is now, like every other entry point: `learn disable` mid-session takes effect. const learn = await RulesService.effectiveLearn((await Config.get()).learn, root, Instance.directory) return proposeFor({ root, directory: Instance.directory, learn, content, sessionID, said, via, memory }) diff --git a/packages/opencode/src/altimate/rules/service.ts b/packages/opencode/src/altimate/rules/service.ts index 19fc708b4c..10ed6f0574 100644 --- a/packages/opencode/src/altimate/rules/service.ts +++ b/packages/opencode/src/altimate/rules/service.ts @@ -12,6 +12,7 @@ // - After each turn (session idle), check the session's new corrections (`RuleTrigger.afterTurn`). // - Publish review notices from a pull as `rule.proposal.reviewed`. import fs from "node:fs/promises" +import path from "node:path" import { Effect } from "effect" import { Bus } from "@/bus" import { registerDisposer } from "@/effect/instance-registry" @@ -28,12 +29,39 @@ const log = Log.create({ service: "rules.service" }) type LearnConfig = { enabled?: boolean; capture?: boolean; model?: string } | undefined -/** `loaded` (the session's config) with the project file's `learn` as it is on disk now: `learn enable/disable` - * write it while the session runs. Shared by the service and the routes so both decide the same way. */ +/** + * `loaded` (the session's config) with the project file's `learn` as it is on disk now: `learn enable/disable` write it + * while the session runs. The layers that outrank project files in `config.ts` (`OPENCODE_CONFIG_CONTENT`, the + * managed config directory, MDM preferences) are applied again on top, so a committed project setting never beats an + * organisation's. Shared by the service and the routes so both decide the same way. + */ export async function effectiveLearn(loaded: LearnConfig, root: string, directory: string): Promise { const { readProjectLearning } = await import("../learn/project-config") const onDisk = (await readProjectLearning(root, directory).catch(() => undefined)) as LearnConfig - return loaded || onDisk ? { ...loaded, ...onDisk } : undefined + if (!loaded && !onDisk) return undefined + const above = await higherLearn().catch(() => ({})) + return { ...loaded, ...onDisk, ...above } +} + +/** The `learn` keys set by the config layers above project files, in their merge order. */ +async function higherLearn(): Promise> { + const [{ env }, { ConfigManaged }, { parse }] = await Promise.all([ + import("@opencode-ai/core/flag/flag"), + import("@/config/managed"), + import("jsonc-parser"), + ]) + const learn: Record = {} + const add = (text: string | undefined) => { + if (!text) return + const value = (parse(text) as { learn?: unknown } | undefined)?.learn + if (value && typeof value === "object" && !Array.isArray(value)) Object.assign(learn, value) + } + add(env("OPENCODE_CONFIG_CONTENT")) + const managedDir = ConfigManaged.managedConfigDir() + for (const file of ["altimate-code.json", "altimate-code.jsonc", "opencode.json", "opencode.jsonc"]) + add(await fs.readFile(path.join(managedDir, file), "utf8").catch(() => undefined)) + add((await ConfigManaged.readManagedPreferences())?.text) + return learn } async function learnConfig(ctx: InstanceContext): Promise { diff --git a/packages/opencode/src/altimate/rules/store.ts b/packages/opencode/src/altimate/rules/store.ts index 3187da5acb..018f75c75b 100644 --- a/packages/opencode/src/altimate/rules/store.ts +++ b/packages/opencode/src/altimate/rules/store.ts @@ -23,7 +23,9 @@ import * as Store from "../learn/store" import { RuleOut, Settings } from "./api" /** The read-only store team rules are mirrored into (reserved: `learn/store.ts` `RESERVED_STORES`). */ -export const TEAM_STORE = "team" +export const TEAM_STORE = Store.WORKSPACE_RULES_STORE +/** Where earlier builds of workspace rules kept their state and mirror: moved by `migrateLegacy`. */ +const LEGACY_STORE = "team" const log = Log.create({ service: "rules.store" }) const LOCK = { timeoutMs: 5_000 } const MAX_NOTICES = 50 @@ -115,6 +117,14 @@ export const State = z.object({ /** The person's own "learning from sessions" choice for this project (`/rules`), when they made one. Kept here, * not in the project config, because that file is usually committed and the choice is per person. */ personal_learning: z.boolean().optional(), + /** The last push outcome per outbox id, so a card can tell what happened to its proposal even when another run (the + * timer, a session start, `learn sync`) sent it. Capped. */ + push_outcomes: z.record(z.string(), z.object({ + status: z.string(), + public_id: z.string().optional(), + error: z.string().optional(), + error_code: z.string().optional(), + })).default({}), /** "Approve now" calls that failed for a reason that can pass (network, 5xx, credential): retried on the next push. */ pending_approvals: z.array(z.object({ public_id: z.string(), @@ -139,12 +149,45 @@ export function dir(root: string) { } const stateFile = (root: string) => path.join(dir(root), "rules.json") -/** The state file's text, or undefined when there is none. Other read errors propagate. */ +/** The state file's text, or undefined when there is none. Other read errors propagate. Until `update` migrates it, + * state an earlier build kept under `team/` is read from there. */ async function readRaw(root: string): Promise { - return fs.readFile(stateFile(root), "utf8").catch((error: NodeJS.ErrnoException) => { + const read = (file: string) => fs.readFile(file, "utf8").catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return undefined throw error }) + return (await read(stateFile(root))) ?? (await read(legacyStateFile(root))) +} + +const legacyStateFile = (root: string) => path.join(Store.paths(root, LEGACY_STORE).learnDir, "rules.json") + +/** + * Earlier builds kept the rules state and mirror in the `team` store, which projects also use for their own playbooks. + * Moves the state to `workspace-rules/`, and removes from `team/` only what was ours: the state file, an `approved.json` + * that is the mirror, and our `.gitignore`. A playbook that was set aside for the mirror is put back. Under the lock. + */ +async function migrateLegacy(root: string): Promise { + const legacy = Store.paths(root, LEGACY_STORE) + const state = await fs.readFile(legacyStateFile(root), "utf8").catch(() => undefined) + if (state === undefined) return + if (!(await fs.access(stateFile(root)).then(() => true, () => false))) await Store.writeAtomic(root, stateFile(root), state) + await fs.rm(legacyStateFile(root), { force: true }) + const approved = await fs.readFile(legacy.approved, "utf8").catch(() => undefined) + if (approved !== undefined && isMirror(approved)) { + await fs.rm(legacy.approved, { force: true }) + const backups = (await fs.readdir(legacy.learnDir).catch(() => [] as string[])) + .filter((name) => name.startsWith("approved.json.before-workspace-rules-")) + .sort() + const latest = backups[backups.length - 1] + if (latest) { + await Store.writeAtomic(root, legacy.approved, await fs.readFile(path.join(legacy.learnDir, latest), "utf8")) + await fs.rm(path.join(legacy.learnDir, latest), { force: true }) + } + } + const ignore = path.join(legacy.learnDir, ".gitignore") + if ((await fs.readFile(ignore, "utf8").catch(() => undefined)) === IGNORE) await fs.rm(ignore, { force: true }) + await fs.rmdir(legacy.learnDir).catch(() => {}) + log.info("workspace rules state moved from the team store", { root }) } function parse(raw: string | undefined): State | undefined { @@ -205,6 +248,7 @@ function capSent(sent: Sent[]): Sent[] { export async function update(root: string, change: (state: State) => State | void): Promise { return withLearnLock(root, async () => { await SafeFS.mkdir(root, dir(root)) + await migrateLegacy(root) const raw = await readRaw(root) let current = parse(raw) let recovered = false @@ -223,8 +267,9 @@ export async function update(root: string, change: (state: State) => State | voi const now = Date.now() next.dismissed = next.dismissed.filter((d) => Date.parse(d.until) > now) await Store.writeAtomic(root, stateFile(root), JSON.stringify(next, null, 2) + "\n") - // Only a linked project (or one whose rules just changed, e.g. a reset) has a mirror to write: a project with no - // workspace must not have its `team` directory touched. After an unreadable state was set aside, an existing + // Only a linked project (or one whose rules just changed, e.g. a reset) has a mirror to write: in a project with no + // workspace, only the state file is written (a dismissal, a learning choice), never an `approved.json` that delivery + // would read. After an unreadable state was set aside, an existing // mirror no longer matches any state: rewrite it, so rules nothing vouches for stop being delivered. const staleMirror = recovered && (await fs.access(Store.paths(root, TEAM_STORE).approved).then(() => true, () => false)) if (next.workspace || JSON.stringify(next.rules) !== rulesBefore || staleMirror) { @@ -240,11 +285,11 @@ async function writeMirror(root: string, rules: readonly RuleOut[]) { const text = canonical(rules.map(toLesson)) const previous = await fs.readFile(file, "utf8").catch(() => undefined) if (previous === text) return - // A playbook someone named `team` before workspace rules existed: keep a copy instead of overwriting it. + // A playbook someone named `workspace-rules` before workspace rules existed: keep a copy instead of overwriting it. if (previous !== undefined && !isMirror(previous)) { const backup = `${file}.before-workspace-rules-${Date.now()}` await Store.writeAtomic(root, backup, previous, 0o600) - log.warn("an existing `team` rule store was set aside for the workspace team rules", { backup }) + log.warn("an existing `workspace-rules` store was set aside for the workspace team rules", { backup }) } await Store.writeAtomic(root, file, text) } @@ -274,15 +319,23 @@ export async function reset(root: string, options: { keepOutbox?: boolean } = {} corrected_reported: state.corrected_reported, processed_signals: state.processed_signals, // A batch built for this scope can only be resent to it. - ...(options.keepOutbox ? { outbox: state.outbox, scope: state.scope, ...(state.pending_usage ? { pending_usage: state.pending_usage } : {}) } : {}), + ...(options.keepOutbox + ? { + outbox: state.outbox, + scope: state.scope, + pending_approvals: state.pending_approvals, + ...(state.pending_usage ? { pending_usage: state.pending_usage } : {}), + } + : {}), })) } /** Queued proposals at most; a new one is refused (the card stays open) rather than an older one dropped. */ export const MAX_OUTBOX = 200 -export function dismissed(state: State, topic: string): boolean { - return state.dismissed.some((d) => d.topic === topic.toLowerCase()) +/** Whether `topic` is dismissed now. Expired entries are dropped on the next write, and do not count before it. */ +export function dismissed(state: State, topic: string, now = Date.now()): boolean { + return state.dismissed.some((d) => d.topic === topic.toLowerCase() && Date.parse(d.until) > now) } export * as RulesStore from "./store" diff --git a/packages/opencode/src/altimate/rules/sync.ts b/packages/opencode/src/altimate/rules/sync.ts index 6ac3b4c433..678c97aa89 100644 --- a/packages/opencode/src/altimate/rules/sync.ts +++ b/packages/opencode/src/altimate/rules/sync.ts @@ -163,7 +163,10 @@ export interface PushResult { target_gone?: boolean }[] queued: number + /** Proposals that were not sent (kept queued). */ error?: string + /** A retried "Approve now" failed (the proposals themselves may all have been sent). */ + approval_error?: string } type OutboxEntry = State["outbox"][number] @@ -213,6 +216,8 @@ const retryableApproval = (error: unknown) => { return kind === "transient" || kind === "auth" } const MAX_APPROVAL_ATTEMPTS = 5 +const MAX_UNCODED_ATTEMPTS = 3 +const MAX_PUSH_OUTCOMES = 200 /** Per-item answers that are about now, not about the proposal: it stays queued and is sent again later. */ const RETRY_LATER = new Set(["too_many_pending"]) @@ -225,7 +230,7 @@ export async function push(root: string, ctx: Ready): Promise { const state = await RulesStore.read(root) const batch = state.outbox.slice(0, MAX_PROPOSALS) const retried = await retryApprovals(root, ctx, state.pending_approvals) - if (!batch.length) return { sent: [], queued: 0, ...(retried ? { error: retried } : {}) } + if (!batch.length) return { sent: [], queued: 0, ...(retried ? { approval_error: retried } : {}) } const outcomes = await send(ctx, batch) const sent: PushResult["sent"] = [] const approvals: { entry: OutboxEntry; publicId: string; version: number }[] = [] @@ -234,7 +239,9 @@ export async function push(root: string, ctx: Ready): Promise { const failed = new Map() for (const { entry, result, error } of outcomes) { if (error) { - if (error.kind === "rejected") { + // An uncoded 400/422 for one item cannot tell the item from the request: kept for a few attempts, then refused + // so it is not resent forever. + if (error.kind === "rejected" || (uncodedContent(error) && entry.attempts + 1 >= MAX_UNCODED_ATTEMPTS)) { done.add(entry.id) sent.push({ id: entry.id, text: entry.item.text, status: "rejected", error: error.message }) } else failed.set(entry.id, error.message) @@ -291,6 +298,15 @@ export async function push(root: string, ctx: Ready): Promise { entry.attempts += 1 entry.last_error = error } + for (const row of sent) + s.push_outcomes[row.id] = { + status: row.status, + ...(row.public_id ? { public_id: row.public_id } : {}), + ...(row.error ? { error: row.error } : {}), + ...(row.error_code ? { error_code: row.error_code } : {}), + } + const ids = Object.keys(s.push_outcomes) + for (const id of ids.slice(0, Math.max(0, ids.length - MAX_PUSH_OUTCOMES))) delete s.push_outcomes[id] for (const row of sent) { if (!row.public_id || row.status === "exists") continue const existing = s.sent.find((x) => x.public_id === row.public_id) @@ -300,8 +316,8 @@ export async function push(root: string, ctx: Ready): Promise { if (row.status === "pending") s.pending_count += 1 } }) - const error = failed.size ? [...failed.values()][0] : retried - return { sent, queued: after.outbox.length, ...(error ? { error } : {}) } + const error = failed.size ? [...failed.values()][0] : undefined + return { sent, queued: after.outbox.length, ...(error ? { error } : {}), ...(retried ? { approval_error: retried } : {}) } } /** @@ -350,7 +366,11 @@ export async function enqueueMany(root: string, items: readonly { item: Proposal const at = new Date().toISOString() await RulesStore.update(root, (state) => { if (state.outbox.length + items.length > RulesStore.MAX_OUTBOX) - throw new Error(`${state.outbox.length} proposals are already waiting to be sent; sync before proposing more`) + throw new Error( + items.length > RulesStore.MAX_OUTBOX + ? `${items.length} proposals are more than can wait to be sent at once (${RulesStore.MAX_OUTBOX}); send fewer` + : `${state.outbox.length} proposals are already waiting to be sent, and ${items.length} more would pass the limit of ${RulesStore.MAX_OUTBOX}; sync before proposing more`, + ) items.forEach(({ item, approve }, index) => state.outbox.push({ id: ids[index], created_at: at, attempts: 0, item, ...(approve ? { approve } : {}) })) }) @@ -362,15 +382,30 @@ export async function enqueue(root: string, item: ProposalItem, approve?: "appro return (await enqueueMany(root, [{ item, ...(approve ? { approve } : {}) }]))[0] } -/** Where an enqueued proposal stands after a run: still queued, sent (with the server's answer), or gone. */ -export function outcomeOf(state: State, report: RunReport | undefined, outboxId: string, text: string): +/** + * Where an enqueued proposal stands after a run: still queued, or sent with the server's answer. Looked up by outbox + * id: this run's report first, then the outcome another run (the timer, a session start, `learn sync`) recorded. Never + * by text, which an older proposal can share. + */ +export function outcomeOf(state: State, report: RunReport | undefined, outboxId: string): | { status: "queued"; error?: string } | { status: "sent"; row: PushResult["sent"][number] | undefined; sent?: State["sent"][number] } { const queued = state.outbox.find((entry) => entry.id === outboxId) if (queued) return { status: "queued", ...(queued.last_error ? { error: queued.last_error } : {}) } - const row = report?.push?.sent.find((s) => s.id === outboxId) - // Sent by another run (background refresh, session start): its record is in `sent`, by text. - return { status: "sent", row, sent: row?.public_id ? state.sent.find((s) => s.public_id === row.public_id) : [...state.sent].reverse().find((s) => s.text === text) } + const recorded = state.push_outcomes[outboxId] + const row = report?.push?.sent.find((s) => s.id === outboxId) ?? + (recorded + ? { + id: outboxId, + text: "", + status: recorded.status, + ...(recorded.public_id ? { public_id: recorded.public_id } : {}), + ...(recorded.error ? { error: recorded.error } : {}), + ...(recorded.error_code ? { error_code: recorded.error_code } : {}), + ...(recorded.error_code && TARGET_GONE.has(recorded.error_code) ? { target_gone: true } : {}), + } + : undefined) + return { status: "sent", row, sent: row?.public_id ? state.sent.find((s) => s.public_id === row.public_id) : undefined } } type PendingUsage = NonNullable @@ -402,14 +437,8 @@ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: nu try { await sendUsage(root, ctx, before.pending_usage) } catch (error) { - // Refused for good (`batch_conflict`, a coded 400/422, a rule the server no longer serves): resending can never - // succeed and would block every later report, so the batch is dropped. Its sessions stay unreported. if (!permanentUsageFailure(error)) return { items: 0, error: errorText(error) } - const batchId = before.pending_usage.batch_id - await RulesStore.update(root, (s) => { - if (s.pending_usage?.batch_id === batchId) s.pending_usage = undefined - }) - log.warn("rules usage batch refused; dropped", { error: errorText(error) }) + await dropUsage(root, before.pending_usage, error) } } const state = await RulesStore.read(root) @@ -487,11 +516,26 @@ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: nu try { await sendUsage(root, ctx, saved) } catch (error) { + if (permanentUsageFailure(error)) await dropUsage(root, saved, error) return { items: 0, error: errorText(error) } } return { items: saved.items.length } } +/** + * Drop a batch the server refused for good (`batch_conflict`, a coded 400/422, a rule it no longer serves): resending + * can never succeed and would block every later report. Its sessions and corrections are marked reported, so the same + * batch is not rebuilt and refused again; those counts are lost, which is the lesser harm. + */ +async function dropUsage(root: string, batch: PendingUsage, error: unknown) { + await RulesStore.update(root, (s) => { + if (s.pending_usage?.batch_id === batch.batch_id) s.pending_usage = undefined + s.usage_sessions.push(...batch.sessions.filter((session) => !s.usage_sessions.includes(session))) + s.corrected_reported = { ...s.corrected_reported, ...batch.corrected } + }) + log.warn("rules usage batch refused; dropped", { error: errorText(error) }) +} + export interface RunReport { context: Context push?: PushResult @@ -509,9 +553,15 @@ export function publicReport(report: RunReport) { * for telemetry. */ export async function run(root: string, directory: string, learn: LearnConfig, trigger: SyncTrigger = "refresh"): Promise { const startedAt = Date.now() - const report = await runUntracked(root, directory, learn) - RulesTelemetry.synced(trigger, report, startedAt) - return report + try { + const report = await runUntracked(root, directory, learn) + RulesTelemetry.synced(trigger, report, startedAt) + return report + } catch (error) { + // A run that threw (the learn lock timed out, the state is not writable): still one `failed` sync. + RulesTelemetry.syncThrew(trigger, startedAt) + throw error + } } async function runUntracked(root: string, directory: string, learn: LearnConfig): Promise { diff --git a/packages/opencode/src/altimate/rules/telemetry.ts b/packages/opencode/src/altimate/rules/telemetry.ts index cb6d4bf6d6..24f6e682dd 100644 --- a/packages/opencode/src/altimate/rules/telemetry.ts +++ b/packages/opencode/src/altimate/rules/telemetry.ts @@ -85,9 +85,10 @@ export function synced(trigger: SyncTrigger, report: RulesSync.RunReport, starte const base = { type: "rules_sync" as const, timestamp: Date.now(), session_id: "", trigger, duration_ms: Date.now() - startedAt } const { context, push, pull, usage } = report if (context.status !== "ready") return send({ ...base, status: context.status, reason: context.code }) + // `ok` only when the pull worked and nothing that was to be sent stayed back; usage has its own flag. send({ ...base, - status: pull?.ok ? "ok" : "failed", + status: pull?.ok && !push?.error ? "ok" : "failed", ...(pull?.ok ? { rules: pull.rules, pending: pull.pending, notices: pull.notices.length } : {}), ...(push ? { @@ -101,6 +102,10 @@ export function synced(trigger: SyncTrigger, report: RulesSync.RunReport, starte }) } +export function syncThrew(trigger: SyncTrigger, startedAt: number) { + send({ type: "rules_sync", timestamp: Date.now(), session_id: "", trigger, status: "failed", reason: "error", duration_ms: Date.now() - startedAt }) +} + export function delivered(input: Omit, "type" | "timestamp">) { if (input.rules > 0) send({ type: "rules_delivered", timestamp: Date.now(), ...input }, false) } diff --git a/packages/opencode/src/altimate/rules/trigger.ts b/packages/opencode/src/altimate/rules/trigger.ts index 6072f56d7a..83753a21df 100644 --- a/packages/opencode/src/altimate/rules/trigger.ts +++ b/packages/opencode/src/altimate/rules/trigger.ts @@ -155,6 +155,12 @@ export async function afterTurn(input: { const fresh = (await readSignals(input.root, Playbook.DEFAULT_NAME)).filter((signal) => signal.kind === "user_correction" && signal.sessionID === input.sessionID && !processed.has(signal.id)) if (!fresh.length) return + // Nothing attached would show a card (`altimate-code run`, ACP, a plugin): the corrections are left unprocessed, + // so a later turn of this session with a card client attached proposes them. + if (!RuleProposal.rendererAttached()) { + outcome = "no client that shows cards is attached; corrections kept for a later check" + return RulesTelemetry.skipped(input.sessionID, undefined, "no_renderer") + } // One card at a time per session: an explicit "remember" may already have shown one for this turn. if (RuleProposal.list().some((card) => card.sessionID === input.sessionID)) return RulesTelemetry.skipped(input.sessionID, undefined, "card_open") diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 10bbdb2d4a..d360991d07 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -602,7 +602,7 @@ export namespace Telemetry { choose: boolean linked: boolean is_owner: boolean - actions: string[] + actions: ("send" | "approve" | "edit" | "dismiss" | "save_local" | "save_memory")[] weakens_verification: boolean evidence_sessions: number from_correction: boolean @@ -644,7 +644,7 @@ export namespace Telemetry { session_id: string /** Absent when the check stopped before choosing a path (a card already open, a failure). */ trigger?: "shown_rule" | "recurrence" - reason: "card_open" | "covered" | "no_draft" | "topic_dismissed" | "no_topic" | "failed" + reason: "card_open" | "no_renderer" | "covered" | "no_draft" | "topic_dismissed" | "no_topic" | "failed" } | { /** The agent saved something the person asked to remember (`training_save` kind rule, or the memory tool diff --git a/packages/opencode/src/cli/cmd/learn.ts b/packages/opencode/src/cli/cmd/learn.ts index 1537ae7083..c841cfad27 100644 --- a/packages/opencode/src/cli/cmd/learn.ts +++ b/packages/opencode/src/cli/cmd/learn.ts @@ -194,7 +194,6 @@ const EnableCommand = effectCmd({ const root = await projectRoot() const requested = args["auto-promote"] as boolean | undefined const file = await writeProjectLearning(root, true, requested) - await clearPersonalLearning(root) // altimate_change end // altimate_change start — explicit capture opt-in checks effective config and preserves the learning kill switch try { @@ -216,6 +215,8 @@ const EnableCommand = effectCmd({ }) throw new Error(`Wrote project config: ${file}, but capture remains off (effective learn.capture=false). ${hint}`) } else { + // Only once it worked: a failed enable must not also delete the person's `/rules` choice. + await clearPersonalLearning(root) out(`Project learning enabled: learn.capture=true, learn.auto_reflect=${autoReflectEnabled(learn)}.`) } // altimate_change end @@ -248,7 +249,6 @@ const DisableCommand = effectCmd({ // altimate_change start — disable also turns automatic promotion off const root = await projectRoot() const file = await writeProjectLearning(root, false, false) - await clearPersonalLearning(root) // altimate_change end // altimate_change start — verify effective settings before reporting learning disabled const { Config } = await import("@/config/config") @@ -268,6 +268,8 @@ const DisableCommand = effectCmd({ throw new Error(`Wrote project config: ${file}, but learning remains active (effective learn.capture=${capture}, learn.auto_reflect=${automatic}). ${hints.join(" ")}`) } // altimate_change end + // Only once it worked: a failed disable must not also delete the person's `/rules` choice. + await clearPersonalLearning(root) out("Project learning disabled: learn.capture=false, learn.auto_reflect=false.") out(`Project config: ${file}`) }) @@ -424,10 +426,9 @@ const StatusCommand = effectCmd({ if (!rules.linked) out("Workspace rules: not linked (run `altimate-code link`)") else { const { RulesSummary } = await import("../../altimate/rules/summary") - out(`Workspace rules: ${rules.workspace?.name ?? "workspace"} — ${RulesSummary.sidebarLine(rules)}${rules.pulled_at ? `; last sync ${rules.pulled_at}` : ""}${rules.last_error ? `; last error: ${rules.last_error}` : ""}`) - if (rules.last_check) out(`Last proposal check: ${rules.last_check.at} — ${rules.last_check.outcome}`) - for (const notice of rules.notices.slice(0, 5)) - out(` ${notice.status === "active" ? "approved" : notice.status}${notice.reviewer ? ` by ${notice.reviewer}` : ""}: "${notice.text}"${notice.reason ? ` — ${notice.reason}` : ""}`) + out(`Workspace rules: ${printable(rules.workspace?.name ?? "workspace")} — ${RulesSummary.sidebarLine(rules)}${rules.pulled_at ? `; last sync ${rules.pulled_at}` : ""}${rules.last_error ? `; last error: ${printable(rules.last_error)}` : ""}`) + if (rules.last_check) out(`Last proposal check: ${rules.last_check.at} — ${printable(rules.last_check.outcome)}`) + for (const notice of rules.notices.slice(0, 5)) out(noticeLine(notice)) } // altimate_change end }) @@ -435,6 +436,15 @@ const StatusCommand = effectCmd({ }) // altimate_change start — workspace rules: send queued proposals, pull approved rules, report usage +/** Server text (a rule, a reviewer's name or reason, an error) without control characters: an owner-written reason + * must not be able to move the cursor, retitle the terminal or plant a hyperlink. */ +const printable = (text: string | undefined) => (text ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ") + +function noticeLine(notice: { status: string; text: string; reviewer?: string; reason?: string }) { + const status = notice.status === "active" ? "approved" : printable(notice.status) + return ` ${status}${notice.reviewer ? ` by ${printable(notice.reviewer)}` : ""}: "${printable(notice.text)}"${notice.reason ? ` — ${printable(notice.reason)}` : ""}` +} + const SyncCommand = effectCmd({ command: "sync", describe: "sync workspace rules now: send queued proposals and pull the approved rules", @@ -453,13 +463,13 @@ const SyncCommand = effectCmd({ return } if (report.context.status !== "ready") return out(`Rules sync skipped: ${report.context.reason}.`) - for (const sent of report.push?.sent ?? []) out(`Sent "${sent.text}": ${sent.error ?? sent.status}`) - if (report.push?.error) out(`Proposals not sent (kept queued): ${report.push.error}`) - if (!report.pull?.ok) throw new Error(`Rules pull failed: ${report.pull?.error ?? "unknown error"}`) + for (const sent of report.push?.sent ?? []) out(`Sent "${printable(sent.text)}": ${printable(sent.error ?? sent.status)}`) + if (report.push?.error) out(`Proposals not sent (kept queued): ${printable(report.push.error)}`) + if (report.push?.approval_error) out(`Approve now not done yet: ${printable(report.push.approval_error)}`) + if (!report.pull?.ok) throw new Error(`Rules pull failed: ${printable(report.pull?.error ?? "unknown error")}`) out(`Synced: ${report.pull.rules} team ${report.pull.rules === 1 ? "rule" : "rules"}, ${report.pull.pending} pending review.`) - for (const notice of report.pull.notices) - out(` ${notice.status === "active" ? "approved" : notice.status}${notice.reviewer ? ` by ${notice.reviewer}` : ""}: "${notice.text}"${notice.reason ? ` — ${notice.reason}` : ""}`) - if (report.usage?.error) out(`Usage not reported: ${report.usage.error}`) + for (const notice of report.pull.notices) out(noticeLine(notice)) + if (report.usage?.error) out(`Usage not reported: ${printable(report.usage.error)}`) }) }), }) @@ -1143,7 +1153,7 @@ const LEARN_HELP = [ const LEARN_SUBCOMMANDS = new Set([ "enable", "disable", "nudge", "status", "sync", "bootstrap", "import-reviews", "reflect", "signals", "signal", - "add", "show", "search", "pin", "unpin", "promote", "rollback", "reject", + "show", "search", "pin", "unpin", "promote", "rollback", "reject", ]) export const LearnCommand = cmd({ diff --git a/packages/opencode/src/memory/tools/memory-write.ts b/packages/opencode/src/memory/tools/memory-write.ts index a98d8e174a..33639bf082 100644 --- a/packages/opencode/src/memory/tools/memory-write.ts +++ b/packages/opencode/src/memory/tools/memory-write.ts @@ -39,19 +39,20 @@ export const MemoryWriteTool = Tool.define("altimate_memory_write", { }), async execute(args, ctx) { try { + const existing = await MemoryStore.read(args.scope, args.id) // altimate_change start — workspace rules: when the person asked to remember something that reads as a team // convention (or is unclear), the proposal card decides between memory and a team rule, not this tool choice. // Not for an update of an existing block, or a save with an expiry or citations: the card would lose them. - if (args.scope === "project" && !args.expires && !args.citations?.length && !(await MemoryStore.read(args.scope, args.id))) { + if (args.scope === "project" && !args.expires && !args.citations?.length && !existing) { const { lastUserText, proposeTeamRule } = await import("../../altimate/rules/remember") const proposed = await proposeTeamRule(args.content, ctx.sessionID, lastUserText(ctx.messages), "memory", { id: args.id, tags: args.tags ?? [], }) - if (proposed) return { ...proposed, metadata: { action: "proposed", id: args.id, scope: args.scope, duplicates: 0 } } + // The card's own action ("proposed", or "error" when the rule must be restated) is kept. + if (proposed) return { ...proposed, metadata: { action: proposed.metadata.action, id: args.id, scope: args.scope, duplicates: 0 } } } // altimate_change end - const existing = await MemoryStore.read(args.scope, args.id) const now = new Date().toISOString() const { duplicates } = await MemoryStore.write({ diff --git a/packages/opencode/src/plugin/tui/altimate/rules.tsx b/packages/opencode/src/plugin/tui/altimate/rules.tsx index dea73b5314..edc96c6534 100644 --- a/packages/opencode/src/plugin/tui/altimate/rules.tsx +++ b/packages/opencode/src/plugin/tui/altimate/rules.tsx @@ -433,13 +433,27 @@ function installCards(api: TuiPluginApi) { for (const notice of [...(summary?.notices ?? [])].reverse()) notify(notice, first) }) on(api)("altimate.rules.changed", replay) + // This TUI shows proposal cards: it holds the server's renderer lease while it runs, renewed at the interval the + // server asks for. Without a live lease, the save tools save as before cards and the after-turn check waits. A + // failed call is retried after a few seconds. + const lease = async () => { + const result = await post<{ ok: boolean; renew_ms?: number }>(api, "/rule-proposal/renderer", { client: "tui" }).catch(() => undefined) + // Not registered yet (the server may still be starting): try again soon, not at the next renewal. + const next = result?.ok !== true ? 3_000 : typeof result.renew_ms === "number" && result.renew_ms > 0 ? result.renew_ms : 30_000 + setTimeout(() => void lease(), next).unref?.() + } + void lease() setTimeout(() => { replay() void load() }, 4_000) } -function reviewToast(api: TuiPluginApi, p: { text: string; status: string; reason?: string; reviewer?: string }) { +/** Server text without control characters (a reviewer's reason must not drive the terminal). */ +const printable = (text: string | undefined) => (text ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ") + +function reviewToast(api: TuiPluginApi, raw: { text: string; status: string; reason?: string; reviewer?: string }) { + const p = { ...raw, text: printable(raw.text), reason: raw.reason && printable(raw.reason), reviewer: raw.reviewer && printable(raw.reviewer) } const who = p.reviewer ? ` by ${p.reviewer}` : "" const quoted = `"${p.text.length > 80 ? p.text.slice(0, 79) + "…" : p.text}"` if (p.status === "active") api.ui.toast({ variant: "success", message: `Your rule was approved${who}: ${quoted}. It now applies to the team.`, duration: 10000 }) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index b79fe6617f..bc34a447dc 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1265,10 +1265,22 @@ export namespace Server { const refused = workspaceRouteRefusal(c.req.header("origin"), c.req.header("host"), undefined, c.req.header("sec-fetch-site")) if (refused) return c.json(refused.body, refused.status) const { RuleProposal } = await import("../altimate/rules/proposal") - // Only clients that render cards list them: from now on the save tools may show cards in this process. - RuleProposal.markRenderer() return c.json(RuleProposal.list(Instance.directory, c.req.query("session") || undefined)) }) + // A client that shows proposal cards renews its lease here while it runs (`RENDERER_RENEW_MS`). Without a live + // lease the save tools save as before cards and the after-turn check waits for one. + .post("/rule-proposal/renderer", async (c) => { + const refused = workspaceRouteRefusal(c.req.header("origin"), c.req.header("host"), undefined, c.req.header("sec-fetch-site")) + if (refused) return c.json(refused.body, refused.status) + const read = await readJsonObject(() => c.req.text(), "rule proposal renderer") + if ("failure" in read) return c.json(read.failure.body, read.failure.status) + const { client } = read.body as { client?: unknown } + if (typeof client !== "string" || !/^[a-z0-9_.-]{1,64}$/i.test(client)) + return c.json({ ok: false, error: "client must be a short name (letters, digits, _ . -)." }, 400) + const { RuleProposal } = await import("../altimate/rules/proposal") + RuleProposal.renewRenderer(client) + return c.json({ ok: true as const, renew_ms: RuleProposal.RENDERER_RENEW_MS, lease_ms: RuleProposal.RENDERER_LEASE_MS }) + }) .post("/rule-proposal/:id/reply", async (c) => { const refused = workspaceRouteRefusal(c.req.header("origin"), c.req.header("host"), undefined, c.req.header("sec-fetch-site")) if (refused) return c.json(refused.body, refused.status) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 834aa47898..fffec41257 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -11,6 +11,8 @@ import { MessageV2, isEchoTransportError } from "./message-v2" // altimate_change end // altimate_change — a linked workspace with no skills yet still waits for the skill sync; see `prompt` import { emptyRecordPath } from "../altimate/workspace/empty-record-path" +// altimate_change — workspace rules: the prompt path skips them in a project that cannot have any +import { Global } from "../global" import { Log } from "../util/log" import { SessionRevert } from "./revert" import { Session } from "." @@ -133,6 +135,12 @@ globalThis.AI_SDK_LOG_WARNINGS = false // altimate_change start — workspace rules: sessions that already had their session-start pull in this process const rulesSessionsStarted = new Set() +/** Rules state in this project, or a workspace linked on this machine (the bindings cache): otherwise the project + * cannot have workspace rules, and the prompt path skips them. Synchronous existence checks only. */ +const mayHaveWorkspaceRules = (root: string) => + existsSync(path.join(root, ".altimate-code", "learn", "workspace-rules", "rules.json")) || + existsSync(path.join(root, ".altimate-code", "learn", "team", "rules.json")) || + existsSync(path.join(Global.Path.state, "altimate-workspace-bindings.json")) // altimate_change end const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format. @@ -774,7 +782,9 @@ export namespace SessionPrompt { const learnRoot = Instance.worktree !== "/" ? Instance.worktree : Instance.directory // Workspace rules: a bounded pull before a session's first turn, so it delivers newly approved rules. Only the // first prompt of a session awaits anything; every later prompt stays on the synchronous path. - if (!rulesSessionsStarted.has(sessionID)) { + // A project that cannot have workspace rules pays only the existence checks in `mayHaveWorkspaceRules`: the rules + // module graph is not loaded on its prompt path. + if (!rulesSessionsStarted.has(sessionID) && mayHaveWorkspaceRules(learnRoot)) { // Bounded: a long-lived server sees many sessions; the oldest is forgotten first, which only costs it another // bounded wait if it is resumed. if (rulesSessionsStarted.size >= 1000) rulesSessionsStarted.delete(rulesSessionsStarted.values().next().value!) diff --git a/packages/opencode/test/altimate/learn/delivery.test.ts b/packages/opencode/test/altimate/learn/delivery.test.ts index 10dee3963d..e8c92b1735 100644 --- a/packages/opencode/test/altimate/learn/delivery.test.ts +++ b/packages/opencode/test/altimate/learn/delivery.test.ts @@ -504,3 +504,17 @@ test("rules added to a session are reported by tier, as counts only; a cached an expect(delivered[1].chars).toBe("Use the `shipping` route for parcel tracking.".length) expect(JSON.stringify(events)).not.toContain("shipping") }) + +test("rules from the workspace-rules store are counted as team rules", async () => { + await approve([lesson("L-0101", "Keep the project rules explicit.", { pinned: true })]) + await approve([lesson("L-0102", "Name staging models stg_.", { pinned: true })], Store.WORKSPACE_RULES_STORE) + const events: Telemetry.Event[] = [] + const spy = spyOn(Telemetry, "track").mockImplementation((event: Telemetry.Event) => void events.push(event)) + try { + await new Delivery(root, { ...limits, core_lessons: 2 }).prepare("session", "first", "staging") + } finally { + spy.mockRestore() + } + const start = events.find((e): e is Extract => e.type === "rules_delivered") + expect(start).toMatchObject({ tier: "start", rules: 2, team_rules: 1 }) +}) diff --git a/packages/opencode/test/altimate/rules/rules.test.ts b/packages/opencode/test/altimate/rules/rules.test.ts index 6105aaca5d..6c94ddf5aa 100644 --- a/packages/opencode/test/altimate/rules/rules.test.ts +++ b/packages/opencode/test/altimate/rules/rules.test.ts @@ -12,6 +12,9 @@ import { appliesTo, learningDecision, learningEnabled } from "../../../src/altim import { RulesFromHistory } from "../../../src/altimate/rules/from-history" import { RulesRemember } from "../../../src/altimate/rules/remember" import { RulesTelemetry } from "../../../src/altimate/rules/telemetry" +import { RulesService } from "../../../src/altimate/rules/service" +import { RuleTrigger } from "../../../src/altimate/rules/trigger" +import { appendSignal } from "../../../src/altimate/learn/signals" import { Telemetry } from "../../../src/altimate/telemetry" import { parse } from "../../../src/altimate/learn/lesson" import * as Store from "../../../src/altimate/learn/store" @@ -506,7 +509,7 @@ describe("the workspace API, by path", () => { const dir = path.join(root, ".altimate-code", "learn", ".sessions") await fs.mkdir(dir, { recursive: true }) for (const [session, text] of [["shown-v1", "Name staging models stg_."], ["shown-v2", "Name staging models stg___."]]) { - const state = { version: 1, session, firstMessage: "m", query: "q", touchedPaths: [], section: "", shown: [{ name: "team", lesson: lesson(text), tier: "core", at: "2026-10-28T00:00:00Z", queryHash: "h" }], requests: [], requestParts: [], compactions: [], counted: [] } + const state = { version: 1, session, firstMessage: "m", query: "q", touchedPaths: [], section: "", shown: [{ name: RulesStore.TEAM_STORE, lesson: lesson(text), tier: "core", at: "2026-10-28T00:00:00Z", queryHash: "h" }], requests: [], requestParts: [], compactions: [], counted: [] } await fs.writeFile(path.join(dir, `${Store.sha256(session)}.json`), JSON.stringify(state)) } seen.length = 0 @@ -514,6 +517,47 @@ describe("the workspace API, by path", () => { await RulesSync.reportUsage(root, ready()) expect(seen.find((r) => r.path === "/usage")?.body.items).toEqual([expect.objectContaining({ public_id: "p-v2", applied: 1 })]) }) + + test("a card's outcome is the one recorded for its outbox id, even when another run sent it", async () => { + const root = await tmp() + const id = await RulesSync.enqueue(root, item("Refused elsewhere.")) + answer = () => Response.json({ detail: { code: "invalid", message: "too vague" } }, { status: 422 }) + await RulesSync.push(root, ready()) + // This run did not send it: its outcome comes from what the other run recorded, not "sent". + expect(RulesSync.outcomeOf(await RulesStore.read(root), undefined, id)).toMatchObject({ status: "sent", row: { status: "rejected", error: "too vague" } }) + }) + + test("one item refused with an uncoded 422 is retried a few times, then refused", async () => { + const root = await tmp() + await RulesSync.enqueue(root, item("Unclear.")) + answer = () => Response.json({ detail: [{ msg: "invalid" }] }, { status: 422 }) + expect((await RulesSync.push(root, ready())).queued).toBe(1) + expect((await RulesSync.push(root, ready())).queued).toBe(1) + const last = await RulesSync.push(root, ready()) + expect(last.queued).toBe(0) + expect(last.sent[0]).toMatchObject({ status: "rejected" }) + }) + + test("a dropped usage batch marks its sessions reported, so the same batch is not rebuilt", async () => { + const root = await tmp() + answer = () => Response.json(syncBody([rule("aaaaaaaaaaaa", "Name staging models stg_.")])) + await RulesSync.pull(root, ready()) + await RulesStore.update(root, (s) => { + s.pending_usage = { batch_id: "batch-y", items: [{ public_id: "p-aaaaaaaaaaaa", applied: 1, helpful: 0, harmful: 0, corrected_again: 0 }], sessions: ["s9"], corrected: { "R-aaaaaaaaaaaa": 1 } } + }) + answer = () => Response.json({ detail: { code: "batch_conflict", message: "conflict" } }, { status: 409 }) + await RulesSync.reportUsage(root, ready()) + const state = await RulesStore.read(root) + expect(state.pending_usage).toBeUndefined() + expect(state.usage_sessions).toContain("s9") + expect(state.corrected_reported["R-aaaaaaaaaaaa"]).toBe(1) + }) + + test("too many proposals at once say so", async () => { + const root = await tmp() + const many = Array.from({ length: RulesStore.MAX_OUTBOX + 1 }, (_, i) => ({ item: item(`Rule ${i}.`) })) + await expect(RulesSync.enqueueMany(root, many)).rejects.toThrow(`${RulesStore.MAX_OUTBOX + 1} proposals are more than can wait to be sent at once`) + }) }) describe("learning decision", () => { @@ -723,7 +767,7 @@ describe("remember, whichever save tool the agent picks", () => { beforeAll(() => { delete process.env.ALTIMATE_DISABLE_WORKSPACE delete process.env.ALTIMATE_LEARN - RuleProposal.markRenderer() + RuleProposal.setRendererForTest(true) }) afterAll(() => { if (env.ALTIMATE_DISABLE_WORKSPACE !== undefined) process.env.ALTIMATE_DISABLE_WORKSPACE = env.ALTIMATE_DISABLE_WORKSPACE @@ -746,7 +790,7 @@ describe("remember, whichever save tool the agent picks", () => { expect(await RulesRemember.proposeFor({ root, directory: root, learn: undefined, content, sessionID: "r0", said })).toBeUndefined() expect(RuleProposal.list(root)).toEqual([]) } finally { - RuleProposal.markRenderer() + RuleProposal.setRendererForTest(true) } }) @@ -874,3 +918,109 @@ describe("telemetry", () => { expect(of("rule_remember_routed").map((e) => [e.via, e.outcome])).toEqual([["training", "learning_off"], ["training", "not_linked"]]) }) }) + +describe("review round three", () => { + test("organisation-managed learn config beats a committed project setting", async () => { + const root = await tmp() + const managed = await tmp() + await fs.mkdir(path.join(root, ".altimate-code"), { recursive: true }) + await fs.writeFile(path.join(root, ".altimate-code", "altimate-code.json"), JSON.stringify({ learn: { enabled: true, capture: true } })) + await fs.writeFile(path.join(managed, "altimate-code.json"), JSON.stringify({ learn: { enabled: false } })) + const env = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR + process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR = managed + try { + expect(await RulesService.effectiveLearn({ enabled: false }, root, root)).toMatchObject({ enabled: false, capture: true }) + } finally { + if (env === undefined) delete process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR + else process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR = env + } + expect(await RulesService.effectiveLearn({ enabled: false }, root, root)).toMatchObject({ enabled: true, capture: true }) + }) + + test("team rules live in workspace-rules; a team playbook is the person's own store again", async () => { + const root = await tmp() + // An earlier build: state and mirror under `team/`, the person's own team playbook set aside. + const legacy = path.join(root, ".altimate-code", "learn", "team") + await fs.mkdir(legacy, { recursive: true }) + const own = JSON.stringify([{ id: "L-000000000001", text: "Our own rule.", tags: [], scope: "project", helpful: 0, harmful: 0, applied: 0, created: "2026-01-01T00:00:00Z", updated: "2026-01-01T00:00:00Z" }]) + await fs.writeFile(path.join(legacy, "approved.json.before-workspace-rules-1"), own) + await fs.writeFile(path.join(legacy, "rules.json"), JSON.stringify({ ...linked(), rules: [rule("Name staging models stg_.")] })) + await fs.writeFile(path.join(legacy, "approved.json"), canonicalMirror()) + await fs.writeFile(path.join(legacy, ".gitignore"), "*\n") + expect((await RulesStore.read(root)).rules).toHaveLength(1) + await RulesStore.update(root, () => {}) + expect(await fs.readFile(path.join(legacy, "approved.json"), "utf8")).toBe(own) + expect(await fs.readdir(legacy)).toEqual(["approved.json"]) + expect(RulesStore.TEAM_STORE).toBe("workspace-rules") + expect(parse(await fs.readFile(Store.paths(root, RulesStore.TEAM_STORE).approved, "utf8"))).toHaveLength(1) + }) + + test("a team playbook that is not ours is left alone by the migration", async () => { + const root = await tmp() + const legacy = path.join(root, ".altimate-code", "learn", "team") + await fs.mkdir(legacy, { recursive: true }) + const own = JSON.stringify([{ id: "L-000000000001", text: "Our own rule.", tags: [], scope: "project", helpful: 0, harmful: 0, applied: 0, created: "2026-01-01T00:00:00Z", updated: "2026-01-01T00:00:00Z" }]) + await fs.writeFile(path.join(legacy, "approved.json"), own) + await RulesStore.update(root, (s) => Object.assign(s, linked())) + expect(await fs.readFile(path.join(legacy, "approved.json"), "utf8")).toBe(own) + }) + + test("with no card client attached, the after-turn check leaves corrections for later", async () => { + const root = await tmp() + await RulesStore.update(root, (s) => Object.assign(s, linked())) + await appendSignal(root, { kind: "user_correction", sessionID: "t9", text: "no, call it ordered_at", reason: "correction" }) + RuleProposal.setRendererForTest(false) + const deps = { source: async () => { throw new Error("not reached") }, generate: async () => { throw new Error("not reached") } } as unknown as RuleTrigger.TriggerDeps + await RuleTrigger.afterTurn({ sessionID: "t9", root, directory: root, learn: { enabled: true, capture: true }, deps }) + const state = await RulesStore.read(root) + expect(state.processed_signals).toEqual([]) + expect(state.last_check?.outcome).toContain("no client that shows cards") + }) + + test("a card already open in the session skips the after-turn check, and its corrections stay unprocessed", async () => { + const root = await tmp() + await RulesStore.update(root, (s) => Object.assign(s, linked())) + await appendSignal(root, { kind: "user_correction", sessionID: "t10", text: "no, call it shipped_at", reason: "correction" }) + RuleProposal.setRendererForTest(true) + try { + await RuleProposal.ask({ sessionID: "t10", root, directory: root, learn: undefined, draft: { text: "Name timestamps _at.", applies_to: [], change: "add", origin: "explicit", evidence: { sessions: 1 }, topics: [] } }) + const deps = { source: async () => { throw new Error("not reached") }, generate: async () => { throw new Error("not reached") } } as unknown as RuleTrigger.TriggerDeps + await RuleTrigger.afterTurn({ sessionID: "t10", root, directory: root, learn: { enabled: true, capture: true }, deps }) + expect((await RulesStore.read(root)).processed_signals).toEqual([]) + } finally { + RuleProposal.drop(root) + RuleProposal.setRendererForTest(false) + } + }) + + test("a dismissal stops counting once it expires, before any write drops it", () => { + const state = RulesStore.State.parse({ version: 1, dismissed: [{ topic: "ordered_at", until: "2026-01-01T00:00:00Z" }] }) + expect(RulesStore.dismissed(state, "ordered_at", Date.parse("2025-12-31T00:00:00Z"))).toBe(true) + expect(RulesStore.dismissed(state, "ordered_at", Date.parse("2026-01-02T00:00:00Z"))).toBe(false) + }) + + test("a restatement keeps the person's negation; just for me is personal; remember … for me stays in its clause", () => { + expect(RulesRemember.restates("Use FLOAT for money.", "Remember: never use FLOAT for money")).toBe(false) + expect(RulesRemember.restates("Never use FLOAT for money.", "Remember: never use FLOAT for money")).toBe(true) + expect(RuleClassify.classify("Just for me, always use trailing commas").kind).toBe("memory") + expect(RuleClassify.classify("Remember: the team always names models stg_, which is easier for me").kind).toBe("rule") + expect(RuleClassify.classify("Remember this for me: I like trailing commas").kind).toBe("memory") + }) + + test("a sync whose push failed is not ok", () => { + const events: unknown[] = [] + const spy = spyOn(Telemetry, "track").mockImplementation((event: Telemetry.Event) => void events.push(event)) + try { + const ready = { status: "ready" as const, actAs: { url: "http://x", instance: "t", apiKey: "k" }, scope: { apiUrl: "http://x", tenant: "t", account: "a", datamateId: 1, repoRemote: "https://github.com/acme/demo" } } + RulesTelemetry.synced("refresh", { context: ready, push: { sent: [], queued: 2, error: "not bound" }, pull: { ok: true, rules: 1, pending: 0, notices: [] } }, Date.now()) + } finally { + spy.mockRestore() + } + expect(events).toEqual([expect.objectContaining({ type: "rules_sync", status: "failed", push_failed: true, queued: 2 })]) + }) +}) + +function canonicalMirror() { + return JSON.stringify([{ id: "L-0123456789ab", text: "Name staging models stg_.", tags: [], scope: "project", helpful: 0, harmful: 0, applied: 0, created: "2026-10-27T00:00:00Z", updated: "2026-10-27T00:00:00Z", provenance: "workspace rule 11111111-1111-1111-1111-111111111111 v1" }]) +} + diff --git a/packages/opencode/test/server/altimate-rules-routes.test.ts b/packages/opencode/test/server/altimate-rules-routes.test.ts new file mode 100644 index 0000000000..2e4531c2f8 --- /dev/null +++ b/packages/opencode/test/server/altimate-rules-routes.test.ts @@ -0,0 +1,100 @@ +// altimate_change - new file +// +// The workspace-rules routes: `/rule-proposal` (list, reply, renderer lease) and `/altimate/rules/learning`. These +// cover the ROUTES: browser-origin refusal, input validation and status mapping. What each reply does is covered by +// test/altimate/rules/rules.test.ts. +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { Server } from "../../src/server/server" +import { RuleProposal } from "../../src/altimate/rules/proposal" +import { resetDatabase } from "./db" +import { disposeAllInstances } from "../fixture/fixture" + +const ORIGINAL_FLAG = process.env.ALTIMATE_DISABLE_WORKSPACE + +function request(method: "GET" | "POST", url: string, body?: unknown, headers: Record = {}) { + return Server.Default().request(url, { + method, + headers: { "content-type": "application/json", ...headers }, + body: body === undefined ? undefined : typeof body === "string" ? body : JSON.stringify(body), + }) +} + +// The instance directory in these tests is the process's cwd (no directory header): cards are asked for it. +const draft = (text: string) => ({ + text, + applies_to: [], + change: "add" as const, + origin: "explicit" as const, + evidence: { sessions: 1 }, + topics: [], +}) + +beforeEach(() => { + delete process.env.ALTIMATE_DISABLE_WORKSPACE +}) + +afterEach(async () => { + RuleProposal.drop(process.cwd()) + RuleProposal.setRendererForTest(false) + if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_DISABLE_WORKSPACE + else process.env.ALTIMATE_DISABLE_WORKSPACE = ORIGINAL_FLAG + await disposeAllInstances() + await resetDatabase() +}) + +describe("rule proposal routes", () => { + test("a browser origin is refused on every route", async () => { + const origin = { origin: "https://evil.example", "sec-fetch-site": "cross-site" } + for (const [method, url, body] of [ + ["GET", "/rule-proposal"], + ["POST", "/rule-proposal/x/reply", { action: "dismiss" }], + ["POST", "/rule-proposal/renderer", { client: "tui" }], + ["POST", "/altimate/rules/learning", { on: true }], + ] as const) { + const response = await request(method, url, body, origin) + expect([403, 409]).toContain(response.status) + expect(await response.json()).toMatchObject({ ok: false }) + } + }) + + test("a reply is validated, and its failure codes map to HTTP statuses", async () => { + expect((await request("POST", "/rule-proposal/x/reply", { action: "share" })).status).toBe(400) + expect((await request("POST", "/rule-proposal/x/reply", { action: "edit", text: 3 })).status).toBe(400) + expect((await request("POST", "/rule-proposal/x/reply", "not json")).status).toBe(400) + const gone = await request("POST", "/rule-proposal/no-such-card/reply", { action: "dismiss" }) + expect(gone.status).toBe(404) + expect(await gone.json()).toMatchObject({ ok: false, code: "not_open" }) + + const root = await fs.mkdtemp(path.join(os.tmpdir(), "rules-routes-")) + const card = (await RuleProposal.ask({ sessionID: "r1", root, directory: process.cwd(), learn: undefined, draft: draft("Prefix seeds with seed_.") }))! + const unavailable = await request("POST", `/rule-proposal/${card.id}/reply`, { action: "approve" }) + expect(unavailable.status).toBe(409) + expect(await unavailable.json()).toMatchObject({ ok: false, code: "not_available" }) + const invalid = await request("POST", `/rule-proposal/${card.id}/reply`, { action: "edit", text: "line one\nline two" }) + expect(invalid.status).toBe(422) + expect(await invalid.json()).toMatchObject({ ok: false, code: "invalid_text" }) + const listed = await request("GET", "/rule-proposal?session=r1") + expect(await listed.json()).toEqual([expect.objectContaining({ id: card.id })]) + }) + + test("a renderer lease is validated, and attaches a renderer", async () => { + expect(RuleProposal.rendererAttached()).toBe(false) + expect((await request("POST", "/rule-proposal/renderer", { client: "has spaces" })).status).toBe(400) + expect((await request("POST", "/rule-proposal/renderer", {})).status).toBe(400) + expect(RuleProposal.rendererAttached()).toBe(false) + const response = await request("POST", "/rule-proposal/renderer", { client: "vscode" }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true, renew_ms: RuleProposal.RENDERER_RENEW_MS, lease_ms: RuleProposal.RENDERER_LEASE_MS }) + expect(RuleProposal.rendererAttached()).toBe(true) + // A lease lapses when it is not renewed. + expect(RuleProposal.rendererAttached(Date.now() + RuleProposal.RENDERER_LEASE_MS + 1)).toBe(false) + }) + + test("the learning choice accepts only true, false or null", async () => { + expect((await request("POST", "/altimate/rules/learning", { on: "yes" })).status).toBe(400) + expect((await request("POST", "/altimate/rules/learning", {})).status).toBe(400) + }) +}) From 0d4bfd4b5edc5a34b30a431fae1123d57083aa9b Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 29 Oct 2026 20:25:02 +0530 Subject: [PATCH 14/20] =?UTF-8?q?fix:=20workspace=20rules=20=E2=80=94=20fo?= =?UTF-8?q?urth=20review=20round=20and=20self-review,=20as=20one=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cards and the save tools: - Cards are raised only while a client holds a renderer lease for that project directory (`POST /rule-proposal/renderer`), and only in top-level sessions; otherwise `training_save` and `altimate_memory_write` save as before cards, and the after-turn check leaves corrections for later - A "remember" card that closes unanswered (24h expiry, or its instance going away) is saved as before cards, through a fallback bound to the asking instance; `training_save`'s fallback keeps the agent's name, source and citations - A card closed while its reply runs is not saved twice: the reply's outcome decides, and the fallback runs only if the reply fails - `RuleProposal.drop` resolves once the fallbacks have landed, and the instance disposer awaits it - "Save as" cards carry `memory_scope`, so clients label Memory with where it is actually saved (the memory tool's block always goes to project memory) - The after-turn "card already open" check and expiry are scoped to the project's directory - A workspace refusal of a card's content is `refused` (422), not a 500 Sync: - The outbox belongs to the signed-in user (`user:` from `GET /users/me`), not the API key: a rotated key or a fresh sign-in keeps it - An account or workspace change is adopted in one locked write: the dropped proposals are counted in their own `scope_drop` notice, which a later pull does not erase, and a run still finishing for the old scope does not write into the new state - What a push sent is recorded before any "Approve now", with retries when the learn lock is busy, so a sent proposal always leaves the outbox - A request-wide uncoded 400/422 is split item by item once; the same outbox then goes as one request until it changes - Review notices are announced once, by the run that pulled them, whoever waits on it (timer, "Sync now", session start, a card reply, `learn sync`) - Reported usage pairs are kept while their session has a snapshot and let go after, instead of a count cap that could re-count resumed sessions - An empty team-rules mirror is replaced without a backup or warning - `learn` keys set above project files (home and `OPENCODE_CONFIG_DIR` config, inline config, the account, managed config, MDM) beat the project file re-read from disk; `capture` set there beats the person's `/rules` choice (`organization`) - One sender per checkout across processes (`rules-push` file lock); usage counted once per (session, rule version) Terminal and CLI: - Server and model text is passed through the shared `printable` everywhere it reaches the terminal (card, menu, toasts, `learn` output) - The TUI renews its lease with a disposable loop and releases its listeners, timers and reactive root on dispose - `learn sync` exits 1 when proposals stay queued with an error Tests cover each failure path above, including a full `run()` across an account change, concurrent callers of one sync, a state lock that is busy after sending, the workspace's 50-pending limit during bootstrap, real card expiry, the real `proposeTeamRule` fallback, and the config-layer precedence through real config loading. Co-Authored-By: Claude Opus 5.5 --- docs/docs/configure/learn.md | 4 +- docs/docs/reference/telemetry.md | 8 +- .../opencode/src/altimate/learn/delivery.ts | 11 + .../opencode/src/altimate/rules/events.ts | 2 + .../src/altimate/rules/from-history.ts | 34 +- .../opencode/src/altimate/rules/printable.ts | 11 + .../opencode/src/altimate/rules/proposal.ts | 130 +++- .../opencode/src/altimate/rules/remember.ts | 42 +- .../opencode/src/altimate/rules/service.ts | 85 +-- packages/opencode/src/altimate/rules/store.ts | 106 ++- .../opencode/src/altimate/rules/summary.ts | 6 +- packages/opencode/src/altimate/rules/sync.ts | 330 +++++++--- .../opencode/src/altimate/rules/telemetry.ts | 1 + .../opencode/src/altimate/rules/trigger.ts | 20 +- .../opencode/src/altimate/telemetry/index.ts | 7 +- .../src/altimate/tools/training-save.ts | 2 +- packages/opencode/src/cli/cmd/learn.ts | 34 +- packages/opencode/src/config/config.ts | 36 + .../src/plugin/tui/altimate/rules.tsx | 137 ++-- packages/opencode/src/server/server.ts | 50 +- packages/opencode/src/session/prompt.ts | 33 +- .../test/altimate/learn/capture-bus.test.ts | 36 +- .../test/altimate/rules/rules.test.ts | 614 +++++++++++++++--- packages/opencode/test/config/config.test.ts | 27 + .../test/server/altimate-rules-routes.test.ts | 26 +- 25 files changed, 1329 insertions(+), 463 deletions(-) create mode 100644 packages/opencode/src/altimate/rules/printable.ts diff --git a/docs/docs/configure/learn.md b/docs/docs/configure/learn.md index c4d3e946fc..254778c34b 100644 --- a/docs/docs/configure/learn.md +++ b/docs/docs/configure/learn.md @@ -195,7 +195,7 @@ In a project linked to an Altimate workspace (`altimate-code link`), what you te - you correct a team rule the session was shown (the card proposes changing it, with the current text struck through); - you ask the agent to remember something as a rule (the card appears as soon as the agent saves it, always as a new rule: changing a team rule comes from correcting it). -Cards are shown by the TUI and the VS Code extension. Where no attached client shows cards (`altimate-code run`, ACP, the Claude Code and Codex plugins), 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. +Cards are shown by the TUI and the VS Code extension, 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 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 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, so the request is never lost. 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. @@ -205,7 +205,7 @@ The card never blocks the input: keep typing, and answer it when you are ready. **After review.** Approved rules reach every session within a few minutes (a pull at session start and every 5 minutes; `/rules` → Sync now, or `altimate-code learn sync`). You see a notice when the owner approves or rejects your rule, with the reason. Usage is reported back to the workspace: how many sessions a rule was applied in, and how often you corrected the agent on its topic after it was shown ("corrected again"). -**Learning on or off.** In order: the learning kill switch (`learn.enabled`, `ALTIMATE_LEARN`), `ALTIMATE_LEARN_CAPTURE`, your `/rules` → "Turn learning on/off for me" choice, `learn.capture` (`learn enable` / `learn disable`), then the workspace's "Learn from sessions". The `/rules` choice is yours alone: it is kept in the project's git-ignored rules state, not in the project config, so it never reaches a teammate through a commit ("Follow the workspace setting again" clears it). It takes effect right away. Approved rules are delivered either way. +**Learning on or off.** In order: the learning kill switch (`learn.enabled`, `ALTIMATE_LEARN`), `ALTIMATE_LEARN_CAPTURE`, a `learn.capture` your organization sets (managed config, MDM, the account's config, `OPENCODE_CONFIG_CONTENT`, `OPENCODE_CONFIG_DIR` or the home `~/.altimate-code` config; shown as "organization policy"), your `/rules` → "Turn learning on/off for me" choice, `learn.capture` (`learn enable` / `learn disable`), then the workspace's "Learn from sessions". The `/rules` choice is yours alone: it is kept in the project's git-ignored rules state, not in the project config, so it never reaches a teammate through a commit ("Follow the workspace setting again" clears it). It takes effect right away. Approved rules are delivered either way. **Commands.** `/rules` in the TUI lists team rules and your proposals with their status. `altimate-code learn sync` syncs now; `learn status` shows the workspace rules line, your learning setting and the last proposal check. In a linked project, `learn bootstrap` and `learn import-reviews` list the rules they staged from history and ask before sending them to the owner's review in the named workspace (default no). Without a terminal, `--send-for-review` sends them; `--yes` only confirms the model step. Declined rules stay local candidates (`learn show`, `learn promote`). diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index bfa53307eb..a6f16f4456 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -75,12 +75,12 @@ We collect the following categories of events: | `review_run` | A dbt/SQL review completed or failed — `invocation` (`cli` for `altimate-code review`, `tool` for the `dbt_pr_review` tool), status, duration, and on success the verdict, the pre-gating verdict, mode, risk tier, and finding counts by severity and by category. No file paths, model or column names, finding titles or bodies, SQL, diff content, or repository/branch/PR names. | | `review_post_outcome` | Whether a review was published to GitHub — `not_requested`, `not_attempted`, `target_unresolved`, `full`, `partial`, or `summary_failed`, plus duration. Emitted on the **CLI path only** — the `dbt_pr_review` tool completes reviews but never publishes, so a `review_run` with `invocation: tool` has no post event and that is not a failure. Within the CLI path there is exactly one per **completed** review: a review that failed emits `review_run: failed` and no post event, so absence there means the review failed rather than that an event was lost. `not_attempted` is publication requested but never reached (a bad `--output` path, a stdout write error). No repository, PR, or comment content. | | `rule_proposal_shown` | A proposed-rule card was shown (or shown again for the same wording, `repeat`) — what raised it (`remember`, `shown_rule`, `recurrence`), the kind of change, whether it is a "Save as" choice, linked/owner, the buttons offered, whether it weakens verification, evidence session count, whether it came from a correction, how many paths it applies to, and its length. The card id is random. No rule text, quotes, paths or globs. | -| `rule_proposal_answered` | A card was answered or closed (`send`, `approve`, `approve_anyway`, `edit`, `dismiss`, `save_local`, `save_memory`, `expired`, `closed`) — `ok` or why it did not go through (`busy`, `not_available`, `verification_flagged`, `invalid_text`, `failed`), what happened (`sent`, `queued`, `approved`, `approve_failed`, `approve_retry`, `target_changed`, `exists`, `dismissed`, `saved_memory_project`, `saved_memory_global`, `saved_local`), wording edits so far, and time since the card was shown. No rule text. | +| `rule_proposal_answered` | A card was answered or closed (`send`, `approve`, `approve_anyway`, `edit`, `dismiss`, `save_local`, `save_memory`, `expired`, `closed`) — `ok` or why it did not go through (`busy`, `not_available`, `verification_flagged`, `invalid_text`, `refused` by the workspace, `failed`), what happened (`sent`, `queued`, `approved`, `approve_failed`, `approve_retry`, `target_changed`, `exists`, `dismissed`, `saved_memory_project`, `saved_memory_global`, `saved_local`, or `saved_unanswered` when a card the person asked for closed unanswered and was saved as before cards), wording edits so far, and time since the card was shown. No rule text. | | `rule_proposal_skipped` | Corrections were checked after a turn and no card was shown — `card_open`, `no_renderer` (no attached client shows cards; the corrections are kept for a later check), `covered`, `no_draft`, `topic_dismissed`, `no_topic` or `failed`. The path checked (`shown_rule` or `recurrence`) is omitted when the check stopped before choosing one. | -| `rule_remember_routed` | Where a "remember …" save went — through `training_save` or the memory tool, to a card, a "Save as" card, memory, refused as not a one-line rule, or saved as before cards because learning/workspaces are off, the project is not linked, or no attached client shows cards. No text. | -| `rules_sync` | One rules sync — what started it (`refresh`, `session_start`, `manual`, `card`, `cli`, `import`), `ok` (the pull worked and every proposal to send went out), `failed` (a failed pull, proposals kept queued, or a run that threw: reason `error`), `off` or `skipped` with a fixed reason code, duration, and counts of rules, pending reviews, proposals sent, refused and still queued, review notices, and usage items. No rule text, error text, URLs or workspace names. | +| `rule_remember_routed` | Where a "remember …" save went — through `training_save` or the memory tool, to a card, a "Save as" card, memory, refused as not a one-line rule, or saved as before cards because learning/workspaces are off, the project is not linked, or no attached client shows cards for this project (or the session is a subagent's). No text. | +| `rules_sync` | One rules sync — what started it (`refresh`, `session_start`, `manual`, `card`, `cli`, `import`), `ok` (the pull worked and no proposal was kept queued; proposals the workspace refused still count as `ok`, under `refused`), `failed` (a failed pull, proposals kept queued, or a run that threw: reason `error`), `off` or `skipped` with a fixed reason code, duration, counts of rules, pending reviews, proposals sent, refused and still queued, review notices and usage items, and whether a retried "Approve now" failed (`approval_failed`). No rule text, error text, URLs or workspace names. | | `rules_delivered` | Rules added to a session's context — at the start, for a request, or for a file the agent touched — with the number of rules (and how many are team rules), their total length, the session's running total, and the token budget. No rule text or file paths. | -| `rules_learning_changed` | The person turned learning from sessions on or off for a project, or back to the workspace's setting, with the resulting state and who decides it. | +| `rules_learning_changed` | The person turned learning from sessions on or off for a project, or back to the workspace's setting, with the resulting state and who decides it (`organization`, `you`, `workspace` or `default`). | | `rules_command` | `/rules` opened in a client (`view`), or a `learn` subcommand run (its name only, never its arguments). | Each event includes a timestamp, session ID, a per-launch correlation ID (`launch_id` — a random value regenerated every process start, not persisted and not derived from your machine or identity; it exists only to group events from the same run), CLI version, and a machine ID (a random UUID stored in `~/.altimate/machine-id`, generated once and not derived from your hardware or identity). When the machine is signed in to Altimate, each event also carries `user_id` and `tenant`, described under [User Identification](#new-user-identification). diff --git a/packages/opencode/src/altimate/learn/delivery.ts b/packages/opencode/src/altimate/learn/delivery.ts index 20f55a4b7a..db5ba74efc 100644 --- a/packages/opencode/src/altimate/learn/delivery.ts +++ b/packages/opencode/src/altimate/learn/delivery.ts @@ -88,6 +88,17 @@ export type ShownSnapshot = { shown: { name: string; id: string; textHash: string; tier: Shown["tier"]; at: string }[] } +/** The snapshot file names (`.json`) present now, all of them: a session whose file is gone can never be + * read again. */ +export async function snapshotFiles(root: string): Promise> { + const dir = path.join(root, ".altimate-code", "learn", ".sessions") + await SafeFS.assertSafePath(root, dir) + return new Set(await fs.readdir(dir)) +} + +/** The snapshot file name of `session`. */ +export const snapshotFile = (session: string) => Store.sha256(session) + ".json" + export async function readShownSnapshots(root: string, options: { limit?: number } = {}) { const dir = path.join(root, ".altimate-code", "learn", ".sessions") const coverage = { sessions_analyzed: 0, sessions_skipped_malformed: 0, truncated: false } diff --git a/packages/opencode/src/altimate/rules/events.ts b/packages/opencode/src/altimate/rules/events.ts index 0e2d7b0cc4..f107ccb36b 100644 --- a/packages/opencode/src/altimate/rules/events.ts +++ b/packages/opencode/src/altimate/rules/events.ts @@ -26,6 +26,8 @@ export const ProposalCard = z.object({ 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(), + /** Where "Memory" saves it: this project's memory, or the person's own for all their projects. Set with `choose`. */ + memory_scope: z.enum(["project", "global"]).optional(), /** Topics a "Not a rule" suppresses for 30 days. */ topics: z.array(z.string()), }) diff --git a/packages/opencode/src/altimate/rules/from-history.ts b/packages/opencode/src/altimate/rules/from-history.ts index 70f5b309ce..64e3f1b91d 100644 --- a/packages/opencode/src/altimate/rules/from-history.ts +++ b/packages/opencode/src/altimate/rules/from-history.ts @@ -36,7 +36,10 @@ export async function stagedRules(input: { origin: "bootstrap" | "import_reviews" /** `candidateTexts` before the run. */ before: Set | undefined + /** The effective learn config: with learning or workspaces off nothing could be sent, so nothing is offered. */ + learn: Parameters[2] }): Promise { + if (!RulesSync.rulesEnabled(input.learn)) return undefined const state = await RulesStore.read(input.root) if (!state.workspace) return undefined const [approved, candidate] = await Promise.all([ @@ -88,29 +91,44 @@ export async function sendStaged(input: { staged: Staged /** `candidateTexts` before the run: a candidate that existed before is never discarded. */ before: Set | undefined -}): Promise<{ sent: number; queued: number; refused: number; message: string }> { +}): Promise<{ sent: number; queued: number; refused: number; lost: number; message: string }> { const ids = await RulesSync.enqueueMany(input.root, input.staged.items.map((item) => ({ item }))) - const report = await RulesSync.runFresh(input.root, input.directory, input.learn, "import") - const state = await RulesStore.read(input.root) + // A push sends at most a batch: keep sending until these proposals have left the outbox, or a run sends nothing + // more (not ready, or kept back with an error). Bounded by the number of batches they need, plus one. + let report = await RulesSync.runFresh(input.root, input.directory, input.learn, "import") + let state = await RulesStore.read(input.root) + for (let round = 1; round <= Math.ceil(ids.length / RulesSync.MAX_PROPOSALS); round++) { + const waiting = ids.some((id) => state.outbox.some((entry) => entry.id === id)) + if (!waiting || report.context.status !== "ready" || report.push?.error) break + report = await RulesSync.runFresh(input.root, input.directory, input.learn, "import") + state = await RulesStore.read(input.root) + } const outcomes = ids.map((id) => RulesSync.outcomeOf(state, report, id)) const queued = outcomes.filter((o) => o.status === "queued").length const refused = outcomes.filter((o) => o.status === "sent" && o.row?.error).length - const sent = outcomes.length - queued - refused + const sent = outcomes.filter((o) => o.status === "sent" && o.row && !o.row.error).length + // Neither queued nor answered: dropped before sending (the account or workspace changed under it). + const lost = outcomes.length - queued - refused - sent // The workspace's review queue replaces a candidate this run created, once it holds every rule of it: only when the // candidate is nothing but the adds that were sent (no local edits or removals, nothing invalid, refused or queued). - const discard = !input.before && queued === 0 && refused === 0 && input.staged.invalid.length === 0 && + const discard = !input.before && queued === 0 && refused === 0 && lost === 0 && input.staged.invalid.length === 0 && input.staged.localChanges === 0 if (discard) await Store.reject(input.root, input.name) const { owner, name } = input.staged.workspace + // Why some stayed queued, as the run said it: sync not ready (not signed in, link lost …), or kept back with an error. + const why = report.context.status !== "ready" + ? report.context.reason + : report.push?.error ?? "the workspace could not be reached" const message = `${sent} ${sent === 1 ? "rule" : "rules"} sent for ${owner}'s review in ${name}` + - (queued ? `; ${queued} queued until the workspace is reachable` : "") + - (refused ? `; ${refused} refused by the workspace` : "") + "." + + (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"})` : "") + "." + (discard ? "" : input.staged.localChanges ? ` The local candidate was kept: it also changes ${input.staged.localChanges} local ${input.staged.localChanges === 1 ? "rule" : "rules"} (\`learn show\`, \`learn promote\`).` : " The local candidate was kept: `learn show`.") - return { sent, queued, refused, message } + return { sent, queued, refused, lost, message } } export * as RulesFromHistory from "./from-history" diff --git a/packages/opencode/src/altimate/rules/printable.ts b/packages/opencode/src/altimate/rules/printable.ts new file mode 100644 index 0000000000..eb31ca9405 --- /dev/null +++ b/packages/opencode/src/altimate/rules/printable.ts @@ -0,0 +1,11 @@ +// altimate_change - new file +// +// Text from the workspace server or a model (a rule, a reviewer's name or reason, an error, a staged rule) shown in the +// terminal: the CLI's output and the TUI's card, menu and toasts. One helper so the two cannot drift. +import { stripBidiControls } from "../workspace/workspace-name" + +/** `text` on one line and inert: control characters (C0, DEL, C1) and the line/paragraph separators become spaces, and + * bidi controls are removed, so it cannot move the cursor, retitle the terminal, plant a hyperlink or reorder itself. */ +export function printable(text: string | undefined): string { + return stripBidiControls((text ?? "").replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]/g, " ")) +} diff --git a/packages/opencode/src/altimate/rules/proposal.ts b/packages/opencode/src/altimate/rules/proposal.ts index bae8737797..197d3a78f1 100644 --- a/packages/opencode/src/altimate/rules/proposal.ts +++ b/packages/opencode/src/altimate/rules/proposal.ts @@ -14,6 +14,7 @@ import { Bus } from "@/bus" import { Log } from "@/util/log" import { lint, normalizeText, verificationWarning } from "../learn/curator" import { redactSecrets } from "../learn/digest" +import { RuleClassify } from "./classify" import { RuleEvents, type ProposalCard } from "./events" import { RulesStore } from "./store" import { RulesSync } from "./sync" @@ -52,47 +53,88 @@ interface Pending { /** The session the evidence came from: the card can move to another session, its evidence does not. */ sourceSession: string memory?: Draft["memory"] + /** For a card the person asked for: saves it as it would have been saved without a card, when the card closes + * unanswered (it expired, or its instance went away). Bound to the asking instance. */ + unanswered?: (text: string) => Promise + /** Closed (expired, or its instance went away) while a reply was running: the reply settles it when it ends. */ + closed?: { message: string; why: "expired" | "closed"; here: string | undefined } } /** An unanswered card lasts a day; after that it is dropped rather than re-offered to every new client. */ export const CARD_TTL_MS = 24 * 60 * 60_000 -/** Remove a card no client can answer any more, and tell clients so they stop showing it. */ -function close(id: string, entry: Pending, message: string, why: "expired" | "closed", here: string | undefined) { +/** Remove a card no client can answer any more, and tell clients so they stop showing it. Resolves once a requested + * card's fallback save has landed. A card whose reply is running is only marked: the reply's outcome decides. */ +function close(id: string, entry: Pending, message: string, why: "expired" | "closed", here: string | undefined): Promise { pending.delete(id) - RulesTelemetry.answered({ card: entry.card, trigger: entry.trigger, action: why, status: "ok", edits: entry.edits, shownAt: entry.at }) + if (entry.busy) { + entry.closed = { message, why, here } + return Promise.resolve() + } + return settle(id, entry, message, why, here) +} + +/** A closed card nobody answered: save a requested one as before cards, record it, tell its clients. */ +function settle(id: string, entry: Pending, message: string, why: "expired" | "closed", here: string | undefined): Promise { + // A "remember" the person asked for is never silently lost: unanswered, it is saved as it was before cards. + const saved = entry.unanswered + ? entry.unanswered(entry.card.text).catch((error) => log.warn("unanswered card not saved", { id, error })) + : Promise.resolve() + RulesTelemetry.answered({ + card: entry.card, + trigger: entry.trigger, + action: why, + status: "ok", + ...(entry.unanswered ? { outcome: "saved_unanswered" as const } : {}), + edits: entry.edits, + shownAt: entry.at, + }) // The Bus is per project: announced from another project's request it would reach the wrong clients. The card's own // clients then learn it is gone from their next list or reply (`not_open`). if (here === entry.directory) void publish(RuleEvents.Event.Replied, { id, sessionID: entry.card.sessionID, action: "expired", message }) + return saved } -// A client that renders cards (the TUI, the IDE extension) holds a lease: it calls `POST /rule-proposal/renderer` when -// it starts and every `RENDERER_RENEW_MS` while it runs. While no lease is live, nothing would show a card: the save -// tools save as they did before cards, and the after-turn check waits (`rendererAttached`). A client that went away -// stops renewing, so its lease lapses; one that failed to register retries on the next renewal. +/** After a reply that did not close the card: if the card was closed meanwhile, settle it now. */ +async function settleIfClosed(id: string, entry: Pending): Promise { + if (!entry.closed) return undefined + const { message, why, here } = entry.closed + await settle(id, entry, message, why, here) + return { ok: false, code: "not_open", error: "This proposal is no longer open." } +} + +// A client that renders cards (the TUI, the IDE extension) holds a lease for the project it shows: it calls +// `POST /rule-proposal/renderer` (in that project's directory) when it starts and every `RENDERER_RENEW_MS` while it runs. +// While no lease is live for a directory, nothing would show its cards: the save tools save as they did before cards, and +// the after-turn check waits (`rendererAttached`). A client that went away stops renewing, so its lease lapses; one that +// failed to register retries on the next renewal. A lease for one project says nothing about another one served by the +// same process. export const RENDERER_RENEW_MS = 30_000 export const RENDERER_LEASE_MS = 3 * RENDERER_RENEW_MS const RENDERERS = Symbol.for("altimate.rules.proposal.renderers") const renderers = ((globalThis as Record)[RENDERERS] ??= new Map()) as Map +const leaseKey = (directory: string, client: string) => `${directory}\0${client}` -/** A client that shows proposal cards holds a live lease in this process. */ -export function rendererAttached(now = Date.now()) { - for (const [client, until] of renderers) { - if (until > now) return true - renderers.delete(client) +/** A client that shows proposal cards holds a live lease for `directory`. */ +export function rendererAttached(directory: string, now = Date.now()) { + let live = false + for (const [key, until] of renderers) { + if (until <= now) renderers.delete(key) + else if (key.startsWith(`${directory}\0`)) live = true } - return false + return live } -/** `POST /rule-proposal/renderer`: `client` shows cards for the next `RENDERER_LEASE_MS`. */ -export function renewRenderer(client: string, now = Date.now()) { - renderers.set(client, now + RENDERER_LEASE_MS) +/** `POST /rule-proposal/renderer`: `client` shows cards of `directory` for the next `RENDERER_LEASE_MS`. */ +export function renewRenderer(directory: string, client: string, now = Date.now()) { + renderers.set(leaseKey(directory, client), now + RENDERER_LEASE_MS) } -export function setRendererForTest(attached: boolean) { +/** For tests: a lease for `directory` that never lapses, or none at all. */ +export function setRendererForTest(directory: string | undefined) { renderers.clear() - if (attached) renderers.set("test", Number.MAX_SAFE_INTEGER) + if (directory !== undefined) renderers.set(leaseKey(directory, "test"), Number.MAX_SAFE_INTEGER) } /** Drop cards past `CARD_TTL_MS`. `here`: the directory of the request doing it (its project's clients are told). */ @@ -100,9 +142,13 @@ function expire(here: string | undefined, now = Date.now()) { for (const [id, entry] of pending) if (now - entry.at > CARD_TTL_MS) close(id, entry, "This proposal expired.", "expired", here) } -/** Drop the cards of a project (its instance is going away; nobody can answer them after that). */ -export function drop(directory: string) { - for (const [id, entry] of pending) if (entry.directory === directory) close(id, entry, "This proposal was closed.", "closed", directory) +/** Drop the cards of a project (its instance is going away; nobody can answer them after that). Resolves once the + * requested cards among them are saved, so the instance is not torn down under those writes. */ +export async function drop(directory: string) { + const saves: Promise[] = [] + for (const [id, entry] of pending) + if (entry.directory === directory) saves.push(close(id, entry, "This proposal was closed.", "closed", directory)) + await Promise.allSettled(saves) } // Process-global like `question`'s registry: the server routes and the session loop may load this module @@ -143,11 +189,14 @@ function build(id: string, sessionID: string, draft: Draft, state: RulesStore.St ...(flag ? { weakens_verification: flag } : {}), ...(linked ? { workspace: state.workspace! } : {}), actions, - ...(draft.choose ? { choose: true } : {}), + ...(draft.choose ? { choose: true, memory_scope: memoryScopeOf(draft.text, draft.memory) } : {}), topics: draft.topics.map((t) => t.toLowerCase()), } } +/** Where "Memory" saves: the memory tool's block in its scope (project), otherwise as the wording reads. */ +const memoryScopeOf = (text: string, memory: Draft["memory"]) => (memory ? "project" : RuleClassify.memoryScope(text)) + /** Show a card for `draft` in `sessionID`. Returns the card, or undefined when its topic was dismissed. * `trigger` (telemetry) defaults from the draft's origin. */ export async function ask(input: { @@ -157,6 +206,7 @@ export async function ask(input: { learn: Pending["learn"] draft: Draft trigger?: CardTrigger + unanswered?: Pending["unanswered"] }) { expire(input.directory) const trigger = input.trigger ?? (input.draft.origin === "explicit" ? "remember" : "recurrence") @@ -169,19 +219,25 @@ export async function ask(input: { if (existing.root === input.root && existing.card.text.toLowerCase() === input.draft.text.toLowerCase()) { // Asked again: the card moves to the session that asked (clients show cards per session), and a client that // missed it gets it back. + // The latest request's memory block and fallback win, whichever session asked. + if (input.draft.memory) { + existing.memory = input.draft.memory + // The memory tool's block goes to project memory: the card says so. + if (existing.card.memory_scope) existing.card = { ...existing.card, memory_scope: "project" } + } + if (input.unanswered) existing.unanswered = input.unanswered if (existing.card.sessionID !== input.sessionID) { // The asking session's project directory and config answer it from now on; the evidence keeps its session. existing.card = { ...existing.card, sessionID: input.sessionID } existing.directory = input.directory existing.learn = input.learn - if (input.draft.memory) existing.memory = input.draft.memory await publish(RuleEvents.Event.Updated, existing.card) } else await publish(RuleEvents.Event.Asked, existing.card) RulesTelemetry.shown(existing.card, existing.trigger, true) return existing.card } const card = build(randomUUID(), input.sessionID, input.draft, state) - pending.set(card.id, { card, root: input.root, directory: input.directory, learn: input.learn, at: Date.now(), trigger, edits: 0, sourceSession: input.sessionID, memory: input.draft.memory }) + pending.set(card.id, { card, root: input.root, directory: input.directory, learn: input.learn, at: Date.now(), trigger, edits: 0, sourceSession: input.sessionID, memory: input.draft.memory, unanswered: input.unanswered }) RulesTelemetry.shown(card, trigger, false) await publish(RuleEvents.Event.Asked, card) return card @@ -201,7 +257,10 @@ export type ReplyAction = ProposalCard["actions"][number] | "approve_anyway" /** Why a reply did not go through, for clients and HTTP status: the card is gone, the request does not fit this * card (or another reply is running), a flagged card was approved without confirming, the new wording failed the * lint, or the action itself failed. */ -export type ReplyFailure = "not_open" | "busy" | "not_available" | "verification_flagged" | "invalid_text" | "failed" +export type ReplyFailure = "not_open" | "busy" | "not_available" | "verification_flagged" | "invalid_text" | "refused" | "failed" + +/** The workspace refused the proposal's content (a client-side condition, HTTP 422): the card stays open to edit. */ +class RefusedError extends Error {} export type ReplyResult = | { ok: true; message: string; card?: ProposalCard; closed: boolean } | { ok: false; code: ReplyFailure; error: string; card?: ProposalCard } @@ -271,6 +330,8 @@ export async function reply(id: string, input: { action: ReplyAction; text?: str } finally { entry.busy = false } + const gone = await settleIfClosed(id, entry) + if (gone) return gone track("ok") return { ok: true, message: "Wording updated.", card: next, closed: false } } @@ -282,9 +343,12 @@ export async function reply(id: string, input: { action: ReplyAction; text?: str message = done.message track("ok", done.outcome) } catch (error) { - log.warn("rule proposal reply failed", { action: input.action, error }) - track("failed") - return { ok: false, code: "failed", error: `Not done: ${error instanceof Error ? error.message : String(error)}`, card: entry.card } + const code = error instanceof RefusedError ? "refused" : "failed" + if (code === "failed") log.warn("rule proposal reply failed", { action: input.action, error }) + track(code) + entry.busy = false + // Closed while this ran: nothing was saved, so the requested card falls back to saving as before cards. + return (await settleIfClosed(id, entry)) ?? { ok: false, code, error: `Not done: ${error instanceof Error ? error.message : String(error)}`, card: entry.card } } finally { entry.busy = false } @@ -306,10 +370,10 @@ async function act(entry: Pending, action: Exclude): Promis }) message = card.topics.length ? "Not a rule: it won't be proposed again for 30 days." : "Dismissed: nothing was saved." } else if (action === "save_memory") { - const [{ MemoryStore }, { RuleClassify }] = await Promise.all([import("../../memory/store"), import("./classify")]) - // A card from the memory tool saves the block it was writing, in its scope (project): the classifier's guess - // could put that block id into personal memory and overwrite an unrelated block there. - const scope = entry.memory ? "project" : RuleClassify.memoryScope(card.text) + const { MemoryStore } = await import("../../memory/store") + // The scope the card showed. A card from the memory tool saves the block it was writing, in its scope (project): + // the classifier's guess could put that block id into personal memory and overwrite an unrelated block there. + const scope = entry.memory ? "project" : (card.memory_scope ?? RuleClassify.memoryScope(card.text)) const id = entry.memory?.id ?? `notes/${slug(card.text)}` const tags = entry.memory?.tags.length ? entry.memory.tags : ["remembered"] const now = new Date().toISOString() @@ -358,7 +422,7 @@ async function act(entry: Pending, action: Exclude): Promis outcome = "target_changed" } else if (sent.row?.error) { // Refused (e.g. an invalid wording): the card stays open so the wording can be edited and sent again. - throw new Error(`not sent: ${sent.row.error}`) + throw new RefusedError(`the workspace refused it: ${sent.row.error}`) } else if (sent.row?.status === "exists") { message = "Already a team rule (or waiting for review): nothing new sent." outcome = "exists" diff --git a/packages/opencode/src/altimate/rules/remember.ts b/packages/opencode/src/altimate/rules/remember.ts index 17aeb49ee5..fee3ad3766 100644 --- a/packages/opencode/src/altimate/rules/remember.ts +++ b/packages/opencode/src/altimate/rules/remember.ts @@ -46,6 +46,8 @@ export async function proposeTeamRule( said?: string, via: "training" | "memory" = "training", memory?: { id: string; tags: string[] }, + /** From `training_save`: the entry it meant to write, saved as such if the card closes unanswered. */ + training?: { name: string; source?: string; citations?: import("../../memory/types").MemoryBlock["citations"] }, ) { const [{ Instance }, { Config }, { RulesService }] = await Promise.all([ import("../../project/instance"), @@ -58,7 +60,33 @@ export async function proposeTeamRule( const root = await fs.realpath(raw).catch(() => raw) // The project's learn file as it is now, like every other entry point: `learn disable` mid-session takes effect. const learn = await RulesService.effectiveLearn((await Config.get()).learn, root, Instance.directory) - return proposeFor({ root, directory: Instance.directory, learn, content, sessionID, said, via, memory }) + // A subagent's session is not the one a client shows: its card would never be seen. + const subagent = await import("@/session") + .then(async ({ Session }) => { + const { SessionID } = await import("@/session/schema") + return !!(await Session.get(SessionID.make(sessionID))).parentID + }) + .catch(() => false) + // Bound to this instance, so it saves into this project whenever the card closes unanswered. + const unanswered = Instance.bind(async (text: string) => { + const now = new Date().toISOString() + if (via === "memory" && memory) { + const { MemoryStore } = await import("../../memory/store") + await MemoryStore.write({ id: memory.id, scope: "project", tags: memory.tags, created: now, updated: now, content: text }, { sessionID }) + return + } + const [{ TrainingStore }, { RuleProposal }] = await Promise.all([import("../training"), import("./proposal")]) + await TrainingStore.save({ + kind: "rule", + name: training?.name ?? RuleProposal.slug(text), + scope: "project", + content: text, + source: training?.source ?? "remember (card closed unanswered)", + ...(training?.citations ? { citations: training.citations } : {}), + sessionID, + }) + }) + return proposeFor({ root, directory: Instance.directory, learn, content, sessionID, said, via, memory, subagent, unanswered }) } /** `proposeTeamRule` for an explicit project (`root`, `directory`) and learn config. */ @@ -72,6 +100,10 @@ export async function proposeFor(input: { via?: "training" | "memory" /** From the memory tool: the block it meant to write, used if the person picks Memory on the card. */ memory?: { id: string; tags: string[] } + /** The session is a subagent's: no client shows its cards. */ + subagent?: boolean + /** Saves the request as before cards if its card closes unanswered. */ + unanswered?: (text: string) => Promise }) { const { root, learn, content, sessionID, said } = input const via = input.via ?? "training" @@ -91,8 +123,8 @@ export async function proposeFor(input: { // Nothing attached to this process shows cards (`altimate-code run`, ACP, a plugin): a card would be lost, so the // tool saves as it did before cards (memory, or project training). const { RuleProposal } = await import("./proposal") - if (!RuleProposal.rendererAttached()) return void RulesTelemetry.routed(sessionID, via, "no_renderer") - const result = await RuleTrigger.explicit({ sessionID, root, directory: input.directory, learn, text: content, said, memory: input.memory }) + if (input.subagent || !RuleProposal.rendererAttached(input.directory)) return void RulesTelemetry.routed(sessionID, via, "no_renderer") + const result = await RuleTrigger.explicit({ sessionID, root, directory: input.directory, learn, text: content, said, memory: input.memory, unanswered: input.unanswered }) RulesTelemetry.routed( sessionID, via, @@ -125,8 +157,8 @@ export async function proposeFor(input: { : `Proposed as a team rule for ${state.workspace.name}. The person sees a card to ` + (state.workspace.is_owner ? "approve it now" : `send it to ${owner} for review`) + ", edit the wording, or decline.", via === "memory" - ? "Nothing was saved yet: the person chooses on the card. Do not save it again with altimate_memory_write or training_save; follow it for the rest of this session." - : "Follow the rule for the rest of this session. Do not save it again, and do not say it is already a team rule: it applies to teammates only after approval.", + ? "Nothing was saved yet: the person chooses on the card (if they never answer, it is saved to this project's memory when the card closes). Do not save it again with altimate_memory_write or training_save; follow it for the rest of this session." + : "Follow the rule for the rest of this session. Do not save it again, and do not say it is already a team rule: it applies to teammates only after approval (if the card is never answered, it is saved as a rule for this project when it closes).", ].join("\n"), } } diff --git a/packages/opencode/src/altimate/rules/service.ts b/packages/opencode/src/altimate/rules/service.ts index 10ed6f0574..1037b59791 100644 --- a/packages/opencode/src/altimate/rules/service.ts +++ b/packages/opencode/src/altimate/rules/service.ts @@ -10,9 +10,8 @@ // capture started here stops when learning turns off (setting changed, or the project unlinked). // - The learn config is read on each refresh and each check, so `learn disable` takes effect without a restart. // - After each turn (session idle), check the session's new corrections (`RuleTrigger.afterTurn`). -// - Publish review notices from a pull as `rule.proposal.reviewed`. +// - Review notices from a pull are published as `rule.proposal.reviewed` by the sync run itself (`RulesSync`). import fs from "node:fs/promises" -import path from "node:path" import { Effect } from "effect" import { Bus } from "@/bus" import { registerDisposer } from "@/effect/instance-registry" @@ -27,41 +26,25 @@ import { RuleTrigger } from "./trigger" const log = Log.create({ service: "rules.service" }) -type LearnConfig = { enabled?: boolean; capture?: boolean; model?: string } | undefined +type LearnConfig = { enabled?: boolean; capture?: boolean; model?: string; capture_policy?: boolean } | undefined /** - * `loaded` (the session's config) with the project file's `learn` as it is on disk now: `learn enable/disable` write it - * while the session runs. The layers that outrank project files in `config.ts` (`OPENCODE_CONFIG_CONTENT`, the - * managed config directory, MDM preferences) are applied again on top, so a committed project setting never beats an - * organisation's. Shared by the service and the routes so both decide the same way. + * `loaded` (the session's config, every layer already merged and expanded) with the project file's `learn` as it is on + * disk now: `learn enable/disable` write it while the session runs. Only keys no layer above project files set + * (`Config.learnKeysAboveProject`) are taken from the file, so a committed project setting never beats the home or + * `OPENCODE_CONFIG_DIR` config, `OPENCODE_CONFIG_CONTENT`, the account's config, managed config or MDM. When one of those + * sets `learn.capture`, `capture_policy` marks it: it then also beats the person's `/rules` choice. Shared by the service + * and the routes so both decide the same way. */ export async function effectiveLearn(loaded: LearnConfig, root: string, directory: string): Promise { - const { readProjectLearning } = await import("../learn/project-config") - const onDisk = (await readProjectLearning(root, directory).catch(() => undefined)) as LearnConfig + const [{ readProjectLearning }, { Config }] = await Promise.all([import("../learn/project-config"), import("@/config/config")]) + const onDisk = (await readProjectLearning(root, directory).catch(() => undefined)) as Record | undefined if (!loaded && !onDisk) return undefined - const above = await higherLearn().catch(() => ({})) - return { ...loaded, ...onDisk, ...above } -} - -/** The `learn` keys set by the config layers above project files, in their merge order. */ -async function higherLearn(): Promise> { - const [{ env }, { ConfigManaged }, { parse }] = await Promise.all([ - import("@opencode-ai/core/flag/flag"), - import("@/config/managed"), - import("jsonc-parser"), - ]) - const learn: Record = {} - const add = (text: string | undefined) => { - if (!text) return - const value = (parse(text) as { learn?: unknown } | undefined)?.learn - if (value && typeof value === "object" && !Array.isArray(value)) Object.assign(learn, value) - } - add(env("OPENCODE_CONFIG_CONTENT")) - const managedDir = ConfigManaged.managedConfigDir() - for (const file of ["altimate-code.json", "altimate-code.jsonc", "opencode.json", "opencode.jsonc"]) - add(await fs.readFile(path.join(managedDir, file), "utf8").catch(() => undefined)) - add((await ConfigManaged.readManagedPreferences())?.text) - return learn + const above = Config.learnKeysAboveProject(directory) + const result: Record = { ...loaded } + for (const [key, value] of Object.entries(onDisk ?? {})) if (!above.has(key)) result[key] = value + if (above.has("capture")) result.capture_policy = true + return result as LearnConfig } async function learnConfig(ctx: InstanceContext): Promise { @@ -95,23 +78,10 @@ function deps(ctx: InstanceContext, model?: string): RuleTrigger.TriggerDeps { } } -async function publishNotices(directory: string, report: RulesSync.RunReport) { - for (const notice of report.pull?.notices ?? []) - await Bus.publish(RuleEvents.Event.Reviewed, { - public_id: notice.public_id, - text: notice.text, - status: notice.status, - ...(notice.reason ? { reason: notice.reason } : {}), - ...(notice.reviewer ? { reviewer: notice.reviewer } : {}), - }).catch(() => {}) - if (report.context.status === "ready") await Bus.publish(RuleEvents.Event.Changed, { directory }).catch(() => {}) -} - -/** Sync now, publishing notices and a change event. Shared with routes and the TUI menu. */ +/** Sync now, or join the sync already running. Its notices and change event are published by the run itself, once. + * Shared with routes and the TUI menu. */ export async function syncNow(root: string, directory: string, learn: Parameters[2], trigger: RulesTelemetry.SyncTrigger = "refresh") { - const report = await RulesSync.runShared(root, directory, learn, trigger) - await publishNotices(directory, report) - return report + return RulesSync.runShared(root, directory, learn, trigger) } /** @@ -124,7 +94,7 @@ export async function sessionStart(root: string, directory: string, loaded: Lear // A project with no rules state yet only waits for the service's first pull if it is running: starting one here // would add up to `SESSION_WAIT_MS` to the first prompt of every session in a project without rules. const hasState = !!(await RulesStore.read(root).catch(() => undefined))?.scope - await RulesSync.sessionStart(root, directory, learn, { startIfDue: hasState, onReport: (report) => publishNotices(directory, report) }) + await RulesSync.sessionStart(root, directory, learn, { startIfDue: hasState }) } export async function start(ctx: InstanceContext): Promise { @@ -178,9 +148,13 @@ export async function start(ctx: InstanceContext): Promise { }) if (report) await followLearning(learn) }) - await followLearning(await learnConfig(ctx)) - void refresh() - const timer = setInterval(() => void refresh(), RulesSync.REFRESH_MS) + // Background callbacks never reject unhandled: an unreadable rules state or config is logged, and the next tick tries + // again. + const safely = (what: string, work: () => Promise) => () => + void work().catch((error) => log.warn(`rules ${what} failed`, { error: error instanceof Error ? error.message : String(error) })) + await followLearning(await learnConfig(ctx)).catch((error) => log.warn("rules learning setup failed", { error: String(error) })) + safely("refresh", refresh)() + const timer = setInterval(safely("refresh", refresh), RulesSync.REFRESH_MS) timer.unref?.() const onIdle = Instance.bind(async (sessionID: string) => { if (disposed) return @@ -195,8 +169,9 @@ export async function start(ctx: InstanceContext): Promise { if (!disposed) await followLearning(await learnConfig(ctx)) }) const stop = Bus.subscribeAll((event) => { - if (event.type === "session.idle") void onIdle(event.properties.sessionID) - if (event.type === RuleEvents.Event.Changed.type && (event.properties as { directory?: string }).directory === ctx.directory) void onChanged() + if (event.type === "session.idle") safely("after-turn check", () => onIdle(event.properties.sessionID))() + if (event.type === RuleEvents.Event.Changed.type && (event.properties as { directory?: string }).directory === ctx.directory) + safely("learning update", onChanged)() }) const unregister = registerDisposer(async (directory) => { if (directory !== ctx.directory) return @@ -206,7 +181,7 @@ export async function start(ctx: InstanceContext): Promise { unregister() // Cards nobody answered go with the instance: they cannot be answered after it. const { RuleProposal } = await import("./proposal") - RuleProposal.drop(ctx.directory) + await RuleProposal.drop(ctx.directory) // The capture's own disposer stops it too; this covers a stop handle taken before disposal. The gate goes last: // signals the stop flushes are still checked against the learning decision. await stopCapture?.() diff --git a/packages/opencode/src/altimate/rules/store.ts b/packages/opencode/src/altimate/rules/store.ts index 018f75c75b..308eca0824 100644 --- a/packages/opencode/src/altimate/rules/store.ts +++ b/packages/opencode/src/altimate/rules/store.ts @@ -1,9 +1,9 @@ // altimate_change - new file // -// Local state for workspace rules, under `.altimate-code/learn/team/` (git-ignored): +// Local state for workspace rules, under `.altimate-code/learn/workspace-rules/` (git-ignored): // -// - `approved.json`: the workspace's approved rules for this repository, as lessons of a read-only `team` -// store. Delivery, recurrence and `learn show` read it like any other store, so team rules reach the +// - `approved.json`: the workspace's approved rules for this repository, as lessons of the read-only +// `workspace-rules` store (a name reserved for it; the person's own stores, `team` included, are separate). Delivery, recurrence and `learn show` read it like any other store, so team rules reach the // prompt ("Team rules"), their exposure is recorded, and "corrected again" falls out of recurrence. // Only a pull writes it; reflection and promotion never target this store. // - `rules.json`: everything else this client keeps: the scope it was pulled for, workspace and owner, @@ -23,15 +23,18 @@ import * as Store from "../learn/store" import { RuleOut, Settings } from "./api" /** The read-only store team rules are mirrored into (reserved: `learn/store.ts` `RESERVED_STORES`). */ -export const TEAM_STORE = Store.WORKSPACE_RULES_STORE -/** Where earlier builds of workspace rules kept their state and mirror: moved by `migrateLegacy`. */ -const LEGACY_STORE = "team" +export const WORKSPACE_RULES_STORE = Store.WORKSPACE_RULES_STORE const log = Log.create({ service: "rules.store" }) const LOCK = { timeoutMs: 5_000 } const MAX_NOTICES = 50 const MAX_SENT = 200 -/** Safety cap for the session and signal id lists; `processed_signals` is also pruned to live signals. */ +/** Safety cap for the signal id list; `processed_signals` is also pruned to live signals. */ const MAX_TRACKED = 5000 +/** Safety cap for `usage_reported`, which is pruned to sessions that still have a snapshot (`reportUsage`). Above what + * the snapshots it is checked against can hold (200 sessions × 40 rules), so a live pair is never trimmed. */ +const MAX_USAGE_PAIRS = 50_000 +/** How long the "queued proposals were dropped" notice of an account or workspace change stays shown. */ +const SCOPE_DROP_NOTICE_MS = 7 * 24 * 60 * 60_000 /** "Not a rule" keeps a topic from being proposed again in this repository for this long. */ export const DISMISS_MS = 30 * 24 * 60 * 60_000 @@ -101,13 +104,17 @@ export const State = z.object({ pending_count: z.number().int().default(0), pulled_at: z.string().optional(), last_error: z.string().optional(), + /** Queued proposals dropped because the account or workspace changed. Its own field: a later pull's error (or its + * success) does not erase it. Shown for `SCOPE_DROP_NOTICE_MS`. */ + scope_drop: z.object({ count: z.number().int(), at: z.string() }).optional(), rules: z.array(RuleOut).default([]), outbox: z.array(OutboxItem).default([]), sent: z.array(Sent).default([]), notices: z.array(Notice).default([]), dismissed: z.array(z.object({ topic: z.string(), until: z.string() })).default([]), - /** Sessions whose deliveries were already reported as `applied`. */ - usage_sessions: z.array(z.string()).default([]), + /** (session, rule version) pairs already reported as `applied`, as `session|public_id`: a session keeps showing + * rules on later turns (requests, files), and each rule it showed counts once. */ + usage_reported: z.array(z.string()).default([]), /** Per rule, the "corrected again" count already reported. */ corrected_reported: z.record(z.string(), z.number().int()).default({}), /** What the last post-turn check decided, for `learn status`. */ @@ -124,6 +131,9 @@ export const State = z.object({ public_id: z.string().optional(), error: z.string().optional(), error_code: z.string().optional(), + approved: z.boolean().optional(), + approve_error: z.string().optional(), + approve_retry: z.boolean().optional(), })).default({}), /** "Approve now" calls that failed for a reason that can pass (network, 5xx, credential): retried on the next push. */ pending_approvals: z.array(z.object({ @@ -136,7 +146,7 @@ export const State = z.object({ pending_usage: z.object({ batch_id: z.string(), items: z.array(z.object({ public_id: z.string(), applied: z.number().int(), helpful: z.number().int(), harmful: z.number().int(), corrected_again: z.number().int() })), - sessions: z.array(z.string()), + pairs: z.array(z.string()), corrected: z.record(z.string(), z.number().int()), }).optional(), }) @@ -145,50 +155,18 @@ export type State = z.infer const EMPTY: State = State.parse({ version: 1 }) export function dir(root: string) { - return Store.paths(root, TEAM_STORE).learnDir + return Store.paths(root, WORKSPACE_RULES_STORE).learnDir } const stateFile = (root: string) => path.join(dir(root), "rules.json") -/** The state file's text, or undefined when there is none. Other read errors propagate. Until `update` migrates it, - * state an earlier build kept under `team/` is read from there. */ +/** The state file's text, or undefined when there is none. Other read errors propagate. */ async function readRaw(root: string): Promise { - const read = (file: string) => fs.readFile(file, "utf8").catch((error: NodeJS.ErrnoException) => { + return fs.readFile(stateFile(root), "utf8").catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return undefined throw error }) - return (await read(stateFile(root))) ?? (await read(legacyStateFile(root))) } -const legacyStateFile = (root: string) => path.join(Store.paths(root, LEGACY_STORE).learnDir, "rules.json") - -/** - * Earlier builds kept the rules state and mirror in the `team` store, which projects also use for their own playbooks. - * Moves the state to `workspace-rules/`, and removes from `team/` only what was ours: the state file, an `approved.json` - * that is the mirror, and our `.gitignore`. A playbook that was set aside for the mirror is put back. Under the lock. - */ -async function migrateLegacy(root: string): Promise { - const legacy = Store.paths(root, LEGACY_STORE) - const state = await fs.readFile(legacyStateFile(root), "utf8").catch(() => undefined) - if (state === undefined) return - if (!(await fs.access(stateFile(root)).then(() => true, () => false))) await Store.writeAtomic(root, stateFile(root), state) - await fs.rm(legacyStateFile(root), { force: true }) - const approved = await fs.readFile(legacy.approved, "utf8").catch(() => undefined) - if (approved !== undefined && isMirror(approved)) { - await fs.rm(legacy.approved, { force: true }) - const backups = (await fs.readdir(legacy.learnDir).catch(() => [] as string[])) - .filter((name) => name.startsWith("approved.json.before-workspace-rules-")) - .sort() - const latest = backups[backups.length - 1] - if (latest) { - await Store.writeAtomic(root, legacy.approved, await fs.readFile(path.join(legacy.learnDir, latest), "utf8")) - await fs.rm(path.join(legacy.learnDir, latest), { force: true }) - } - } - const ignore = path.join(legacy.learnDir, ".gitignore") - if ((await fs.readFile(ignore, "utf8").catch(() => undefined)) === IGNORE) await fs.rm(ignore, { force: true }) - await fs.rmdir(legacy.learnDir).catch(() => {}) - log.info("workspace rules state moved from the team store", { root }) -} function parse(raw: string | undefined): State | undefined { if (!raw) return structuredClone(EMPTY) @@ -248,7 +226,6 @@ function capSent(sent: Sent[]): Sent[] { export async function update(root: string, change: (state: State) => State | void): Promise { return withLearnLock(root, async () => { await SafeFS.mkdir(root, dir(root)) - await migrateLegacy(root) const raw = await readRaw(root) let current = parse(raw) let recovered = false @@ -262,16 +239,17 @@ export async function update(root: string, change: (state: State) => State | voi const next = change(current) ?? current next.notices = next.notices.slice(-MAX_NOTICES) next.sent = capSent(next.sent) - next.usage_sessions = next.usage_sessions.slice(-MAX_TRACKED) + next.usage_reported = next.usage_reported.slice(-MAX_USAGE_PAIRS) next.processed_signals = next.processed_signals.slice(-MAX_TRACKED) const now = Date.now() next.dismissed = next.dismissed.filter((d) => Date.parse(d.until) > now) + if (next.scope_drop && !(now - Date.parse(next.scope_drop.at) < SCOPE_DROP_NOTICE_MS)) delete next.scope_drop await Store.writeAtomic(root, stateFile(root), JSON.stringify(next, null, 2) + "\n") // Only a linked project (or one whose rules just changed, e.g. a reset) has a mirror to write: in a project with no // workspace, only the state file is written (a dismissal, a learning choice), never an `approved.json` that delivery // would read. After an unreadable state was set aside, an existing // mirror no longer matches any state: rewrite it, so rules nothing vouches for stop being delivered. - const staleMirror = recovered && (await fs.access(Store.paths(root, TEAM_STORE).approved).then(() => true, () => false)) + const staleMirror = recovered && (await fs.access(Store.paths(root, WORKSPACE_RULES_STORE).approved).then(() => true, () => false)) if (next.workspace || JSON.stringify(next.rules) !== rulesBefore || staleMirror) { await ensureIgnore(root) await writeMirror(root, next.rules) @@ -281,7 +259,7 @@ export async function update(root: string, change: (state: State) => State | voi } async function writeMirror(root: string, rules: readonly RuleOut[]) { - const file = Store.paths(root, TEAM_STORE).approved + const file = Store.paths(root, WORKSPACE_RULES_STORE).approved const text = canonical(rules.map(toLesson)) const previous = await fs.readFile(file, "utf8").catch(() => undefined) if (previous === text) return @@ -294,10 +272,12 @@ async function writeMirror(root: string, rules: readonly RuleOut[]) { await Store.writeAtomic(root, file, text) } -/** Whether an `approved.json` is this mirror: every entry came from a workspace rule. */ +/** Whether an `approved.json` is this mirror: every entry came from a workspace rule. An empty list is written by the + * mirror itself (no rules yet, or the last one retired), and holds nothing to keep either way. */ function isMirror(text: string): boolean { try { - return parseLessons(text).every((lesson) => lesson.provenance?.startsWith("workspace rule ")) + const lessons = parseLessons(text) + return lessons.every((lesson) => lesson.provenance?.startsWith("workspace rule ")) } catch { return false } @@ -309,25 +289,43 @@ function isMirror(text: string): boolean { * `adopt` drops them before anything is sent to a different scope. */ export async function reset(root: string, options: { keepOutbox?: boolean } = {}): Promise { - await update(root, (state) => ({ + await update(root, (state) => cleared(state, options)) +} + +/** `reset` as a state change, for a caller that decides inside its own `update` (`adopt`). */ +export function cleared(state: State, options: { keepOutbox?: boolean } = {}): State { + return { ...structuredClone(EMPTY), dismissed: state.dismissed, ...(state.personal_learning !== undefined ? { personal_learning: state.personal_learning } : {}), // What was already reported or considered stays: after a relink or an account round trip it must not be counted // again (sessions and signals are local; corrected counts are keyed by rule key). - usage_sessions: state.usage_sessions, + usage_reported: state.usage_reported, corrected_reported: state.corrected_reported, processed_signals: state.processed_signals, // A batch built for this scope can only be resent to it. + // The same scope comes back (the link or access returns): its queue, approvals, the person's sent history and the + // review cursor stay, so reviews made meanwhile are still announced. ...(options.keepOutbox ? { outbox: state.outbox, scope: state.scope, pending_approvals: state.pending_approvals, + sent: state.sent, + ...(state.cursor ? { cursor: state.cursor } : {}), ...(state.pending_usage ? { pending_usage: state.pending_usage } : {}), } : {}), - })) + // A drop notice outlives a later reset: it is about proposals the person was told were queued. + ...(state.scope_drop ? { scope_drop: state.scope_drop } : {}), + } +} + +/** The notice for proposals dropped by an account or workspace change, while it is shown. */ +export function scopeDropNote(state: State): string | undefined { + const drop = state.scope_drop + if (!drop) return undefined + return `${drop.count} queued ${drop.count === 1 ? "proposal was" : "proposals were"} for another account or workspace and ${drop.count === 1 ? "was" : "were"} not sent` } /** Queued proposals at most; a new one is refused (the card stays open) rather than an older one dropped. */ diff --git a/packages/opencode/src/altimate/rules/summary.ts b/packages/opencode/src/altimate/rules/summary.ts index d0502a1986..77f8385655 100644 --- a/packages/opencode/src/altimate/rules/summary.ts +++ b/packages/opencode/src/altimate/rules/summary.ts @@ -18,7 +18,7 @@ export interface Summary { queued: number learning: boolean /** Who decided `learning`: the person's own setting, or the workspace's "Learn from sessions". */ - learning_source: "you" | "workspace" | "default" + learning_source: RuleTrigger.LearningSource /** The person's `/rules` choice, when they made one (absent: following config or the workspace). */ personal_learning?: boolean pulled_at?: string @@ -31,6 +31,8 @@ export interface Summary { export async function summary(root: string, learn: LearnConfig, env: NodeJS.ProcessEnv = process.env): Promise { const state = await RulesStore.read(root) const learning = RuleTrigger.learningDecision(learn, state, env) + // Dropped proposals first: the person was told they were queued, and a later pull does not clear it. + const lastError = [RulesStore.scopeDropNote(state), state.last_error].filter(Boolean).join("; ") return { linked: !!state.workspace, ...(state.workspace ? { workspace: state.workspace } : {}), @@ -42,7 +44,7 @@ export async function summary(root: string, learn: LearnConfig, env: NodeJS.Proc learning_source: learning.source, ...(state.personal_learning !== undefined ? { personal_learning: state.personal_learning } : {}), ...(state.pulled_at ? { pulled_at: state.pulled_at } : {}), - ...(state.last_error ? { last_error: state.last_error } : {}), + ...(lastError ? { last_error: lastError } : {}), notices: state.notices.slice(-10).reverse(), ...(state.last_check ? { last_check: state.last_check } : {}), } diff --git a/packages/opencode/src/altimate/rules/sync.ts b/packages/opencode/src/altimate/rules/sync.ts index 678c97aa89..f3ebdaf7ec 100644 --- a/packages/opencode/src/altimate/rules/sync.ts +++ b/packages/opencode/src/altimate/rules/sync.ts @@ -2,29 +2,34 @@ // // Workspace rules sync for a linked project: // -// - Pull (`POST /rules/sync`): the approved rules for this repository into the `team` store mirror, the +// - Pull (`POST /rules/sync`): the approved rules for this repository into the `workspace-rules` store mirror, the // workspace settings, and this person's proposals the owner reviewed since the last pull. Each review // becomes a notice (`rule.proposal.reviewed`): "approved", or "rejected" with the owner's reason. // - Push: the outbox of proposals the person sent from a card (or bootstrap), then "Approve now" for an -// owner who chose it. Offline-safe: an unsent proposal stays queued for the next run. -// - Usage: per rule, sessions it was delivered in (`applied`) and sessions where the person corrected the -// same topic after it was shown (`corrected_again`, from recurrence). +// owner who chose it. Offline-safe: an unsent proposal stays queued for the next run, across key rotations and +// sign-ins of the same person (the scope is the user, not the key). One sender per checkout across processes. +// - Usage: per rule version, the sessions it was delivered in (`applied`, each session once) and sessions where the +// person corrected the same topic after it was shown (`corrected_again`, from recurrence). // // Timing: a pull at session start (bounded wait), then every `REFRESH_MS` in the background, plus // "Sync now". Never throws to an automatic caller: failures are recorded and reported. import { randomUUID } from "node:crypto" import fs from "node:fs/promises" import { Log } from "@/util/log" +import { Flock } from "@opencode-ai/core/util/flock" import { learnEnabled } from "../learn/config" +import * as SafeFS from "../learn/safe-fs" import type { ActAs } from "../workspace/api-client" +import { Bus } from "@/bus" import { RulesApi, RulesApiError, type ProposalItem, type ProposalResult } from "./api" +import { RuleEvents } from "./events" import { RulesStore, type Scope, type State } from "./store" import { RulesTelemetry, type SyncTrigger } from "./telemetry" const log = Log.create({ service: "rules.sync" }) export const REFRESH_MS = 5 * 60_000 export const SESSION_WAIT_MS = 2_000 -const MAX_PROPOSALS = 50 +export const MAX_PROPOSALS = 50 const MAX_USAGE_DELTA = 50 /** The server's per-batch limit on usage items (`MAX_USAGE_ITEMS`). */ const MAX_USAGE_ITEMS = 500 @@ -37,7 +42,10 @@ export type Context = | { status: "skipped"; code: SkipCode; reason: string; unlinked?: boolean } | { status: "ready"; scope: Scope; actAs: ActAs } -type SkipCode = "not_signed_in" | "not_linked" | "link_unconfirmed" | "no_remote" | "account_changed" +type SkipCode = "not_signed_in" | "not_linked" | "link_unconfirmed" | "no_remote" | "account_changed" | "account_unconfirmed" + +/** The signed-in user per credential digest, for this process: one `GET /users/me` per key, not per run. */ +const principals = new Map() /** Rules need learning on and workspaces on. */ export function rulesEnabled(learn: LearnConfig, env: NodeJS.ProcessEnv = process.env): boolean { @@ -58,13 +66,28 @@ export async function context(directory: string, learn: LearnConfig): Promise undefined) + if (id === undefined) return { status: "skipped", code: "account_unconfirmed", reason: "the Altimate account could not be confirmed" } + account = `user:${id}` + principals.set(digest, account) + } return { status: "ready", actAs, - scope: { apiUrl: actAs.url, tenant: actAs.instance, account, datamateId: outcome.binding.datamateId, repoRemote: outcome.binding.repoRemote }, + scope: { + apiUrl: actAs.url.replace(/\/+$/, ""), + tenant: actAs.instance, + account, + datamateId: outcome.binding.datamateId, + repoRemote: outcome.binding.repoRemote, + }, } } @@ -80,15 +103,23 @@ export interface PullResult { const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error)) -/** State pulled for another account, workspace or remote is dropped, outbox included: it never reaches this scope. */ +/** State pulled for another account, workspace or remote is dropped, outbox included: it never reaches this scope. + * Decided and done in one locked write, so a proposal queued meanwhile is counted, never lost unsaid. */ export async function adopt(root: string, ctx: Ready): Promise<{ state: State; same: boolean }> { const state = await RulesStore.read(root) - const same = RulesStore.sameScope(state.scope, ctx.scope) - if (state.scope && !same) { - await RulesStore.reset(root) - return { state: await RulesStore.read(root), same } - } - return { state, same } + if (!state.scope || RulesStore.sameScope(state.scope, ctx.scope)) return { state, same: !!state.scope } + let changed = false + const after = await RulesStore.update(root, (s) => { + if (!s.scope || RulesStore.sameScope(s.scope, ctx.scope)) return + changed = true + const dropped = s.outbox.length + // The state now belongs to this scope: a run still finishing for the old one does not write into it (`pushLocked`). + const next: State = { ...RulesStore.cleared(s), scope: ctx.scope } + // Said, not silent: the person was told these were queued. + if (dropped) next.scope_drop = { count: dropped, at: new Date().toISOString() } + return next + }) + return { state: after, same: !changed } } export async function pull(root: string, ctx: Ready): Promise { @@ -174,7 +205,7 @@ type Outcome = { entry: OutboxEntry; result?: ProposalResult; error?: RulesApiEr /** Send `entries`. A batch the server refused outright is retried one item at a time, so one bad item * cannot hold back the rest. */ -async function send(ctx: Ready, entries: OutboxEntry[]): Promise { +async function send(ctx: Ready, entries: OutboxEntry[], split = true): Promise { try { const results = await RulesApi.propose(ctx.actAs, ctx.scope.datamateId, { repo_remote: ctx.scope.repoRemote, @@ -186,7 +217,7 @@ async function send(ctx: Ready, entries: OutboxEntry[]): Promise { const failure = error instanceof RulesApiError ? error : RulesApi.classify(error) // Only a refusal of the content splits the batch. Scope and credential failures (`repo_not_bound`, // `workspace_not_found`, `forbidden`, `auth`, `unsupported`) are about the request: every item stays queued. - if (entries.length > 1 && (failure.kind === "rejected" || uncodedContent(failure))) { + if (split && entries.length > 1 && (failure.kind === "rejected" || uncodedContent(failure))) { const outcomes: Outcome[] = [] for (const entry of entries) outcomes.push(...(await send(ctx, [entry]))) if (failure.kind === "rejected") return outcomes @@ -216,32 +247,74 @@ const retryableApproval = (error: unknown) => { return kind === "transient" || kind === "auth" } const MAX_APPROVAL_ATTEMPTS = 5 -const MAX_UNCODED_ATTEMPTS = 3 const MAX_PUSH_OUTCOMES = 200 /** Per-item answers that are about now, not about the proposal: it stays queued and is sent again later. */ const RETRY_LATER = new Set(["too_many_pending"]) +/** Per project, the outbox (ids) whose items all failed alike, one by one, after an uncoded 400/422 of the whole batch: + * the request itself is refused. Until the outbox changes it is sent as one batch, not split again on every refresh. */ +const refusedWhole = new Map() + +/** `RulesStore.update`, tried again when the learn lock is busy: what it records was already sent. */ +async function record(root: string, change: (state: State) => State | void): Promise { + for (let attempt = 1; ; attempt++) { + try { + return await RulesStore.update(root, change) + } catch (error) { + if (attempt >= RECORD_ATTEMPTS) throw error + log.warn("rules state busy; recording again", { attempt, error: errorText(error) }) + await new Promise((resolve) => setTimeout(resolve, 500 * attempt)) + } + } +} +const RECORD_ATTEMPTS = 3 + /** * Send the outbox. An item leaves it once the server answered for it, or refused it (`rejected`, reported * as an error); a transient failure, or no answer for that item, keeps it queued. */ export async function push(root: string, ctx: Ready): Promise { + // One sender per checkout across processes (the TUI's timer, a second TUI, `learn sync`): the per-process `inflight` + // cannot stop two of them from reading the same outbox and posting it twice. A run that finds another one sending + // waits for it; the outcome it recorded per outbox id then answers this run's card. + await SafeFS.mkdir(root, RulesStore.dir(root)) + try { + return await Flock.withLock("rules-push", () => pushLocked(root, ctx), { + dir: RulesStore.dir(root), + staleMs: 60_000, + timeoutMs: PUSH_LOCK_WAIT_MS, + }) + } catch (error) { + if (!/Timed out waiting for lock/.test(errorText(error))) throw error + const queued = (await RulesStore.read(root)).outbox.length + return { sent: [], queued, ...(queued ? { error: "another sync is still sending proposals" } : {}) } + } +} + +const PUSH_LOCK_WAIT_MS = 30_000 + +async function pushLocked(root: string, ctx: Ready): Promise { const state = await RulesStore.read(root) + // The state was taken over by another account or workspace since this run started: none of it is this run's to send. + if (state.scope && !RulesStore.sameScope(state.scope, ctx.scope)) return { sent: [], queued: 0 } const batch = state.outbox.slice(0, MAX_PROPOSALS) const retried = await retryApprovals(root, ctx, state.pending_approvals) if (!batch.length) return { sent: [], queued: 0, ...(retried ? { approval_error: retried } : {}) } - const outcomes = await send(ctx, batch) + const signature = batch.map((entry) => entry.id).join(",") + const outcomes = await send(ctx, batch, refusedWhole.get(root) !== signature) + if (batch.length > 1 && outcomes.every((outcome) => outcome.error && uncodedContent(outcome.error))) refusedWhole.set(root, signature) + else refusedWhole.delete(root) const sent: PushResult["sent"] = [] const approvals: { entry: OutboxEntry; publicId: string; version: number }[] = [] - const pendingApprovals: RulesStore.State["pending_approvals"] = [] const done = new Set() const failed = new Map() for (const { entry, result, error } of outcomes) { if (error) { - // An uncoded 400/422 for one item cannot tell the item from the request: kept for a few attempts, then refused - // so it is not resent forever. - if (error.kind === "rejected" || (uncodedContent(error) && entry.attempts + 1 >= MAX_UNCODED_ATTEMPTS)) { + // Only a refusal of this item leaves the outbox. An item that failed alone in a split batch already arrives as + // `rejected`; anything else (a request-level failure, an uncoded 400/422 nobody can pin on one item) stays + // queued with its error, which the card, `/rules` and `learn status` show. + if (error.kind === "rejected") { done.add(entry.id) sent.push({ id: entry.id, text: entry.item.text, status: "rejected", error: error.message }) } else failed.set(entry.id, error.message) @@ -270,43 +343,19 @@ export async function push(root: string, ctx: Ready): Promise { if (entry.approve && result.status === "pending" && result.public_id && result.version) approvals.push({ entry, publicId: result.public_id, version: result.version }) } - for (const approval of approvals) { - try { - const reviewed = await RulesApi.review(ctx.actAs, ctx.scope.datamateId, approval.publicId, { - action: approval.entry.approve!, - expected_version: approval.version, - }) - const row = sent.find((s) => s.public_id === approval.publicId) - if (row) Object.assign(row, { status: reviewed.status, approved: true }) - } catch (error) { - const row = sent.find((s) => s.public_id === approval.publicId) - if (row) row.approve_error = errorText(error) - if (retryableApproval(error)) { - if (row) row.approve_retry = true - pendingApprovals.push({ public_id: approval.publicId, version: approval.version, action: approval.entry.approve!, attempts: 1 }) - } - } - } + // Recorded before any "Approve now": what was sent leaves the outbox even if an approval or a later write fails. const now = new Date().toISOString() - const after = await RulesStore.update(root, (s) => { + let foreign = false + const after = await record(root, (s) => { + if (s.scope && !RulesStore.sameScope(s.scope, ctx.scope)) return void (foreign = true) s.outbox = s.outbox.filter((entry) => !done.has(entry.id)) - for (const approval of pendingApprovals) - if (!s.pending_approvals.some((p) => p.public_id === approval.public_id)) s.pending_approvals.push(approval) for (const entry of s.outbox) { const error = failed.get(entry.id) if (error === undefined) continue entry.attempts += 1 entry.last_error = error } - for (const row of sent) - s.push_outcomes[row.id] = { - status: row.status, - ...(row.public_id ? { public_id: row.public_id } : {}), - ...(row.error ? { error: row.error } : {}), - ...(row.error_code ? { error_code: row.error_code } : {}), - } - const ids = Object.keys(s.push_outcomes) - for (const id of ids.slice(0, Math.max(0, ids.length - MAX_PUSH_OUTCOMES))) delete s.push_outcomes[id] + recordOutcomes(s, sent) for (const row of sent) { if (!row.public_id || row.status === "exists") continue const existing = s.sent.find((x) => x.public_id === row.public_id) @@ -316,10 +365,52 @@ export async function push(root: string, ctx: Ready): Promise { if (row.status === "pending") s.pending_count += 1 } }) + if (foreign) { + log.warn("rules state changed scope while proposals were sent; their answers were not recorded") + return { sent: [], queued: after.outbox.length } + } + const pendingApprovals: RulesStore.State["pending_approvals"] = [] + for (const approval of approvals) { + const row = sent.find((s) => s.public_id === approval.publicId) + try { + const reviewed = await RulesApi.review(ctx.actAs, ctx.scope.datamateId, approval.publicId, { + action: approval.entry.approve!, + expected_version: approval.version, + }) + if (row) Object.assign(row, { status: reviewed.status, approved: true }) + } catch (error) { + if (row) row.approve_error = errorText(error) + if (retryableApproval(error)) { + if (row) row.approve_retry = true + pendingApprovals.push({ public_id: approval.publicId, version: approval.version, action: approval.entry.approve!, attempts: 1 }) + } + } + } + if (approvals.length) + await record(root, (s) => { + if (s.scope && !RulesStore.sameScope(s.scope, ctx.scope)) return + for (const approval of pendingApprovals) + if (!s.pending_approvals.some((p) => p.public_id === approval.public_id)) s.pending_approvals.push(approval) + const answered = sent.filter((row) => approvals.some((a) => a.publicId === row.public_id)) + recordOutcomes(s, answered) + for (const row of answered) { + const existing = s.sent.find((x) => x.public_id === row.public_id) + if (!existing || existing.status === row.status) continue + if (existing.status === "pending") s.pending_count = Math.max(0, s.pending_count - 1) + Object.assign(existing, { status: row.status, updated_at: now }) + } + }) const error = failed.size ? [...failed.values()][0] : undefined return { sent, queued: after.outbox.length, ...(error ? { error } : {}), ...(retried ? { approval_error: retried } : {}) } } +/** Keep each row's answer per outbox id (capped), for a card whose proposal another run sent. */ +function recordOutcomes(s: State, rows: PushResult["sent"]) { + for (const row of rows) s.push_outcomes[row.id] = toRecorded(row) + const ids = Object.keys(s.push_outcomes) + for (const id of ids.slice(0, Math.max(0, ids.length - MAX_PUSH_OUTCOMES))) delete s.push_outcomes[id] +} + /** * Retry "Approve now" calls that failed for a passing reason. One that succeeds, or is refused for good (the rule * changed, is flagged, the person is no longer the owner), leaves the list; a refusal is recorded on the person's @@ -360,6 +451,31 @@ async function retryApprovals(root: string, ctx: Ready, pending: RulesStore.Stat return last } +type SentRow = PushResult["sent"][number] +type Recorded = State["push_outcomes"][string] + +/** A push answer as kept per outbox id, and back: the two directions in one place so they cannot drift. */ +function toRecorded(row: SentRow): Recorded { + return { + status: row.status, + ...(row.public_id ? { public_id: row.public_id } : {}), + ...(row.error ? { error: row.error } : {}), + ...(row.error_code ? { error_code: row.error_code } : {}), + ...(row.approved ? { approved: true } : {}), + ...(row.approve_error ? { approve_error: row.approve_error } : {}), + ...(row.approve_retry ? { approve_retry: true } : {}), + } +} + +function fromRecorded(id: string, recorded: Recorded): SentRow { + return { + id, + text: "", + ...recorded, + ...(recorded.error_code && TARGET_GONE.has(recorded.error_code) ? { target_gone: true } : {}), + } +} + /** Queue proposals for the next push, in one write. Returns their outbox ids, in order. Refuses past `MAX_OUTBOX`. */ export async function enqueueMany(root: string, items: readonly { item: ProposalItem; approve?: "approve" | "approve_anyway" }[]): Promise { const ids = items.map(() => randomUUID()) @@ -393,18 +509,7 @@ export function outcomeOf(state: State, report: RunReport | undefined, outboxId: const queued = state.outbox.find((entry) => entry.id === outboxId) if (queued) return { status: "queued", ...(queued.last_error ? { error: queued.last_error } : {}) } const recorded = state.push_outcomes[outboxId] - const row = report?.push?.sent.find((s) => s.id === outboxId) ?? - (recorded - ? { - id: outboxId, - text: "", - status: recorded.status, - ...(recorded.public_id ? { public_id: recorded.public_id } : {}), - ...(recorded.error ? { error: recorded.error } : {}), - ...(recorded.error_code ? { error_code: recorded.error_code } : {}), - ...(recorded.error_code && TARGET_GONE.has(recorded.error_code) ? { target_gone: true } : {}), - } - : undefined) + const row = report?.push?.sent.find((s) => s.id === outboxId) ?? (recorded ? fromRecorded(outboxId, recorded) : undefined) return { status: "sent", row, sent: row?.public_id ? state.sent.find((s) => s.public_id === row.public_id) : undefined } } @@ -419,8 +524,15 @@ function permanentUsageFailure(error: unknown) { /** Send one batch; on success advance the reported state and clear it from `pending_usage`. */ async function sendUsage(root: string, ctx: Ready, batch: PendingUsage): Promise { await RulesApi.usage(ctx.actAs, ctx.scope.datamateId, { batch_id: batch.batch_id, items: batch.items }) + await markReported(root, batch) +} + +/** A batch is done (acknowledged, or refused for good): its pairs and corrections count as reported, and it leaves + * `pending_usage`. The one place that decides what "reported" means. */ +async function markReported(root: string, batch: PendingUsage) { await RulesStore.update(root, (s) => { - s.usage_sessions.push(...batch.sessions.filter((session) => !s.usage_sessions.includes(session))) + const known = new Set(s.usage_reported) + s.usage_reported.push(...batch.pairs.filter((pair) => !known.has(pair))) s.corrected_reported = { ...s.corrected_reported, ...batch.corrected } if (s.pending_usage?.batch_id === batch.batch_id) s.pending_usage = undefined }) @@ -444,31 +556,41 @@ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: nu const state = await RulesStore.read(root) if (!state.rules.length) return { items: 0 } const byLesson = new Map(state.rules.map((rule) => [RulesStore.lessonId(rule.rule_key), rule])) - const [{ readShownSnapshots, lessonTextHash }, { analyzeRecurrence }] = await Promise.all([import("../learn/delivery"), import("../learn/recurrence")]) + const [{ readShownSnapshots, lessonTextHash, snapshotFiles, snapshotFile }, { analyzeRecurrence }] = await Promise.all([import("../learn/delivery"), import("../learn/recurrence")]) const { snapshots } = await readShownSnapshots(root) - const reported = new Set(state.usage_sessions) + // A reported pair is kept while its session has a snapshot (any of them, not only the newest read here): a resumed + // session comes back into the newest, and its pairs must still read as reported. A pair whose session is gone can + // never be counted again, so it is let go: the list stays as small as the sessions on disk. + const files = await snapshotFiles(root).catch(() => undefined) + const sessionOf = (pair: string) => pair.slice(0, pair.lastIndexOf("|")) + if (files && state.usage_reported.some((pair) => !files.has(snapshotFile(sessionOf(pair))))) + await RulesStore.update(root, (s) => { + s.usage_reported = s.usage_reported.filter((pair) => files.has(snapshotFile(sessionOf(pair)))) + }) + const reported = new Set(state.usage_reported) const counts = new Map() - const sessions: string[] = [] - // A rule counts at most `MAX_USAGE_DELTA` sessions per report, and a batch at most `MAX_USAGE_ITEMS` rules: - // later sessions wait for the next report. + const pairs: string[] = [] + // Each (session, rule version) once: a session keeps showing rules on later turns, and a rule shown on turn 4 counts + // even when the session was reported after turn 1. A rule counts at most `MAX_USAGE_DELTA` sessions per report, and + // a batch at most `MAX_USAGE_ITEMS` rules: the rest waits for the next report. for (const snapshot of snapshots) { - if (reported.has(snapshot.session)) continue // Only against the version the session was shown: the lesson id (from `rule_key`) survives an approved edit, the // text does not. A session shown an earlier version is not counted against the current one. const shown = [...new Set(snapshot.shown - .filter((entry) => entry.name === RulesStore.TEAM_STORE) + .filter((entry) => entry.name === RulesStore.WORKSPACE_RULES_STORE) .flatMap((entry) => { const rule = byLesson.get(entry.id) return rule && entry.textHash === lessonTextHash(rule.text) ? [rule.public_id] : [] }))] - if (!shown.length) continue - if (shown.some((publicId) => (counts.get(publicId)?.applied ?? 0) >= MAX_USAGE_DELTA)) break - if (new Set([...counts.keys(), ...shown]).size > MAX_USAGE_ITEMS) break - sessions.push(snapshot.session) for (const publicId of shown) { + const pair = `${snapshot.session}|${publicId}` + if (reported.has(pair)) continue const row = counts.get(publicId) ?? { applied: 0, corrected_again: 0 } + if (row.applied >= MAX_USAGE_DELTA) continue + if (!counts.has(publicId) && counts.size >= MAX_USAGE_ITEMS) continue row.applied += 1 counts.set(publicId, row) + pairs.push(pair) } } // `recurred` is a window over recent snapshots and can fall as old ones age out. The reported baseline @@ -476,7 +598,7 @@ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: nu const corrected: Record = {} const recurrence = await analyzeRecurrence(root).catch(() => undefined) for (const row of recurrence?.lessons ?? []) { - if (row.name !== RulesStore.TEAM_STORE) continue + if (row.name !== RulesStore.WORKSPACE_RULES_STORE) continue const rule = byLesson.get(row.id) if (!rule) continue // Keyed by rule key, which survives an approved edit (a new version gets a new public id), so the old version's @@ -497,7 +619,7 @@ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: nu const batch: PendingUsage = { batch_id: randomUUID(), items: [...counts].map(([public_id, c]) => ({ public_id, applied: c.applied, helpful: 0, harmful: 0, corrected_again: c.corrected_again })), - sessions, + pairs, corrected, } // Another process (a second instance, `learn sync`) may have built a batch from the same unreported sessions in @@ -507,7 +629,7 @@ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: nu let stale = false const after = await RulesStore.update(root, (s) => { if (s.pending_usage) return - stale = sessions.some((session) => s.usage_sessions.includes(session)) || + stale = pairs.some((pair) => s.usage_reported.includes(pair)) || Object.keys(corrected).some((key) => (s.corrected_reported[key] ?? 0) !== (state.corrected_reported[key] ?? 0)) if (!stale) s.pending_usage = batch }) @@ -528,11 +650,7 @@ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: nu * batch is not rebuilt and refused again; those counts are lost, which is the lesser harm. */ async function dropUsage(root: string, batch: PendingUsage, error: unknown) { - await RulesStore.update(root, (s) => { - if (s.pending_usage?.batch_id === batch.batch_id) s.pending_usage = undefined - s.usage_sessions.push(...batch.sessions.filter((session) => !s.usage_sessions.includes(session))) - s.corrected_reported = { ...s.corrected_reported, ...batch.corrected } - }) + await markReported(root, batch) log.warn("rules usage batch refused; dropped", { error: errorText(error) }) } @@ -593,12 +711,37 @@ const canonicalRoot = (root: string) => fs.realpath(root).catch(() => root) /** One run per project at a time; concurrent callers share it. */ export async function runShared(root: string, directory: string, learn: LearnConfig, trigger: SyncTrigger = "refresh"): Promise { + return (await joinOrStart(root, directory, learn, trigger)).report +} + +/** `runShared`, saying whether this call started the run. The run that starts announces what it pulled, once, whoever + * waits on it (the timer, "Sync now", a session start, a card reply, `learn sync`). */ +async function joinOrStart(root: string, directory: string, learn: LearnConfig, trigger: SyncTrigger) { const key = await canonicalRoot(root) const existing = inflight.get(key) - if (existing) return existing - const started = run(root, directory, learn, trigger).finally(() => inflight.delete(key)) - inflight.set(key, started) - return started + if (existing) return { report: existing, started: false } + const report = run(root, directory, learn, trigger) + .then(async (done) => { + await announce(directory, done) + return done + }) + .finally(() => inflight.delete(key)) + inflight.set(key, report) + return { report, started: true } +} + +/** Review notices from a pull become `rule.proposal.reviewed`, and clients refresh (`altimate.rules.changed`). On the + * Bus of the instance the run started in; a run outside one (none to tell) publishes nothing. */ +async function announce(directory: string, report: RunReport) { + for (const notice of report.pull?.notices ?? []) + await Bus.publish(RuleEvents.Event.Reviewed, { + public_id: notice.public_id, + text: notice.text, + status: notice.status, + ...(notice.reason ? { reason: notice.reason } : {}), + ...(notice.reviewer ? { reviewer: notice.reviewer } : {}), + }).catch(() => {}) + if (report.context.status === "ready") await Bus.publish(RuleEvents.Event.Changed, { directory }).catch(() => {}) } /** A run that starts after anything already queued: waits out an in-flight run (which may have pushed @@ -623,18 +766,15 @@ export async function sessionStart( root: string, directory: string, learn: LearnConfig, - options: { waitMs?: number; onReport?: (report: RunReport) => Promise; startIfDue?: boolean } = {}, + options: { waitMs?: number; startIfDue?: boolean } = {}, ): Promise { if (!rulesEnabled(learn)) return const existing = inflight.get(await canonicalRoot(root)) // With `startIfDue: false` only a run already in flight is waited on (the service's first pull of a freshly // linked project); nothing new is started on the prompt path. if (!existing && (options.startIfDue === false || !(await due(root)))) return - // A run this call starts publishes its notices (the service's refresh publishes its own). - const running = existing ?? runShared(root, directory, learn, "session_start").then(async (report) => { - await options.onReport?.(report) - return report - }) + // The run announces its own notices (`joinOrStart`), whether this call started it or joined it. + const running = existing ?? joinOrStart(root, directory, learn, "session_start").then(({ report }) => report) const settled = running.catch((error) => log.warn("rules session sync failed", { error: errorText(error) })) await Promise.race([settled, new Promise((resolve) => setTimeout(resolve, options.waitMs ?? SESSION_WAIT_MS).unref?.())]) } diff --git a/packages/opencode/src/altimate/rules/telemetry.ts b/packages/opencode/src/altimate/rules/telemetry.ts index 24f6e682dd..acd1a561df 100644 --- a/packages/opencode/src/altimate/rules/telemetry.ts +++ b/packages/opencode/src/altimate/rules/telemetry.ts @@ -96,6 +96,7 @@ export function synced(trigger: SyncTrigger, report: RulesSync.RunReport, starte refused: push.sent.filter((row) => row.error).length, queued: push.queued, push_failed: !!push.error, + approval_failed: !!push.approval_error, } : {}), ...(usage ? { usage_items: usage.items, usage_failed: !!usage.error } : {}), diff --git a/packages/opencode/src/altimate/rules/trigger.ts b/packages/opencode/src/altimate/rules/trigger.ts index 83753a21df..ef1d216541 100644 --- a/packages/opencode/src/altimate/rules/trigger.ts +++ b/packages/opencode/src/altimate/rules/trigger.ts @@ -35,20 +35,22 @@ export interface TriggerDeps { source: (sessionID: string) => Promise } -type LearnConfig = { enabled?: boolean; capture?: boolean } | undefined +type LearnConfig = { enabled?: boolean; capture?: boolean; capture_policy?: boolean } | undefined -export type LearningSource = "you" | "workspace" | "default" +export type LearningSource = "organization" | "you" | "workspace" | "default" /** * Whether sessions here learn from corrections (capture them and propose rules), and who decided. In order: the - * learning kill switch (`ALTIMATE_LEARN`, `learn.enabled`); `ALTIMATE_LEARN_CAPTURE`; the person's `/rules` choice - * (`personal_learning`, git-ignored); `learn.capture` (`learn enable` / `learn disable`); a linked workspace's + * learning kill switch (`ALTIMATE_LEARN`, `learn.enabled`); `ALTIMATE_LEARN_CAPTURE`; `learn.capture` set by a config + * layer above project files (`capture_policy`: managed config, MDM, the account's config …); the person's `/rules` + * choice (`personal_learning`, git-ignored); `learn.capture` (`learn enable` / `learn disable`); a linked workspace's * "Learn from sessions"; otherwise off. Env flags parse like `learn/config.ts`, so the two agree. */ export function learningDecision(learn: LearnConfig, state: RulesStore.State, env: NodeJS.ProcessEnv = process.env): { on: boolean; source: LearningSource } { if (!learnEnabled(learn, env)) return { on: false, source: "you" } const forced = booleanFlag(env.ALTIMATE_LEARN_CAPTURE) if (forced !== undefined) return { on: forced, source: "you" } + if (learn?.capture_policy && learn.capture !== undefined) return { on: learn.capture, source: "organization" } if (state.personal_learning !== undefined) return { on: state.personal_learning, source: "you" } if (learn?.capture !== undefined) return { on: learn.capture, source: "you" } if (state.workspace) return { on: state.settings?.learning_enabled === true, source: "workspace" } @@ -157,12 +159,12 @@ export async function afterTurn(input: { if (!fresh.length) return // Nothing attached would show a card (`altimate-code run`, ACP, a plugin): the corrections are left unprocessed, // so a later turn of this session with a card client attached proposes them. - if (!RuleProposal.rendererAttached()) { + if (!RuleProposal.rendererAttached(input.directory)) { outcome = "no client that shows cards is attached; corrections kept for a later check" return RulesTelemetry.skipped(input.sessionID, undefined, "no_renderer") } // One card at a time per session: an explicit "remember" may already have shown one for this turn. - if (RuleProposal.list().some((card) => card.sessionID === input.sessionID)) + if (RuleProposal.list(input.directory, input.sessionID).length) return RulesTelemetry.skipped(input.sessionID, undefined, "card_open") // Marked processed once the check completes; a failed check is retried after the next turn. checked = fresh.map((signal) => signal.id) @@ -174,7 +176,7 @@ export async function afterTurn(input: { const snapshot = (await readShownSnapshots(input.root)).snapshots.find((s) => s.session === input.sessionID) const correctedAt = Date.parse(newest.correctedAt ?? newest.at) const shownIds = new Set((snapshot?.shown ?? []) - .filter((entry) => entry.name === RulesStore.TEAM_STORE && !(Date.parse(entry.at) >= correctedAt)) + .filter((entry) => entry.name === RulesStore.WORKSPACE_RULES_STORE && !(Date.parse(entry.at) >= correctedAt)) .map((entry) => entry.id)) const said = words(fresh.map((signal) => signal.text).join(" ")) const hit = state.rules @@ -268,6 +270,8 @@ export async function explicit(input: { said?: string /** The memory block the agent meant to write, kept for the card's Memory choice. */ memory?: { id: string; tags: string[] } + /** Saves the request as before cards if the card closes unanswered (`RuleProposal.ask`). */ + unanswered?: (text: string) => Promise }): Promise< | { status: "shown"; card: Awaited>; choose: boolean } | { status: "invalid"; reason: string; kind: "rule" | "ambiguous" } @@ -286,7 +290,7 @@ export async function explicit(input: { // "staging"), and the card has no "add instead". Changing a team rule is the correction path's job, where the // drafter decides whether the shown rule is the subject. const card = await RuleProposal.ask({ - sessionID: input.sessionID, root: input.root, directory: input.directory, learn: input.learn, + sessionID: input.sessionID, root: input.root, directory: input.directory, learn: input.learn, unanswered: input.unanswered, draft: { text, applies_to: appliesTo([text, ...(last ? [last.text] : [])]), diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index ff48bf8c0f..22c3fb6b59 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -795,7 +795,7 @@ export namespace Telemetry { trigger: "remember" | "shown_rule" | "recurrence" action: "send" | "approve" | "approve_anyway" | "edit" | "dismiss" | "save_local" | "save_memory" | "expired" | "closed" /** `ok`, or why the reply did not go through (the card stays open). */ - status: "ok" | "busy" | "not_available" | "verification_flagged" | "invalid_text" | "failed" + status: "ok" | "busy" | "not_available" | "verification_flagged" | "invalid_text" | "refused" | "failed" /** For `ok` on a closing action: what happened. */ outcome?: | "sent" @@ -809,6 +809,7 @@ export namespace Telemetry { | "saved_memory_project" | "saved_memory_global" | "saved_local" + | "saved_unanswered" /** Wording edits accepted on this card so far. */ edits: number ms_since_shown: number @@ -850,6 +851,8 @@ export namespace Telemetry { notices?: number usage_items?: number push_failed?: boolean + /** A retried "Approve now" failed this run (reported apart from the proposals themselves). */ + approval_failed?: boolean usage_failed?: boolean } | { @@ -872,7 +875,7 @@ export namespace Telemetry { choice: "on" | "off" | "workspace" learning: boolean /** Who decides learning now. Not `source`: that is the envelope's client attribution. */ - decided_by: "you" | "workspace" | "default" + decided_by: "organization" | "you" | "workspace" | "default" } | { /** A rules command: `/rules` opened in a client, or a `learn` CLI subcommand. */ diff --git a/packages/opencode/src/altimate/tools/training-save.ts b/packages/opencode/src/altimate/tools/training-save.ts index fec555c503..58d4ef35b5 100644 --- a/packages/opencode/src/altimate/tools/training-save.ts +++ b/packages/opencode/src/altimate/tools/training-save.ts @@ -73,7 +73,7 @@ export const TrainingSaveTool = Tool.define("training_save", { // altimate_change start — in a project linked to a workspace, a project rule is a proposed team rule: // it shows the proposal card (the person sends it to the owner's review, or the owner approves it), // instead of training that applies at once and reaches the team through git without review. - const proposed = args.kind === "rule" && args.scope === "project" ? await proposeTeamRule(args.content, ctx.sessionID, lastUserText(ctx.messages)) : undefined + const proposed = args.kind === "rule" && args.scope === "project" ? await proposeTeamRule(args.content, ctx.sessionID, lastUserText(ctx.messages), "training", undefined, { name: args.name, source: args.source, citations: args.citations }) : undefined if (proposed) return proposed // altimate_change end const scopeForCount = args.scope === "global" ? "global" : "project" diff --git a/packages/opencode/src/cli/cmd/learn.ts b/packages/opencode/src/cli/cmd/learn.ts index c841cfad27..e4c4b6afb1 100644 --- a/packages/opencode/src/cli/cmd/learn.ts +++ b/packages/opencode/src/cli/cmd/learn.ts @@ -23,6 +23,7 @@ import { cmd } from "./cmd" import { effectCmd, fail } from "../effect-cmd" import { Flag } from "@opencode-ai/core/flag/flag" import { RulesTelemetry } from "../../altimate/rules/telemetry" +import { printable } from "../../altimate/rules/printable" import * as Playbook from "../../altimate/learn/playbook" import * as Store from "../../altimate/learn/store" import { MAX_TEXT, summarize, describeApplied, describeRejected } from "../../altimate/learn/curator" @@ -398,7 +399,7 @@ const StatusCommand = effectCmd({ // altimate_change end // One line for learning from sessions, effective value and who decided it (your setting, the linked // workspace's "Learn from sessions", or the default); the JSON keeps `capture` (your setting) too. - const source = { you: "your setting", workspace: "workspace setting", default: "default" }[status.rules.learning_source] + const source = { organization: "organization policy", you: "your setting", workspace: "workspace setting", default: "default" }[status.rules.learning_source] out(`Learning from sessions: ${status.rules.learning ? "on" : "off"} (${source}); automatic reflection: ${status.auto_reflect ? "on" : "off"}`) // altimate_change start — automatic promotion status out(`Automatic promotion: ${status.auto_promote ? "on" : "off"}`) @@ -436,10 +437,6 @@ const StatusCommand = effectCmd({ }) // altimate_change start — workspace rules: send queued proposals, pull approved rules, report usage -/** Server text (a rule, a reviewer's name or reason, an error) without control characters: an owner-written reason - * must not be able to move the cursor, retitle the terminal or plant a hyperlink. */ -const printable = (text: string | undefined) => (text ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ") - function noticeLine(notice: { status: string; text: string; reviewer?: string; reason?: string }) { const status = notice.status === "active" ? "approved" : printable(notice.status) return ` ${status}${notice.reviewer ? ` by ${printable(notice.reviewer)}` : ""}: "${printable(notice.text)}"${notice.reason ? ` — ${printable(notice.reason)}` : ""}` @@ -458,13 +455,16 @@ const SyncCommand = effectCmd({ const report = await RulesSync.runFresh(root, Instance.directory, (await Config.get()).learn, "cli") if (args.json) { out(JSON.stringify(RulesSync.publicReport(report), null, 2)) - // Same exit status as the text mode: a failed pull is a failed sync. - if (report.context.status === "ready" && !report.pull?.ok) process.exitCode = 1 + // Same exit status as the text mode: a failed pull, or proposals kept back, is a failed sync. + if (report.context.status === "ready" && (!report.pull?.ok || report.push?.error)) process.exitCode = 1 return } - if (report.context.status !== "ready") return out(`Rules sync skipped: ${report.context.reason}.`) + if (report.context.status !== "ready") return out(`Rules sync skipped: ${printable(report.context.reason)}.`) for (const sent of report.push?.sent ?? []) out(`Sent "${printable(sent.text)}": ${printable(sent.error ?? sent.status)}`) - if (report.push?.error) out(`Proposals not sent (kept queued): ${printable(report.push.error)}`) + if (report.push?.error) { + out(`Proposals not sent (kept queued): ${printable(report.push.error)}`) + process.exitCode = 1 + } if (report.push?.approval_error) out(`Approve now not done yet: ${printable(report.push.approval_error)}`) if (!report.pull?.ok) throw new Error(`Rules pull failed: ${printable(report.pull?.error ?? "unknown error")}`) out(`Synced: ${report.pull.rules} team ${report.pull.rules === 1 ? "rule" : "rules"}, ${report.pull.pending} pending review.`) @@ -487,13 +487,17 @@ async function offerStagedForReview(input: { name: string; origin: "bootstrap" | import("../../altimate/rules/from-history"), ]) const root = await projectRoot() - const staged = await RulesFromHistory.stagedRules({ root, name: input.name, origin: input.origin, before: input.before }) + const { RulesService } = await import("../../altimate/rules/service") + const learn = await RulesService.effectiveLearn((await Config.get()).learn, root, Instance.directory) + const staged = await RulesFromHistory.stagedRules({ root, name: input.name, origin: input.origin, before: input.before, learn }) if (!staged) return - for (const invalid of staged.invalid) out(`Kept local (not a valid rule: ${invalid.reason}): ${invalid.text}`) + // Model-written rules from past sessions or reviews, and server-supplied names: shown inert. + for (const invalid of staged.invalid) out(`Kept local (not a valid rule: ${printable(invalid.reason)}): ${printable(invalid.text)}`) if (!staged.items.length) return - const { owner, name } = staged.workspace + 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}:`) - for (const item of staged.items) out(` - ${item.text}`) + 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 @@ -502,9 +506,9 @@ async function offerStagedForReview(input: { name: string; origin: "bootstrap" | return } const result = await RulesFromHistory.sendStaged({ - root, directory: Instance.directory, learn: (await Config.get()).learn, name: input.name, staged, before: input.before, + root, directory: Instance.directory, learn, name: input.name, staged, before: input.before, }) - out(result.message) + out(printable(result.message)) } // altimate_change end diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 60f4399964..db9e4ff7b5 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -52,6 +52,19 @@ import { makeRuntime } from "@/effect/run-service" // altimate_change start — bootstrap can inspect config already loaded by service initialization // without introducing another asynchronous dependency for opted-out learning capture. const loadedConfig = new Map() +// altimate_change start — workspace rules: per instance directory, the `learn` keys a layer above project files set +const LEARN_ABOVE_PROJECT = new Map>() +/** The `learn` keys set by config layers that outrank project files, as of the last load for `directory`. A project + * file re-read while the session runs (`learn enable`/`disable`) may change only the other keys. */ +export function learnKeysAboveProject(directory: string): ReadonlySet { + return LEARN_ABOVE_PROJECT.get(directory) ?? new Set() +} +export function setLearnKeysAboveProjectForTest(directory: string, keys: readonly string[] | undefined) { + if (keys) LEARN_ABOVE_PROJECT.set(directory, new Set(keys)) + else LEARN_ABOVE_PROJECT.delete(directory) +} +// altimate_change end + export function peek(ctx: InstanceContext): Info | undefined { return loadedConfig.get(ctx.directory) } @@ -497,7 +510,26 @@ export const layer = Layer.effect( result.plugin_origins = plugins }) + // altimate_change start — workspace rules: the `learn` keys set by layers that outrank project files (the home + // `.altimate-code`/`.opencode` dirs, `OPENCODE_CONFIG_DIR`, `OPENCODE_CONFIG_CONTENT`, the account's config, + // managed config, MDM), so a project file re-read later never beats them (`learnKeysAboveProject`). + const learnAbove = new Set() + const homeDirs = [".altimate-code", ".opencode"].map((name) => path.join(Global.Path.home, name)) + const aboveProject = (source: string) => + source === "OPENCODE_CONFIG_CONTENT" || + source.endsWith("/api/config") || + source.startsWith(ConfigManaged.managedConfigDir() + path.sep) || + (!!Flag.OPENCODE_CONFIG_DIR && path.dirname(source) === path.resolve(Flag.OPENCODE_CONFIG_DIR)) || + homeDirs.includes(path.dirname(source)) + const noteLearn = (source: string, next: Info | undefined) => { + const learn = (next as { learn?: unknown } | undefined)?.learn + if (learn && typeof learn === "object" && aboveProject(source)) for (const key of Object.keys(learn)) learnAbove.add(key) + } + // altimate_change end const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => { + // altimate_change start — workspace rules: see `learnAbove` + noteLearn(source, next) + // altimate_change end result = mergeConfigConcatArrays(result, next) return mergePluginOrigins(source, next.plugin, kind) } @@ -700,8 +732,12 @@ export const layer = Layer.effect( }) if (managedPrefs.mcp && DATAMATE_KEY in managedPrefs.mcp) managedOwnsDatamate = true result = mergeConfigConcatArrays(result, managedPrefs) + for (const key of Object.keys(managedPrefs.learn ?? {})) learnAbove.add(key) // altimate_change end } + // altimate_change start — workspace rules: see `learnAbove` + LEARN_ABOVE_PROJECT.set(ctx.directory, learnAbove) + // altimate_change end // altimate_change start — upstream_fix: decide installs only after every config source has // merged. Inline, account, managed, and later-directory configs can declare a file plugin diff --git a/packages/opencode/src/plugin/tui/altimate/rules.tsx b/packages/opencode/src/plugin/tui/altimate/rules.tsx index edc96c6534..870df8f0d7 100644 --- a/packages/opencode/src/plugin/tui/altimate/rules.tsx +++ b/packages/opencode/src/plugin/tui/altimate/rules.tsx @@ -19,6 +19,7 @@ import type { BuiltinTuiPlugin } from "@opencode-ai/tui/builtins" import { createEffect, createRoot, createSignal, onCleanup, Show } from "solid-js" import type { ProposalCard } from "@/altimate/rules/events" 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" @@ -41,6 +42,10 @@ async function post(api: TuiPluginApi, url: string, body?: unknown): Promise< return (result.data ?? (result.error as T | undefined)) as T | undefined } +/** Who decides learning, as `/rules` says it. */ +const sourceLabel = (source: string) => + source === "organization" ? "organization policy" : source === "you" ? "your setting" : source === "workspace" ? "workspace setting" : "default" + const on = (api: TuiPluginApi) => api.event.on as unknown as (type: string, handler: (event: { properties: any }) => void) => () => void const root = (api: TuiPluginApi) => (api.state.path.worktree && api.state.path.worktree !== "/" ? api.state.path.worktree : api.state.path.directory) type Summary = RulesSummary.Summary & { ok: boolean; line: string } @@ -66,13 +71,16 @@ function title(card: ProposalCard) { } function meta(card: ProposalCard) { - const parts = [card.applies_to.length ? `applies to ${card.applies_to.join(", ")}` : "applies to the whole repository"] + const parts = [card.applies_to.length ? `applies to ${printable(card.applies_to.join(", "))}` : "applies to the whole repository"] if (card.evidence.correction) parts.push(`from your correction ${describeAge(Date.parse(card.evidence.correction.at))}`) if (card.evidence.sessions > 1) parts.push(`seen in ${card.evidence.sessions} sessions`) parts.push(`${card.chars} chars`) return parts.join(" · ") } +/** Where "Memory" saves, as the server decided (older servers did not say: the wording decides there too). */ +const memoryScope = (card: ProposalCard) => card.memory_scope ?? RuleClassify.memoryScope(card.text) + /** Button labels, in the order shown. Enter on a fresh card takes the first: the team action, or "Memory" on a * "Save as" card. */ function buttons(card: ProposalCard): { action: ProposalCard["actions"][number]; label: string }[] { @@ -80,7 +88,7 @@ function buttons(card: ProposalCard): { action: ProposalCard["actions"][number]; send: card.choose ? "Team rule — send for review" : "Send for review", approve: card.choose ? "Team rule — approve now" : card.weakens_verification ? "Approve anyway" : "Approve now", save_local: "Save for this project", - save_memory: RuleClassify.memoryScope(card.text) === "global" ? "Memory — just for you" : "Memory — this project", + save_memory: memoryScope(card) === "global" ? "Memory — just for you" : "Memory — this project", edit: "Edit wording", dismiss: card.choose ? "Skip" : "Not a rule", } @@ -90,13 +98,13 @@ function buttons(card: ProposalCard): { action: ProposalCard["actions"][number]; /** What the team action does, shown under the buttons while the card is focused. */ function explain(card: ProposalCard, action: string): string { - const owner = card.workspace?.owner_name ?? "the workspace owner" - const ws = card.workspace?.name ?? "the workspace" + 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 === "save_local") return "Not linked to a workspace: saved as project training, no review." if (action === "save_memory") - return RuleClassify.memoryScope(card.text) === "global" + return memoryScope(card) === "global" ? "Your preference: saved to your memory now, for all your projects. No review." : "A fact about this project: saved to its memory now. Shared with the workspace if its memory sharing is on. Not a team rule." if (action === "edit") return "Change the text before deciding." @@ -141,18 +149,18 @@ function InlineCard(props: { - {card().replaces!.text} + {printable(card().replaces!.text)} - {card().text} + {printable(card().text)} {meta(card())} - {`Why: ${card().why}`} + {`Why: ${printable(card().why)}`} - {`Your correction: "${card().evidence.correction!.excerpt}"`} + {`Your correction: "${printable(card().evidence.correction!.excerpt)}"`} - {`⚠ ${card().weakens_verification} — it needs "approve anyway"`} + {`⚠ ${printable(card().weakens_verification)} — it needs "approve anyway"`} @@ -238,13 +246,13 @@ function installCards(api: TuiPluginApi) { if (!result.ok) { // Closed on the server (expired, or its instance went away): it can't be answered, so it leaves the queue. if ((result as { code?: string }).code === "not_open") remove(card.id) - return api.ui.toast({ variant: "error", message: result.error ?? "Not done.", duration: 8000 }) + return api.ui.toast({ variant: "error", message: printable(result.error ?? "Not done."), duration: 8000 }) } if (action === "edit") { if (result.card) setCards((list) => list.map((c) => (c.id === card.id ? result.card! : c))) return } - api.ui.toast({ variant: "success", message: result.message ?? "Done.", duration: 6000 }) + api.ui.toast({ variant: "success", message: printable(result.message ?? "Done."), duration: 6000 }) remove(card.id) unfocus() } @@ -326,7 +334,7 @@ function installCards(api: TuiPluginApi) { // A dialog opened while the card is focused (the edit prompt, the palette, `/rules`) takes the keys, and the card // leaves focus: when the dialog closes the host gives focus back to the prompt, so re-binding the card's keys then // would take Enter, `e` and `n` from whatever the person types next. `p` focuses the card again. - createRoot(() => { + const disposeEffects = createRoot((dispose) => { createEffect(() => { if (!api.ui.dialog.open || !focused()) return releaseKeys() @@ -338,6 +346,7 @@ function installCards(api: TuiPluginApi) { createEffect(() => { if (focused() && (!active() || !current())) unfocus() }) + return dispose }) const offer = (card: ProposalCard) => @@ -374,17 +383,21 @@ function installCards(api: TuiPluginApi) { /> )} - { - prompt = ref - props.ref?.(ref) - }} - right={} - /> + {/* A click into the input while the card is focused gives the keys back: otherwise Enter, `e` and `n` typed + there would still answer the card. */} + focused() && unfocus()}> + { + prompt = ref + props.ref?.(ref) + }} + right={} + /> + ) }, @@ -411,9 +424,12 @@ function installCards(api: TuiPluginApi) { if (Array.isArray(result?.data)) setCards(result.data as ProposalCard[]) } - on(api)("rule.proposal.asked", (event) => offer(event.properties as ProposalCard)) - on(api)("rule.proposal.updated", (event) => offer(event.properties as ProposalCard)) - on(api)("rule.proposal.replied", (event) => closed((event.properties as { id: string }).id)) + const offs: (() => void)[] = [] + let disposed = false + const listen = (type: string, handler: (event: { properties: any }) => void) => void offs.push(on(api)(type, handler)) + listen("rule.proposal.asked", (event) => offer(event.properties as ProposalCard)) + listen("rule.proposal.updated", (event) => offer(event.properties as ProposalCard)) + listen("rule.proposal.replied", (event) => closed((event.properties as { id: string }).id)) // Each review is shown once per project, whether it arrived as an event or was pulled before this TUI // subscribed (the startup sync): remembered by id and status. // The first time a project is seen, its existing reviews are recorded silently: they are history, not news. @@ -425,32 +441,48 @@ function installCards(api: TuiPluginApi) { api.kv.set(seenKey(), [...seen, key].slice(-200)) if (!quiet) reviewToast(api, p) } - on(api)("rule.proposal.reviewed", (event) => notify(event.properties)) + listen("rule.proposal.reviewed", (event) => notify(event.properties)) const replay = () => void summaryOf(api).then((summary) => { const first = api.kv.get(seenKey(), undefined) === undefined if (first) api.kv.set(seenKey(), []) for (const notice of [...(summary?.notices ?? [])].reverse()) notify(notice, first) }) - on(api)("altimate.rules.changed", replay) - // This TUI shows proposal cards: it holds the server's renderer lease while it runs, renewed at the interval the - // server asks for. Without a live lease, the save tools save as before cards and the after-turn check waits. A - // failed call is retried after a few seconds. + listen("altimate.rules.changed", replay) + // This TUI shows proposal cards: it holds the server's renderer lease for its project while it runs, renewed at the + // interval the server asks for. Without a live lease, the save tools save as before cards and the after-turn check + // waits. No answer (the server may still be starting): tried again in a few seconds. A refusal (workspaces off, an + // older server): tried again at the normal interval, not every few seconds. + let leaseTimer: ReturnType | undefined const lease = async () => { + if (disposed) return const result = await post<{ ok: boolean; renew_ms?: number }>(api, "/rule-proposal/renderer", { client: "tui" }).catch(() => undefined) - // Not registered yet (the server may still be starting): try again soon, not at the next renewal. - const next = result?.ok !== true ? 3_000 : typeof result.renew_ms === "number" && result.renew_ms > 0 ? result.renew_ms : 30_000 - setTimeout(() => void lease(), next).unref?.() + if (disposed) return + const next = result === undefined + ? 3_000 + : result.ok === true && typeof result.renew_ms === "number" && result.renew_ms > 0 + ? result.renew_ms + : 30_000 + leaseTimer = setTimeout(() => void lease(), next) + leaseTimer.unref?.() } void lease() - setTimeout(() => { + // A plugin that goes away (reloaded, another server) stops renewing and stops listening, so its lease lapses. + // Startup: after the session-start sync had a moment, catch up on reviews and cards from before this TUI listened. + const startup = setTimeout(() => { + if (disposed) return replay() void load() }, 4_000) + api.lifecycle?.onDispose?.(() => { + disposed = true + if (leaseTimer) clearTimeout(leaseTimer) + clearTimeout(startup) + for (const off of offs.splice(0)) off() + disposeEffects() + }) } -/** Server text without control characters (a reviewer's reason must not drive the terminal). */ -const printable = (text: string | undefined) => (text ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ") function reviewToast(api: TuiPluginApi, raw: { text: string; status: string; reason?: string; reviewer?: string }) { const p = { ...raw, text: printable(raw.text), reason: raw.reason && printable(raw.reason), reviewer: raw.reviewer && printable(raw.reviewer) } @@ -468,24 +500,24 @@ async function showMenu(api: TuiPluginApi) { const team = rows.filter((r) => r.source === "team") const mine = rows.filter((r) => r.source === "mine") const status = summary.linked - ? `${summary.workspace?.name ?? "Workspace"} · ${summary.line}${summary.pulled_at ? ` · synced ${describeAge(Date.parse(summary.pulled_at))}` : ""}` + ? printable(`${summary.workspace?.name ?? "Workspace"} · ${summary.line}${summary.pulled_at ? ` · synced ${describeAge(Date.parse(summary.pulled_at))}` : ""}`) : "Not linked to a workspace — run `altimate-code link`" - const learning = `Learning from sessions: ${summary.learning ? "on" : "off"} (${summary.learning_source === "you" ? "your setting" : summary.learning_source === "workspace" ? "workspace setting" : "default"})` + const learning = `Learning from sessions: ${summary.learning ? "on" : "off"} (${sourceLabel(summary.learning_source)})` const label: Record = { active: "approved", pending: "pending review", rejected: "rejected", queued: "queued", retired: "retired", superseded: "replaced" } const options: TuiDialogSelectOption[] = [ ...team.map((r) => ({ - title: `${r.pinned ? "📌 " : ""}${r.text}`, + title: `${r.pinned ? "📌 " : ""}${printable(r.text)}`, value: `rule:${r.public_id}`, - description: r.applies_to.length ? `applies to ${r.applies_to.join(", ")}` : "whole repository", + description: r.applies_to.length ? `applies to ${printable(r.applies_to.join(", "))}` : "whole repository", category: `Team rules (${team.length})`, })), ...mine.map((r) => ({ - title: r.text, + title: printable(r.text), value: `mine:${r.public_id}`, - description: `${label[r.status] ?? r.status}${r.reviewer ? ` by ${r.reviewer}` : ""}${r.reason ? ` — ${r.reason}` : ""}`, + description: `${label[r.status] ?? printable(r.status)}${r.reviewer ? ` by ${printable(r.reviewer)}` : ""}${r.reason ? ` — ${printable(r.reason)}` : ""}`, category: `Your proposals (${mine.length})`, })), - { title: "Sync now", value: "sync", description: summary.last_error ? `last sync failed: ${summary.last_error}` : "Pull approved rules and send queued proposals", category: "Actions" }, + { title: "Sync now", value: "sync", description: summary.last_error ? `last sync: ${printable(summary.last_error)}` : "Pull approved rules and send queued proposals", category: "Actions" }, { title: summary.learning ? "Turn learning off for me" : "Turn learning on for me", value: "toggle", @@ -505,21 +537,26 @@ async function showMenu(api: TuiPluginApi) { if (option.value === "sync" || option.value === "toggle" || option.value === "follow") api.ui.dialog.clear() if (option.value === "sync") { api.ui.toast({ variant: "info", message: "Syncing rules…", duration: 3000 }) - const report = await post<{ ok: boolean; pull?: { ok: boolean; rules: number; pending: number; error?: string; notices: unknown[] }; push?: { sent: unknown[] }; context?: { status: string; reason?: string } }>(api, "/altimate/rules/sync").catch(() => undefined) + const report = await post<{ ok: boolean; error?: string; pull?: { ok: boolean; rules: number; pending: number; error?: string; notices: unknown[] }; push?: { sent: unknown[]; queued?: number; error?: string }; context?: { status: string; reason?: string } }>(api, "/altimate/rules/sync").catch(() => undefined) // A review notice from this pull is its own toast; do not cover it with the summary. if (report?.pull?.notices.length) return if (report?.context && report.context.status !== "ready") - api.ui.toast({ variant: "warning", message: `Rules sync skipped: ${report.context.reason ?? report.context.status}.`, duration: 8000 }) + api.ui.toast({ variant: "warning", message: `Rules sync skipped: ${printable(report.context.reason ?? report.context.status)}.`, duration: 8000 }) + else if (report?.pull?.ok && report.push?.error) + api.ui.toast({ variant: "warning", message: `Synced ${report.pull.rules} team ${report.pull.rules === 1 ? "rule" : "rules"}, but ${report.push.queued ?? 0} ${report.push.queued === 1 ? "proposal stays" : "proposals stay"} queued: ${printable(report.push.error)}`, duration: 10000 }) else if (report?.pull?.ok) api.ui.toast({ variant: "success", message: `Synced: ${report.pull.rules} team ${report.pull.rules === 1 ? "rule" : "rules"}, ${report.pull.pending} pending review${report.push?.sent.length ? `, ${report.push.sent.length} sent` : ""}.`, duration: 6000 }) - else api.ui.toast({ variant: "error", message: `Rules sync failed: ${report?.pull?.error ?? "unknown error"}`, duration: 8000 }) + else api.ui.toast({ variant: "error", message: `Rules sync failed: ${printable(report?.pull?.error ?? report?.error ?? "the session server did not answer")}`, duration: 8000 }) return } if (option.value === "toggle" || option.value === "follow") { const on = option.value === "follow" ? null : !summary.learning const next = await post(api, "/altimate/rules/learning", { on }).catch(() => undefined) - if (!next?.ok) return api.ui.toast({ variant: "error", message: `Learning not changed: ${next?.error ?? "the session server did not answer"}`, duration: 8000 }) - const source = next.learning_source === "you" ? "your setting" : next.learning_source === "workspace" ? "workspace setting" : "default" + if (!next?.ok) return api.ui.toast({ variant: "error", message: `Learning not changed: ${printable(next?.error ?? "the session server did not answer")}`, duration: 8000 }) + const source = sourceLabel(next.learning_source) + // Under an organization policy the choice is kept but does not decide: say so instead of claiming it did. + if (next.learning_source === "organization") + return api.ui.toast({ variant: "warning", message: `Learning from sessions stays ${next.learning ? "on" : "off"}: your organization's policy decides it.`, duration: 8000 }) api.ui.toast({ variant: "success", message: `Learning from sessions is ${next.learning ? "on" : "off"} for you in this project (${source}).`, duration: 6000 }) return } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index e037df0f7f..538565c3e3 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1278,7 +1278,7 @@ export namespace Server { if (typeof client !== "string" || !/^[a-z0-9_.-]{1,64}$/i.test(client)) return c.json({ ok: false, error: "client must be a short name (letters, digits, _ . -)." }, 400) const { RuleProposal } = await import("../altimate/rules/proposal") - RuleProposal.renewRenderer(client) + RuleProposal.renewRenderer(Instance.directory, client) return c.json({ ok: true as const, renew_ms: RuleProposal.RENDERER_RENEW_MS, lease_ms: RuleProposal.RENDERER_LEASE_MS }) }) .post("/rule-proposal/:id/reply", async (c) => { @@ -1298,7 +1298,7 @@ export namespace Server { { action: action as (typeof actions)[number], ...(text !== undefined ? { text } : {}) }, Instance.directory, ) - const status = result.ok ? 200 : { not_open: 404, busy: 409, not_available: 409, verification_flagged: 409, invalid_text: 422, failed: 500 }[result.code] + const status = result.ok ? 200 : { not_open: 404, busy: 409, not_available: 409, verification_flagged: 409, invalid_text: 422, refused: 422, failed: 500 }[result.code] return c.json(result, status as 200 | 404 | 409 | 422 | 500) } catch (err) { const error = err instanceof Error ? err.message : String(err) @@ -1309,29 +1309,41 @@ export namespace Server { .get("/altimate/rules/summary", async (c) => { const refused = workspaceRouteRefusal(c.req.header("origin"), c.req.header("host"), undefined, c.req.header("sec-fetch-site")) if (refused) return c.json(refused.body, refused.status) - const [{ RulesSummary }, { RulesService }, { Config }] = await Promise.all([ - import("../altimate/rules/summary"), - import("../altimate/rules/service"), - import("@/config/config"), - ]) - const root = Instance.worktree !== "/" ? Instance.worktree : Instance.directory - const learn = await RulesService.effectiveLearn((await Config.get()).learn, root, Instance.directory) - const summary = await RulesSummary.summary(root, learn) - return c.json({ ok: true as const, ...summary, line: RulesSummary.sidebarLine(summary) }) + try { + const [{ RulesSummary }, { RulesService }, { Config }] = await Promise.all([ + import("../altimate/rules/summary"), + import("../altimate/rules/service"), + import("@/config/config"), + ]) + const root = Instance.worktree !== "/" ? Instance.worktree : Instance.directory + const learn = await RulesService.effectiveLearn((await Config.get()).learn, root, Instance.directory) + const summary = await RulesSummary.summary(root, learn) + return c.json({ ok: true as const, ...summary, line: RulesSummary.sidebarLine(summary) }) + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + log.error("rules summary failed", { error }) + return c.json({ ok: false, error }, 500) + } }) .get("/altimate/rules", async (c) => { const refused = workspaceRouteRefusal(c.req.header("origin"), c.req.header("host"), undefined, c.req.header("sec-fetch-site")) if (refused) return c.json(refused.body, refused.status) const view = c.req.query("view") if (view !== undefined && view !== "active" && view !== "mine") return c.json({ ok: false, error: "view must be active or mine." }, 400) - const [{ RulesSummary }, { RulesTelemetry }] = await Promise.all([ - import("../altimate/rules/summary"), - import("../altimate/rules/telemetry"), - ]) - const root = Instance.worktree !== "/" ? Instance.worktree : Instance.directory - // Only the `/rules` view lists rules (the sidebar reads the summary): one call is one opening. - RulesTelemetry.command("view") - return c.json({ ok: true as const, rules: await RulesSummary.rows(root, view ?? "all") }) + try { + const [{ RulesSummary }, { RulesTelemetry }] = await Promise.all([ + import("../altimate/rules/summary"), + import("../altimate/rules/telemetry"), + ]) + const root = Instance.worktree !== "/" ? Instance.worktree : Instance.directory + // Only the `/rules` view lists rules (the sidebar reads the summary): one call is one opening. + RulesTelemetry.command("view") + return c.json({ ok: true as const, rules: await RulesSummary.rows(root, view ?? "all") }) + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + log.error("rules list failed", { error }) + return c.json({ ok: false, error }, 500) + } }) // The person's own "learning from sessions" choice for this project: `on` true/false, or null to follow the // config and the workspace again. Stored per person (git-ignored rules state), never in the project config. diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 2ee92d2337..6af8683ee9 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,6 +1,6 @@ import path from "path" import { Token } from "@/util/token" -import { existsSync } from "node:fs" +import { existsSync, readFileSync, realpathSync } from "node:fs" import os from "os" import fs from "fs/promises" import z from "zod" @@ -151,12 +151,29 @@ globalThis.AI_SDK_LOG_WARNINGS = false // altimate_change start — workspace rules: sessions that already had their session-start pull in this process const rulesSessionsStarted = new Set() -/** Rules state in this project, or a workspace linked on this machine (the bindings cache): otherwise the project - * cannot have workspace rules, and the prompt path skips them. Synchronous existence checks only. */ -const mayHaveWorkspaceRules = (root: string) => - existsSync(path.join(root, ".altimate-code", "learn", "workspace-rules", "rules.json")) || - existsSync(path.join(root, ".altimate-code", "learn", "team", "rules.json")) || - existsSync(path.join(Global.Path.state, "altimate-workspace-bindings.json")) +/** Rules state in this project, or a cached workspace binding for this directory: otherwise the project cannot have + * workspace rules, and the prompt path skips them. Synchronous, local checks only (a small JSON read at most). */ +const mayHaveWorkspaceRules = (root: string, directory: string) => { + if (existsSync(path.join(root, ".altimate-code", "learn", "workspace-rules", "rules.json"))) return true + try { + const cache = JSON.parse(readFileSync(path.join(Global.Path.state, "altimate-workspace-bindings.json"), "utf8")) as { + bindings?: Record + } + const keys = Object.keys(cache.bindings ?? {}) + if (!keys.length) return false + const canonical = (dir: string) => { + try { + return realpathSync(path.resolve(dir)) + } catch { + return path.resolve(dir) + } + } + const here = canonical(directory) + return keys.some((key) => key === here || canonical(key) === here) + } catch { + return false + } +} // altimate_change end const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format. @@ -812,7 +829,7 @@ export namespace SessionPrompt { // first prompt of a session awaits anything; every later prompt stays on the synchronous path. // A project that cannot have workspace rules pays only the existence checks in `mayHaveWorkspaceRules`: the rules // module graph is not loaded on its prompt path. - if (!rulesSessionsStarted.has(sessionID) && mayHaveWorkspaceRules(learnRoot)) { + if (!rulesSessionsStarted.has(sessionID) && mayHaveWorkspaceRules(learnRoot, Instance.directory)) { // Bounded: a long-lived server sees many sessions; the oldest is forgotten first, which only costs it another // bounded wait if it is resumed. if (rulesSessionsStarted.size >= 1000) rulesSessionsStarted.delete(rulesSessionsStarted.values().next().value!) diff --git a/packages/opencode/test/altimate/learn/capture-bus.test.ts b/packages/opencode/test/altimate/learn/capture-bus.test.ts index ee2c7b24f0..8107bfdeab 100644 --- a/packages/opencode/test/altimate/learn/capture-bus.test.ts +++ b/packages/opencode/test/altimate/learn/capture-bus.test.ts @@ -9,7 +9,7 @@ import { Instance } from "../../../src/project/instance" import { Session } from "../../../src/session" import { MessageID, PartID } from "../../../src/session/schema" import { ProviderID, ModelID } from "../../../src/provider/schema" -import { startCapture, flushCapture } from "../../../src/altimate/learn/capture" +import { startCapture, flushCapture, setCaptureGate } from "../../../src/altimate/learn/capture" import * as Signals from "../../../src/altimate/learn/signals" import { tmpdir } from "../../fixture/fixture" import { bootstrap } from "../../../src/cli/bootstrap" @@ -123,6 +123,40 @@ describe("capture over the real session bus", () => { }) }) + test("a learning gate that cannot decide records nothing, and a later update of the message records it", async () => { + process.env["ALTIMATE_LEARN_CAPTURE"] = "1" + await using dir = await tmpdir({ git: true }) + await Instance.provide({ + directory: dir.path, + fn: async () => { + let unreadable = true + setCaptureGate(Instance.directory, async () => { + if (unreadable) throw new Error("rules state unreadable") + return true + }) + try { + const session = await Session.create({}) + const first = await user(session.id, "Create a file.", MessageID.make("msg_gate_first"), 100) + await assistant(session.id, first, true, MessageID.make("msg_gate_zzz"), 200) + await startCapture({ directory: Instance.directory, worktree: Instance.worktree }) + const correction = MessageID.make("msg_gate_corr") + const partID = PartID.ascending() + await Session.updateMessage({ id: correction, sessionID: session.id, role: "user", time: { created: 300 }, agent: "build", model, tools: {} } as never) + await Session.updatePart({ id: partID, sessionID: session.id, messageID: correction, type: "text", text: "No, use explicit column names." } as never) + await flushCapture() + // Failed closed: nothing written, and the message is not marked recorded. + expect(await Signals.readSignals(dir.path)).toEqual([]) + unreadable = false + await Session.updatePart({ id: partID, sessionID: session.id, messageID: correction, type: "text", text: "No, use explicit column names here." } as never) + await settle(dir.path, 1) + expect(await Signals.readSignals(dir.path)).toMatchObject([{ messageID: correction }]) + } finally { + setCaptureGate(Instance.directory, undefined) + } + }, + }) + }) + test("disabled bootstrap never imports capture; config opt-in still starts it", async () => { const child = Bun.spawn([process.execPath, "test", path.join(import.meta.dir, "capture-bootstrap.fixture.ts")], { cwd: path.resolve(import.meta.dir, "../../.."), diff --git a/packages/opencode/test/altimate/rules/rules.test.ts b/packages/opencode/test/altimate/rules/rules.test.ts index 6c94ddf5aa..59c90f531b 100644 --- a/packages/opencode/test/altimate/rules/rules.test.ts +++ b/packages/opencode/test/altimate/rules/rules.test.ts @@ -9,6 +9,9 @@ import { RulesStore } from "../../../src/altimate/rules/store" import { RulesSummary } from "../../../src/altimate/rules/summary" import { RulesSync } from "../../../src/altimate/rules/sync" import { appliesTo, learningDecision, learningEnabled } from "../../../src/altimate/rules/trigger" +import { Config } from "../../../src/config/config" +import { WorkspaceApi } from "../../../src/altimate/workspace/api-client" +import * as WorkspaceState from "../../../src/altimate/workspace/state" import { RulesFromHistory } from "../../../src/altimate/rules/from-history" import { RulesRemember } from "../../../src/altimate/rules/remember" import { RulesTelemetry } from "../../../src/altimate/rules/telemetry" @@ -18,6 +21,12 @@ import { appendSignal } from "../../../src/altimate/learn/signals" import { Telemetry } from "../../../src/altimate/telemetry" import { parse } from "../../../src/altimate/learn/lesson" import * as Store from "../../../src/altimate/learn/store" +import { Bus } from "../../../src/bus" +import { MemoryStore } from "../../../src/memory/store" +import { TrainingStore } from "../../../src/altimate/training" +import { Instance } from "../../../src/project/instance" +import { Session } from "../../../src/session" +import { tmpdir } from "../../fixture/fixture" const tmp = () => fs.mkdtemp(path.join(os.tmpdir(), "rules-")) @@ -101,13 +110,13 @@ describe("team rule mirror", () => { await RulesStore.update(root, (state) => { state.rules = [rule("Name staging models `stg___`.", { applies_to: ["models/staging/**"], pinned: true })] }) - const lessons = parse(await fs.readFile(Store.paths(root, RulesStore.TEAM_STORE).approved, "utf8")) + const lessons = parse(await fs.readFile(Store.paths(root, RulesStore.WORKSPACE_RULES_STORE).approved, "utf8")) expect(lessons).toEqual([ expect.objectContaining({ id: "L-0123456789ab", pinned: true, trigger: { paths: ["models/staging/**"] } }), ]) expect(await fs.readFile(path.join(RulesStore.dir(root), ".gitignore"), "utf8")).toBe("*\n") await RulesStore.reset(root) - expect(parse(await fs.readFile(Store.paths(root, RulesStore.TEAM_STORE).approved, "utf8"))).toEqual([]) + expect(parse(await fs.readFile(Store.paths(root, RulesStore.WORKSPACE_RULES_STORE).approved, "utf8"))).toEqual([]) }) test("expired dismissals are dropped on write", async () => { @@ -328,7 +337,7 @@ describe("the workspace API, by path", () => { // A stand-in for `/datamates/:id/rules/{sync,proposals,usage}`: each test sets the answer per path. let server: ReturnType const seen: { path: string; body: any }[] = [] - let answer: (path: string, body: any) => Response = () => new Response("{}") + let answer: (path: string, body: any) => Response | Promise = () => new Response("{}") beforeAll(() => { server = Bun.serve({ port: 0, @@ -409,7 +418,7 @@ describe("the workspace API, by path", () => { await RulesSync.pull(root, ready()) // Pretend a batch went out and its answer was lost. await RulesStore.update(root, (s) => { - s.pending_usage = { batch_id: "batch-1", items: [{ public_id: "p-aaaaaaaaaaaa", applied: 2, helpful: 0, harmful: 0, corrected_again: 0 }], sessions: ["s1", "s2"], corrected: {} } + s.pending_usage = { batch_id: "batch-1", items: [{ public_id: "p-aaaaaaaaaaaa", applied: 2, helpful: 0, harmful: 0, corrected_again: 0 }], pairs: ["s1|p-aaaaaaaaaaaa", "s2|p-aaaaaaaaaaaa"], corrected: {} } }) seen.length = 0 answer = () => Response.json({ duplicate: true, applied_items: 1, skipped_items: 0 }) @@ -417,7 +426,7 @@ describe("the workspace API, by path", () => { expect(seen.find((r) => r.path === "/usage")?.body.batch_id).toBe("batch-1") const state = await RulesStore.read(root) expect(state.pending_usage).toBeUndefined() - expect(state.usage_sessions).toEqual(expect.arrayContaining(["s1", "s2"])) + expect(state.usage_reported).toEqual(expect.arrayContaining(["s1|p-aaaaaaaaaaaa", "s2|p-aaaaaaaaaaaa"])) }) test("a sync report given to a client never carries the credential", () => { @@ -481,7 +490,7 @@ describe("the workspace API, by path", () => { answer = () => Response.json(syncBody([rule("aaaaaaaaaaaa", "Name staging models stg_.")])) await RulesSync.pull(root, ready()) await RulesStore.update(root, (s) => { - s.pending_usage = { batch_id: "batch-x", items: [{ public_id: "p-aaaaaaaaaaaa", applied: 1, helpful: 0, harmful: 0, corrected_again: 0 }], sessions: ["s1"], corrected: {} } + s.pending_usage = { batch_id: "batch-x", items: [{ public_id: "p-aaaaaaaaaaaa", applied: 1, helpful: 0, harmful: 0, corrected_again: 0 }], pairs: ["s1|p-aaaaaaaaaaaa"], corrected: {} } }) answer = () => Response.json({ detail: { code: "batch_conflict", message: "conflict" } }, { status: 409 }) await RulesSync.reportUsage(root, ready()) @@ -509,7 +518,7 @@ describe("the workspace API, by path", () => { const dir = path.join(root, ".altimate-code", "learn", ".sessions") await fs.mkdir(dir, { recursive: true }) for (const [session, text] of [["shown-v1", "Name staging models stg_."], ["shown-v2", "Name staging models stg___."]]) { - const state = { version: 1, session, firstMessage: "m", query: "q", touchedPaths: [], section: "", shown: [{ name: RulesStore.TEAM_STORE, lesson: lesson(text), tier: "core", at: "2026-10-28T00:00:00Z", queryHash: "h" }], requests: [], requestParts: [], compactions: [], counted: [] } + const state = { version: 1, session, firstMessage: "m", query: "q", touchedPaths: [], section: "", shown: [{ name: RulesStore.WORKSPACE_RULES_STORE, lesson: lesson(text), tier: "core", at: "2026-10-28T00:00:00Z", queryHash: "h" }], requests: [], requestParts: [], compactions: [], counted: [] } await fs.writeFile(path.join(dir, `${Store.sha256(session)}.json`), JSON.stringify(state)) } seen.length = 0 @@ -527,15 +536,114 @@ describe("the workspace API, by path", () => { expect(RulesSync.outcomeOf(await RulesStore.read(root), undefined, id)).toMatchObject({ status: "sent", row: { status: "rejected", error: "too vague" } }) }) - test("one item refused with an uncoded 422 is retried a few times, then refused", async () => { + test("a request-wide uncoded 422 never drops the outbox, however many syncs it lasts; nor does one for a single item", async () => { const root = await tmp() - await RulesSync.enqueue(root, item("Unclear.")) + for (const text of ["one", "two", "three"]) await RulesSync.enqueue(root, item(text)) answer = () => Response.json({ detail: [{ msg: "invalid" }] }, { status: 422 }) - expect((await RulesSync.push(root, ready())).queued).toBe(1) - expect((await RulesSync.push(root, ready())).queued).toBe(1) - const last = await RulesSync.push(root, ready()) - expect(last.queued).toBe(0) - expect(last.sent[0]).toMatchObject({ status: "rejected" }) + seen.length = 0 + for (let run = 0; run < 5; run++) expect((await RulesSync.push(root, ready())).queued).toBe(3) + expect((await RulesStore.read(root)).outbox.map((e) => e.item.text)).toEqual(["one", "two", "three"]) + // Split once (the batch, then each item); after that, the same outbox goes as one request per sync. + expect(seen.filter((r) => r.path === "/proposals")).toHaveLength(4 + 4) + // A changed outbox is split again: the new item may be the bad one. + await RulesSync.enqueue(root, item("four")) + seen.length = 0 + await RulesSync.push(root, ready()) + expect(seen.filter((r) => r.path === "/proposals")).toHaveLength(5) + const single = await tmp() + await RulesSync.enqueue(single, item("Alone.")) + for (let run = 0; run < 5; run++) expect((await RulesSync.push(single, ready())).queued).toBe(1) + }) + + test("a rule a session shows on a later turn is counted, after the session was already reported", async () => { + const root = await tmp() + answer = () => Response.json(syncBody([ + { ...rule("aaaaaaaaaaaa", "Name staging models stg_."), public_id: "p-a" }, + { ...rule("bbbbbbbbbbbb", "Seeds live under seeds/reference."), public_id: "p-b" }, + ])) + await RulesSync.pull(root, ready()) + const lesson = (key: string, text: string) => ({ id: RulesStore.lessonId(`R-${key}`), text, tags: [], scope: "project", helpful: 0, harmful: 0, applied: 0, created: "2026-10-27T00:00:00Z", updated: "2026-10-27T00:00:00Z" }) + const dir = path.join(root, ".altimate-code", "learn", ".sessions") + await fs.mkdir(dir, { recursive: true }) + const write = (shown: ReturnType[]) => fs.writeFile(path.join(dir, `${Store.sha256("s1")}.json`), JSON.stringify({ + version: 1, session: "s1", firstMessage: "m", query: "q", touchedPaths: [], section: "", requests: [], requestParts: [], compactions: [], counted: [], + shown: shown.map((l) => ({ name: RulesStore.WORKSPACE_RULES_STORE, lesson: l, tier: "core", at: "2026-10-28T00:00:00Z", queryHash: "h" })), + })) + await write([lesson("aaaaaaaaaaaa", "Name staging models stg_.")]) + seen.length = 0 + answer = () => Response.json({ duplicate: false, applied_items: 1, skipped_items: 0 }) + await RulesSync.reportUsage(root, ready()) + // Turn 4 of the same session shows a second rule. + await write([lesson("aaaaaaaaaaaa", "Name staging models stg_."), lesson("bbbbbbbbbbbb", "Seeds live under seeds/reference.")]) + await RulesSync.reportUsage(root, ready()) + const usage = seen.filter((r) => r.path === "/usage").map((r) => r.body.items.map((i: { public_id: string; applied: number }) => [i.public_id, i.applied])) + expect(usage).toEqual([[["p-a", 1]], [["p-b", 1]]]) + }) + + test("an approval that failed is still on the card when another run sent the proposal", async () => { + const root = await tmp() + const id = await RulesSync.enqueue(root, item("Approve me later."), "approve") + answer = (p) => (p === "/proposals" + ? Response.json({ results: [{ index: 0, status: "pending", public_id: "p-later", version: 1 }] }) + : new Response("unavailable", { status: 503 })) + await RulesSync.push(root, ready()) + expect(RulesSync.outcomeOf(await RulesStore.read(root), undefined, id)).toMatchObject({ status: "sent", row: { public_id: "p-later", approve_retry: true } }) + }) + + test("bootstrap sends its staged rules past one push's batch, up to the workspace's pending limit; the rest stay queued", async () => { + const root = await tmp() + const env = process.env.ALTIMATE_DISABLE_WORKSPACE + delete process.env.ALTIMATE_DISABLE_WORKSPACE + const url = `http://127.0.0.1:${server.port}` + const spies = [ + spyOn(WorkspaceState, "resolveBindingOutcome").mockResolvedValue({ status: "bound", binding: { datamateId: 1, repoRemote: "https://github.com/acme/demo" } } as never), + spyOn(WorkspaceApi, "captureCredentials").mockResolvedValue({ url, instance: "t", apiKey: "k" }), + spyOn(WorkspaceState, "accountDigest").mockResolvedValue(WorkspaceState.credentialDigest(url, "t", "k")), + spyOn(WorkspaceApi, "whoami").mockResolvedValue(7), + ] + let proposals = 0 + let pending = 0 + // The workspace keeps at most 50 pending proposals per person (`MAX_PENDING_PER_PROPOSER`). + answer = (p, body) => { + if (p === "/proposals") { + proposals++ + return Response.json({ results: body.items.map((_: unknown, index: number) => pending < 50 + ? { index, status: "pending", public_id: `p-${proposals}-${index}-${pending++}`, version: 1 } + : { index, status: "invalid", error_code: "too_many_pending", error_detail: "50 proposals are waiting for review" }) }) + } + if (p === "/sync") return Response.json(syncBody([])) + return Response.json({ duplicate: false, applied_items: 0, skipped_items: 0 }) + } + try { + // The scope this identity resolves to, as an earlier pull would have stored it. + await RulesStore.update(root, (s) => Object.assign(s, linked(), { scope: { apiUrl: url, tenant: "t", account: "user:7", datamateId: 1, repoRemote: "https://github.com/acme/demo" } })) + const items = Array.from({ length: 60 }, (_, i) => item(`Staged rule ${i}.`)) + const staged = { workspace: { name: "Team", owner: "Olive" }, items, invalid: [], localChanges: 0 } + const result = await RulesFromHistory.sendStaged({ root, directory: root, learn: { enabled: true }, name: "team-playbook", staged, before: new Set() }) + expect(result).toMatchObject({ sent: 50, queued: 10, refused: 0, lost: 0 }) + expect(result.message).toContain("10 kept queued (50 proposals are waiting for review)") + expect(proposals).toBe(2) + expect((await RulesStore.read(root)).outbox).toHaveLength(10) + } finally { + for (const spy of spies) spy.mockRestore() + if (env === undefined) delete process.env.ALTIMATE_DISABLE_WORKSPACE + else process.env.ALTIMATE_DISABLE_WORKSPACE = env + } + }) + + test("two pushes at once send each proposal once", async () => { + const root = await tmp() + await RulesSync.enqueue(root, item("Only once.")) + let proposals = 0 + answer = async (p) => { + if (p !== "/proposals") return new Response("{}") + proposals++ + await Bun.sleep(150) + return Response.json({ results: [{ index: 0, status: "pending", public_id: "p-once", version: 1 }] }) + } + await Promise.all([RulesSync.push(root, ready()), RulesSync.push(root, ready())]) + expect(proposals).toBe(1) + expect((await RulesStore.read(root)).outbox).toEqual([]) }) test("a dropped usage batch marks its sessions reported, so the same batch is not rebuilt", async () => { @@ -543,16 +651,141 @@ describe("the workspace API, by path", () => { answer = () => Response.json(syncBody([rule("aaaaaaaaaaaa", "Name staging models stg_.")])) await RulesSync.pull(root, ready()) await RulesStore.update(root, (s) => { - s.pending_usage = { batch_id: "batch-y", items: [{ public_id: "p-aaaaaaaaaaaa", applied: 1, helpful: 0, harmful: 0, corrected_again: 0 }], sessions: ["s9"], corrected: { "R-aaaaaaaaaaaa": 1 } } + s.pending_usage = { batch_id: "batch-y", items: [{ public_id: "p-aaaaaaaaaaaa", applied: 1, helpful: 0, harmful: 0, corrected_again: 0 }], pairs: ["s9|p-aaaaaaaaaaaa"], corrected: { "R-aaaaaaaaaaaa": 1 } } }) answer = () => Response.json({ detail: { code: "batch_conflict", message: "conflict" } }, { status: 409 }) await RulesSync.reportUsage(root, ready()) const state = await RulesStore.read(root) expect(state.pending_usage).toBeUndefined() - expect(state.usage_sessions).toContain("s9") + expect(state.usage_reported).toContain("s9|p-aaaaaaaaaaaa") expect(state.corrected_reported["R-aaaaaaaaaaaa"]).toBe(1) }) + // A full sync as the signed-in user `user`: binding, credentials and `GET /users/me` stubbed, the server above. + const asUser = (user: number) => { + const url = `http://127.0.0.1:${server.port}` + const key = `key-${user}` + const env = process.env.ALTIMATE_DISABLE_WORKSPACE + delete process.env.ALTIMATE_DISABLE_WORKSPACE + const spies: { mockRestore(): void }[] = [ + spyOn(WorkspaceState, "resolveBindingOutcome").mockResolvedValue({ status: "bound", binding: { datamateId: 1, repoRemote: "https://github.com/acme/demo" } } as never), + spyOn(WorkspaceApi, "captureCredentials").mockResolvedValue({ url, instance: "t", apiKey: key }), + spyOn(WorkspaceState, "accountDigest").mockResolvedValue(WorkspaceState.credentialDigest(url, "t", key)), + spyOn(WorkspaceApi, "whoami").mockResolvedValue(user), + ] + return { + scope: { ...scope(), account: `user:${user}` }, + [Symbol.dispose]() { + for (const spy of spies) spy.mockRestore() + if (env === undefined) delete process.env.ALTIMATE_DISABLE_WORKSPACE + else process.env.ALTIMATE_DISABLE_WORKSPACE = env + }, + } + } + + test("an account change drops the queued proposals, and the same sync's pull does not erase the notice", async () => { + const root = await tmp() + using user = asUser(8) + await RulesStore.update(root, (s) => Object.assign(s, linked(), { scope: { ...user.scope, account: "user:7" } })) + await RulesSync.enqueue(root, item("Queued for user 7.")) + answer = (p) => (p === "/sync" ? Response.json(syncBody([])) : Response.json({ duplicate: false, applied_items: 0, skipped_items: 0 })) + seen.length = 0 + const report = await RulesSync.run(root, root, { enabled: true }, "manual") + expect(report.pull).toMatchObject({ ok: true }) + expect(seen.filter((r) => r.path === "/proposals")).toEqual([]) + const state = await RulesStore.read(root) + expect(state.outbox).toEqual([]) + expect(state.scope?.account).toBe("user:8") + expect((await RulesSummary.summary(root, { enabled: true })).last_error).toContain("1 queued proposal was for another account or workspace") + }) + + test("a review notice is announced once, however many callers wait on the sync that pulled it", async () => { + const root = await tmp() + using user = asUser(7) + await RulesStore.update(root, (s) => Object.assign(s, linked(), { scope: user.scope })) + const reviewed = { public_id: "p-1", rule_key: "R-1", text: "Use cents.", change: "add", status: "active", reviewed_by_name: "Olive", reviewed_at: "2026-10-28T00:00:00Z", updated_at: "2026-10-28T00:00:00Z" } + answer = async (p) => { + await Bun.sleep(20) + return p === "/sync" ? Response.json(syncBody([], { my_reviewed: [reviewed] })) : Response.json({ duplicate: false, applied_items: 0, skipped_items: 0 }) + } + const published: string[] = [] + const bus = spyOn(Bus, "publish").mockImplementation((async (def: { type: string }) => void published.push(def.type)) as never) + try { + await Promise.all([ + RulesService.syncNow(root, root, { enabled: true }, "manual"), + RulesSync.runShared(root, root, { enabled: true }), + RulesSync.sessionStart(root, root, { enabled: true }, { waitMs: 5_000 }), + ]) + } finally { + bus.mockRestore() + } + expect(published.filter((type) => type === "rule.proposal.reviewed")).toHaveLength(1) + expect(published.filter((type) => type === "altimate.rules.changed")).toHaveLength(1) + }) + + test("answers to proposals sent for one account are not written into another account's state", async () => { + const root = await tmp() + await RulesStore.update(root, (s) => { s.scope = scope() }) + await RulesSync.enqueue(root, item("Sent before the switch.")) + answer = async () => { + // Another process adopted the state for someone else while this request was out. + await RulesStore.update(root, (s) => { s.scope = { ...scope(), account: "someone-else" } }) + return Response.json({ results: [{ index: 0, status: "pending", public_id: "p-switch", version: 1 }] }) + } + expect((await RulesSync.push(root, ready())).sent).toEqual([]) + const state = await RulesStore.read(root) + expect(state.push_outcomes).toEqual({}) + expect(state.sent).toEqual([]) + }) + + test("a busy state lock after sending is waited out: the sent proposal still leaves the outbox", async () => { + const root = await tmp() + await RulesSync.enqueue(root, item("Recorded late.")) + answer = () => Response.json({ results: [{ index: 0, status: "pending", public_id: "p-late", version: 1 }] }) + const update = RulesStore.update + let failures = 0 + const spy = spyOn(RulesStore, "update").mockImplementation((async (...args: Parameters) => { + if (failures++ === 0) throw new Error("Timed out waiting for lock") + return update(...args) + }) as never) + try { + expect(await RulesSync.push(root, ready())).toMatchObject({ queued: 0, sent: [expect.objectContaining({ public_id: "p-late" })] }) + } finally { + spy.mockRestore() + } + expect(failures).toBe(2) + expect((await RulesStore.read(root)).outbox).toEqual([]) + }) + + test("reported usage is kept while its session has a snapshot, and let go once the snapshot is gone", async () => { + const root = await tmp() + answer = () => Response.json(syncBody([{ ...rule("aaaaaaaaaaaa", "Name staging models stg_."), public_id: "p-a" }])) + await RulesSync.pull(root, ready()) + const dir = path.join(root, ".altimate-code", "learn", ".sessions") + await fs.mkdir(dir, { recursive: true }) + const lesson = { id: RulesStore.lessonId("R-aaaaaaaaaaaa"), text: "Name staging models stg_.", tags: [], scope: "project", helpful: 0, harmful: 0, applied: 0, created: "2026-10-27T00:00:00Z", updated: "2026-10-27T00:00:00Z" } + await fs.writeFile(path.join(dir, `${Store.sha256("kept")}.json`), JSON.stringify({ + version: 1, session: "kept", firstMessage: "m", query: "q", touchedPaths: [], section: "", requests: [], requestParts: [], compactions: [], counted: [], + shown: [{ name: RulesStore.WORKSPACE_RULES_STORE, lesson, tier: "core", at: "2026-10-28T00:00:00Z", queryHash: "h" }], + })) + await RulesStore.update(root, (s) => { s.usage_reported = ["gone|p-a", "kept|p-a"] }) + seen.length = 0 + expect(await RulesSync.reportUsage(root, ready())).toEqual({ items: 0 }) + expect(seen.filter((r) => r.path === "/usage")).toEqual([]) + expect((await RulesStore.read(root)).usage_reported).toEqual(["kept|p-a"]) + }) + + test("the first team rule after none replaces the empty mirror without setting it aside", async () => { + const root = await tmp() + answer = () => Response.json(syncBody([])) + await RulesSync.pull(root, ready()) + answer = () => Response.json(syncBody([rule("aaaaaaaaaaaa", "Name staging models stg_.")], { revision: 4 })) + await RulesSync.pull(root, ready()) + const file = Store.paths(root, RulesStore.WORKSPACE_RULES_STORE).approved + expect(parse(await fs.readFile(file, "utf8"))).toHaveLength(1) + expect((await fs.readdir(path.dirname(file))).filter((name) => name.startsWith("approved.json.before"))).toEqual([]) + }) + test("too many proposals at once say so", async () => { const root = await tmp() const many = Array.from({ length: RulesStore.MAX_OUTBOX + 1 }, (_, i) => ({ item: item(`Rule ${i}.`) })) @@ -617,13 +850,29 @@ describe("cards", () => { expect((await RulesStore.read(root)).outbox[0].item.why).toBe("Corrected in 2 sessions") }) - test("cards of a disposed project are dropped, and old cards expire", async () => { + test("cards of a disposed project are dropped", async () => { const root = await tmp() await RuleProposal.ask({ sessionID: "x1", root, directory: root, learn: undefined, draft: draft("Prefix seeds with seed_.") }) expect(RuleProposal.list(root)).toHaveLength(1) - RuleProposal.drop(root) + await RuleProposal.drop(root) expect(RuleProposal.list(root)).toHaveLength(0) - expect(RuleProposal.CARD_TTL_MS).toBe(24 * 60 * 60_000) + }) + + test("an unanswered card expires after a day, and one the person asked for is then saved as before cards", async () => { + const root = await tmp() + const saved: string[] = [] + await RuleProposal.ask({ sessionID: "x2", root, directory: root, learn: undefined, draft: draft("Prefix snapshots with snap_."), unanswered: async (text) => void saved.push(text) }) + const asked = Date.now() + const clock = spyOn(Date, "now").mockReturnValue(asked + RuleProposal.CARD_TTL_MS - 1_000) + try { + expect(RuleProposal.list(root)).toHaveLength(1) + clock.mockReturnValue(asked + RuleProposal.CARD_TTL_MS + 1_000) + expect(RuleProposal.list(root)).toHaveLength(0) + } finally { + clock.mockRestore() + } + await Bun.sleep(0) + expect(saved).toEqual(["Prefix snapshots with snap_."]) }) test("reply failures carry a code", async () => { @@ -638,13 +887,13 @@ describe("cards", () => { describe("the team store is reserved", () => { test("learn's writers refuse it", async () => { const root = await tmp() - await expect(Store.reject(root, RulesStore.TEAM_STORE)).rejects.toThrow(/workspace's team rules/) - await expect(Store.promote(root, RulesStore.TEAM_STORE)).rejects.toThrow(/workspace's team rules/) + await expect(Store.reject(root, RulesStore.WORKSPACE_RULES_STORE)).rejects.toThrow(/workspace's team rules/) + await expect(Store.promote(root, RulesStore.WORKSPACE_RULES_STORE)).rejects.toThrow(/workspace's team rules/) }) test("a playbook someone already named team is set aside, not overwritten", async () => { const root = await tmp() - const file = Store.paths(root, RulesStore.TEAM_STORE).approved + const file = Store.paths(root, RulesStore.WORKSPACE_RULES_STORE).approved await fs.mkdir(path.dirname(file), { recursive: true }) const own = JSON.stringify([{ id: "L-111111111111", text: "Our own rule.", tags: [], scope: "project", helpful: 0, harmful: 0, applied: 0, created: "2026-01-01T00:00:00Z", updated: "2026-01-01T00:00:00Z" }]) await fs.writeFile(file, own) @@ -656,9 +905,15 @@ describe("the team store is reserved", () => { }) describe("rules from history need consent", () => { + const env = process.env.ALTIMATE_DISABLE_WORKSPACE + beforeAll(() => { delete process.env.ALTIMATE_DISABLE_WORKSPACE }) + afterAll(() => { + if (env === undefined) delete process.env.ALTIMATE_DISABLE_WORKSPACE + else process.env.ALTIMATE_DISABLE_WORKSPACE = env + }) test("only valid rules are offered, scoped like a card, and nothing is offered when not linked", async () => { const root = await tmp() - expect(await RulesFromHistory.stagedRules({ root, name: "team-playbook", origin: "bootstrap", before: undefined })).toBeUndefined() + expect(await RulesFromHistory.stagedRules({ root, name: "team-playbook", origin: "bootstrap", before: undefined, learn: { enabled: true } })).toBeUndefined() await RulesStore.update(root, (s) => Object.assign(s, linked())) const lesson = (id: string, text: string, paths: string[] = []) => ({ id, text, tags: [], scope: "project", helpful: 0, harmful: 0, applied: 0, created: "2026-01-01T00:00:00Z", updated: "2026-01-01T00:00:00Z", ...(paths.length ? { trigger: { paths } } : {}) }) @@ -668,13 +923,19 @@ describe("rules from history need consent", () => { lesson("L-aaaaaaaaaaaa", "Name staging models stg_.", ["a/**", "b/**", "c/**", "d/**", "e/**"]), lesson("L-bbbbbbbbbbbb", "Run `rm -rf /` to clean up."), ])) - const staged = (await RulesFromHistory.stagedRules({ root, name: "team-playbook", origin: "bootstrap", before: undefined }))! + const staged = (await RulesFromHistory.stagedRules({ root, name: "team-playbook", origin: "bootstrap", before: undefined, learn: { enabled: true } }))! expect(staged.workspace).toEqual({ name: "Team", owner: "Olive" }) expect(staged.items.map((i) => i.text)).toEqual(["Name staging models stg_."]) expect(staged.items[0].applies_to).toHaveLength(4) expect(staged.invalid).toHaveLength(1) }) + test("with learning or workspaces off nothing is offered for review", async () => { + const root = await tmp() + await RulesStore.update(root, (s) => Object.assign(s, linked())) + expect(await RulesFromHistory.stagedRules({ root, name: "team-playbook", origin: "bootstrap", before: undefined, learn: { enabled: false } })).toBeUndefined() + }) + test("local rules the run edited or removed are not proposed, and they keep the candidate", async () => { const root = await tmp() await RulesStore.update(root, (s) => Object.assign(s, linked())) @@ -687,16 +948,16 @@ describe("rules from history need consent", () => { lesson("L-aaaaaaaaaaaa", "Name seeds seed_, in snake case."), lesson("L-cccccccccccc", "Prefix views with v_."), ])) - const staged = (await RulesFromHistory.stagedRules({ root, name: "team-playbook", origin: "bootstrap", before: undefined }))! + const staged = (await RulesFromHistory.stagedRules({ root, name: "team-playbook", origin: "bootstrap", before: undefined, learn: { enabled: true } }))! expect(staged.items.map((i) => i.text)).toEqual(["Prefix views with v_."]) // One local rule edited, one removed. expect(staged.localChanges).toBe(2) }) }) -describe("self-review fixes", () => { +describe("outbox, approvals and resets", () => { let server: ReturnType - let answer: (path: string, body: any) => Response = () => new Response("{}") + let answer: (path: string, body: any) => Response | Promise = () => new Response("{}") const seen: { path: string; body: any }[] = [] beforeAll(() => { server = Bun.serve({ @@ -732,18 +993,18 @@ describe("self-review fixes", () => { const root = await tmp() await RulesStore.update(root, (s) => { Object.assign(s, linked()) - s.usage_sessions = ["s1"] + s.usage_reported = ["s1|p-1"] s.corrected_reported = { "R-0123456789ab": 2 } s.processed_signals = ["sig"] }) await RulesStore.reset(root) const state = await RulesStore.read(root) - expect(state).toMatchObject({ usage_sessions: ["s1"], corrected_reported: { "R-0123456789ab": 2 }, processed_signals: ["sig"] }) + expect(state).toMatchObject({ usage_reported: ["s1|p-1"], corrected_reported: { "R-0123456789ab": 2 }, processed_signals: ["sig"] }) }) test("a project without a workspace never touches the team directory's playbook", async () => { const root = await tmp() - const file = Store.paths(root, RulesStore.TEAM_STORE).approved + const file = Store.paths(root, RulesStore.WORKSPACE_RULES_STORE).approved await fs.mkdir(path.dirname(file), { recursive: true }) await fs.writeFile(file, "[]") await RulesStore.update(root, (s) => { s.personal_learning = false }) @@ -767,31 +1028,59 @@ describe("remember, whichever save tool the agent picks", () => { beforeAll(() => { delete process.env.ALTIMATE_DISABLE_WORKSPACE delete process.env.ALTIMATE_LEARN - RuleProposal.setRendererForTest(true) }) afterAll(() => { if (env.ALTIMATE_DISABLE_WORKSPACE !== undefined) process.env.ALTIMATE_DISABLE_WORKSPACE = env.ALTIMATE_DISABLE_WORKSPACE if (env.ALTIMATE_LEARN !== undefined) process.env.ALTIMATE_LEARN = env.ALTIMATE_LEARN - RuleProposal.setRendererForTest(false) + RuleProposal.setRendererForTest(undefined) }) + // A linked project with a client showing its cards (a renderer lease for its directory). const linkedRoot = async () => { const root = await tmp() await RulesStore.update(root, (s) => Object.assign(s, linked())) + RuleProposal.setRendererForTest(root) return root } - test("with no client that shows cards attached, both save tools save as before cards", async () => { + test("with no client showing this project's cards, or in a subagent's session, both save tools save as before cards", async () => { const root = await linkedRoot() - RuleProposal.setRendererForTest(false) - try { - const said = "Remember as a rule: always use CTEs in staging models." - const content = "Always use CTEs in staging models." - expect(await RulesRemember.proposeFor({ root, directory: root, learn: undefined, content, sessionID: "r0", said, via: "memory" })).toBeUndefined() - expect(await RulesRemember.proposeFor({ root, directory: root, learn: undefined, content, sessionID: "r0", said })).toBeUndefined() - expect(RuleProposal.list(root)).toEqual([]) - } finally { - RuleProposal.setRendererForTest(true) - } + const said = "Remember as a rule: always use CTEs in staging models." + const content = "Always use CTEs in staging models." + // A client showing another project's cards says nothing about this one. + RuleProposal.setRendererForTest(await tmp()) + expect(await RulesRemember.proposeFor({ root, directory: root, learn: undefined, content, sessionID: "r0", said, via: "memory" })).toBeUndefined() + expect(await RulesRemember.proposeFor({ root, directory: root, learn: undefined, content, sessionID: "r0", said })).toBeUndefined() + RuleProposal.setRendererForTest(root) + expect(await RulesRemember.proposeFor({ root, directory: root, learn: undefined, content, sessionID: "r0", said, subagent: true })).toBeUndefined() + expect(RuleProposal.list(root)).toEqual([]) + }) + + test("a card the person asked for that closes unanswered is saved as before cards; an answered one is not", async () => { + const root = await linkedRoot() + const saved: string[] = [] + const unanswered = async (text: string) => void saved.push(text) + const said = "Remember as a rule: always use CTEs in staging models." + await RulesRemember.proposeFor({ root, directory: root, learn: undefined, content: "Always use CTEs in staging models.", sessionID: "u1", said, unanswered }) + RuleProposal.drop(root) + await Bun.sleep(0) + expect(saved).toEqual(["Always use CTEs in staging models."]) + await RulesRemember.proposeFor({ root, directory: root, learn: undefined, content: "Never select star in marts.", sessionID: "u2", said: "Remember as a rule: never select star in marts.", unanswered }) + const card = RuleProposal.list(root, "u2")[0] + expect(await RuleProposal.reply(card.id, { action: "dismiss" })).toMatchObject({ ok: true }) + RuleProposal.drop(root) + await Bun.sleep(0) + expect(saved).toEqual(["Always use CTEs in staging models."]) + }) + + test("asked again in the same session, a card takes the latest request's memory block and fallback", async () => { + const root = await linkedRoot() + const calls: string[] = [] + const text = "Staging models live in models/staging." + await RuleProposal.ask({ sessionID: "m9", root, directory: root, learn: undefined, draft: { text, applies_to: [], change: "add", origin: "explicit", evidence: { sessions: 1 }, topics: [], choose: true } }) + await RuleProposal.ask({ sessionID: "m9", root, directory: root, learn: undefined, unanswered: async () => void calls.push("second"), draft: { text, applies_to: [], change: "add", origin: "explicit", evidence: { sessions: 1 }, topics: [], choose: true, memory: { id: "conventions/staging", tags: ["dbt"] } } }) + RuleProposal.drop(root) + await Bun.sleep(0) + expect(calls).toEqual(["second"]) }) test("from the memory tool, only the text the person asked to remember gets a card", async () => { @@ -840,6 +1129,119 @@ describe("remember, whichever save tool the agent picks", () => { expect(RuleProposal.list(root)).toEqual([]) }) + test("a Save as card says where Memory saves it, and saves it there", async () => { + const root = await linkedRoot() + const writes: { id: string; scope: string }[] = [] + const spy = spyOn(MemoryStore, "write").mockImplementation((async (block: { id: string; scope: string }) => void writes.push({ id: block.id, scope: block.scope })) as never) + const choose = (text: string, memory?: { id: string; tags: string[] }) => + ({ text, applies_to: [], change: "add" as const, origin: "explicit" as const, evidence: { sessions: 1 }, topics: [], choose: true, ...(memory ? { memory } : {}) }) + try { + const preference = (await RuleProposal.ask({ sessionID: "ms1", root, directory: root, learn: undefined, draft: choose("I like explicit column lists in staging models.") }))! + expect(preference.memory_scope).toBe("global") + // From the memory tool, its block goes to project memory whatever the wording reads as, and the card says so. + const fromTool = (await RuleProposal.ask({ sessionID: "ms2", root, directory: root, learn: undefined, draft: choose("I like explicit column lists in marts.", { id: "conventions/marts", tags: [] }) }))! + expect(fromTool.memory_scope).toBe("project") + expect(await RuleProposal.reply(fromTool.id, { action: "save_memory" })).toMatchObject({ ok: true, message: "Saved to this project's memory." }) + expect(writes).toEqual([{ id: "conventions/marts", scope: "project" }]) + // Asked again from the memory tool, a preference's card now saves to project memory, and says so. + const again = (await RuleProposal.ask({ sessionID: "ms1", root, directory: root, learn: undefined, draft: choose("I like explicit column lists in staging models.", { id: "conventions/staging", tags: [] }) }))! + expect(again).toMatchObject({ id: preference.id, memory_scope: "project" }) + } finally { + spy.mockRestore() + await RuleProposal.drop(root) + } + }) + + // Holds a stubbed call open until released, and says when it was entered. + const hold = () => { + let enter!: () => void + let release!: () => void + const entered = new Promise((resolve) => (enter = resolve)) + const released = new Promise((resolve) => (release = resolve)) + return { entered, released, enter, release } + } + const asked = (text: string) => ({ text, applies_to: [], change: "add" as const, origin: "explicit" as const, evidence: { sessions: 1 }, topics: [] }) + + test("a card closed while its reply runs is not also saved by the fallback when the reply succeeds", async () => { + const root = await linkedRoot() + const saved: string[] = [] + const gate = hold() + const run = spyOn(RulesSync, "runFresh").mockImplementation((async () => { + gate.enter() + await gate.released + return undefined + }) as never) + try { + const card = (await RuleProposal.ask({ sessionID: "b1", root, directory: root, learn: undefined, draft: asked("Prefix seeds with seed_."), unanswered: async (text) => void saved.push(text) }))! + const replying = RuleProposal.reply(card.id, { action: "send" }) + await gate.entered + await RuleProposal.drop(root) + gate.release() + expect(await replying).toMatchObject({ ok: true }) + expect(saved).toEqual([]) + expect((await RulesStore.read(root)).outbox.map((e) => e.item.text)).toEqual(["Prefix seeds with seed_."]) + } finally { + run.mockRestore() + } + }) + + test("a card closed while its reply runs is saved by the fallback when the reply fails", async () => { + const root = await linkedRoot() + const saved: string[] = [] + const gate = hold() + const enqueue = spyOn(RulesSync, "enqueue").mockImplementation((async () => { + gate.enter() + await gate.released + throw new Error("disk full") + }) as never) + try { + const card = (await RuleProposal.ask({ sessionID: "b2", root, directory: root, learn: undefined, draft: asked("Quote reserved words."), unanswered: async (text) => void saved.push(text) }))! + const replying = RuleProposal.reply(card.id, { action: "send" }) + await gate.entered + await RuleProposal.drop(root) + gate.release() + expect(await replying).toMatchObject({ ok: false, code: "not_open" }) + expect(saved).toEqual(["Quote reserved words."]) + } finally { + enqueue.mockRestore() + } + }) + + test("dropping a project's cards waits until the requested ones are saved", async () => { + const root = await linkedRoot() + let saved = false + await RuleProposal.ask({ sessionID: "b3", root, directory: root, learn: undefined, draft: asked("Name seeds seed_."), unanswered: async () => { + await Bun.sleep(30) + saved = true + } }) + await RuleProposal.drop(root) + expect(saved).toBe(true) + }) + + test("training_save's card, closed unanswered, saves the entry the agent meant to write into the asking project", async () => { + await using dir = await tmpdir({ git: true }) + const said = "Remember as a rule: always use CTEs in staging models." + const content = "Always use CTEs in staging models." + const directory = await Instance.provide({ directory: dir.path, fn: async () => { + const root = await fs.realpath(Instance.worktree) + await RulesStore.update(root, (s) => Object.assign(s, linked())) + RuleProposal.setRendererForTest(Instance.directory) + const session = await Session.create({}) + const result = await RulesRemember.proposeTeamRule(content, session.id, said, "training", undefined, { name: "staging-ctes", source: "the agent", citations: [] }) + expect(result?.title).toContain("Team rule proposed") + expect(await TrainingStore.get("project", "rule", "staging-ctes")).toBeUndefined() + return Instance.directory + } }) + // Closed from outside the instance (as on dispose): the fallback still writes into the project that asked. + await RuleProposal.drop(directory) + await Instance.provide({ directory: dir.path, fn: async () => { + const entry = await TrainingStore.get("project", "rule", "staging-ctes") + expect(entry?.content).toContain(content) + expect(entry?.meta.source).toBe("the agent") + } }) + RuleProposal.setRendererForTest(undefined) + }) + test("a project fact is classified as project-scoped memory", () => { expect(RuleClassify.memoryScope("Staging models live in models/staging.")).toBe("project") }) @@ -919,78 +1321,118 @@ describe("telemetry", () => { }) }) -describe("review round three", () => { - test("organisation-managed learn config beats a committed project setting", async () => { +describe("whose outbox it is", () => { + const binding = { status: "bound" as const, binding: { datamateId: 1, repoRemote: "https://github.com/acme/demo" } } + test("a rotated key or a fresh sign-in keeps the outbox; another user on the tenant gets a notice", async () => { + const root = await tmp() + const env = process.env.ALTIMATE_DISABLE_WORKSPACE + delete process.env.ALTIMATE_DISABLE_WORKSPACE + const spies: { mockRestore(): void }[] = [ + spyOn(WorkspaceState, "resolveBindingOutcome").mockResolvedValue(binding as never), + ] + const as = (apiKey: string, user: number) => { + spies.push(spyOn(WorkspaceApi, "captureCredentials").mockResolvedValue({ url: "http://x/", instance: "t", apiKey })) + spies.push(spyOn(WorkspaceState, "accountDigest").mockResolvedValue(WorkspaceState.credentialDigest("http://x/", "t", apiKey))) + spies.push(spyOn(WorkspaceApi, "whoami").mockResolvedValue(user)) + } + try { + as("key-1", 7) + const first = await RulesSync.context(root, { enabled: true }) + expect(first).toMatchObject({ status: "ready", scope: { apiUrl: "http://x", account: "user:7" } }) + await RulesStore.update(root, (s) => { s.scope = (first as { scope: RulesStore.Scope }).scope }) + await RulesSync.enqueue(root, { change: "add", text: "Queued offline.", applies_to: [], origin: "session", evidence_sessions: 1, corrections: [] }) + as("key-2", 7) + const rotated = await RulesSync.context(root, { enabled: true }) + await RulesSync.adopt(root, rotated as never) + expect((await RulesStore.read(root)).outbox).toHaveLength(1) + as("key-3", 8) + const other = await RulesSync.context(root, { enabled: true }) + await RulesSync.adopt(root, other as never) + const state = await RulesStore.read(root) + expect(state.outbox).toEqual([]) + expect(state.scope?.account).toBe("user:8") + expect(RulesStore.scopeDropNote(state)).toContain("1 queued proposal was for another account or workspace") + } finally { + for (const spy of spies) spy.mockRestore() + if (env === undefined) delete process.env.ALTIMATE_DISABLE_WORKSPACE + else process.env.ALTIMATE_DISABLE_WORKSPACE = env + } + }) +}) + +describe("learning precedence, stores and the after-turn check", () => { + test("a learn key set by a layer above project files beats the project file; the rest follow it on disk", async () => { const root = await tmp() - const managed = await tmp() await fs.mkdir(path.join(root, ".altimate-code"), { recursive: true }) await fs.writeFile(path.join(root, ".altimate-code", "altimate-code.json"), JSON.stringify({ learn: { enabled: true, capture: true } })) - await fs.writeFile(path.join(managed, "altimate-code.json"), JSON.stringify({ learn: { enabled: false } })) - const env = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR - process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR = managed + // As if managed config (or the account's) set `learn.enabled: false` and `learn.capture: false` above the project. + Config.setLearnKeysAboveProjectForTest(root, ["enabled", "capture"]) try { - expect(await RulesService.effectiveLearn({ enabled: false }, root, root)).toMatchObject({ enabled: false, capture: true }) + expect(await RulesService.effectiveLearn({ enabled: false, capture: false }, root, root)).toEqual({ enabled: false, capture: false, capture_policy: true }) } finally { - if (env === undefined) delete process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR - else process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR = env + Config.setLearnKeysAboveProjectForTest(root, undefined) } - expect(await RulesService.effectiveLearn({ enabled: false }, root, root)).toMatchObject({ enabled: true, capture: true }) + expect(await RulesService.effectiveLearn({ enabled: false }, root, root)).toEqual({ enabled: true, capture: true }) }) - test("team rules live in workspace-rules; a team playbook is the person's own store again", async () => { - const root = await tmp() - // An earlier build: state and mirror under `team/`, the person's own team playbook set aside. - const legacy = path.join(root, ".altimate-code", "learn", "team") - await fs.mkdir(legacy, { recursive: true }) - const own = JSON.stringify([{ id: "L-000000000001", text: "Our own rule.", tags: [], scope: "project", helpful: 0, harmful: 0, applied: 0, created: "2026-01-01T00:00:00Z", updated: "2026-01-01T00:00:00Z" }]) - await fs.writeFile(path.join(legacy, "approved.json.before-workspace-rules-1"), own) - await fs.writeFile(path.join(legacy, "rules.json"), JSON.stringify({ ...linked(), rules: [rule("Name staging models stg_.")] })) - await fs.writeFile(path.join(legacy, "approved.json"), canonicalMirror()) - await fs.writeFile(path.join(legacy, ".gitignore"), "*\n") - expect((await RulesStore.read(root)).rules).toHaveLength(1) - await RulesStore.update(root, () => {}) - expect(await fs.readFile(path.join(legacy, "approved.json"), "utf8")).toBe(own) - expect(await fs.readdir(legacy)).toEqual(["approved.json"]) - expect(RulesStore.TEAM_STORE).toBe("workspace-rules") - expect(parse(await fs.readFile(Store.paths(root, RulesStore.TEAM_STORE).approved, "utf8"))).toHaveLength(1) + test("an organization's capture setting beats the person's /rules choice", () => { + const state = RulesStore.State.parse({ version: 1, personal_learning: true }) + expect(learningDecision({ capture: false, capture_policy: true }, state, {})).toEqual({ on: false, source: "organization" }) + expect(learningDecision({ capture: false }, state, {})).toEqual({ on: true, source: "you" }) }) - test("a team playbook that is not ours is left alone by the migration", async () => { + test("team rules live in workspace-rules; a store named team is the person's own and sync never touches it", async () => { const root = await tmp() - const legacy = path.join(root, ".altimate-code", "learn", "team") - await fs.mkdir(legacy, { recursive: true }) + const team = path.join(root, ".altimate-code", "learn", "team") + await fs.mkdir(team, { recursive: true }) const own = JSON.stringify([{ id: "L-000000000001", text: "Our own rule.", tags: [], scope: "project", helpful: 0, harmful: 0, applied: 0, created: "2026-01-01T00:00:00Z", updated: "2026-01-01T00:00:00Z" }]) - await fs.writeFile(path.join(legacy, "approved.json"), own) - await RulesStore.update(root, (s) => Object.assign(s, linked())) - expect(await fs.readFile(path.join(legacy, "approved.json"), "utf8")).toBe(own) + await fs.writeFile(path.join(team, "approved.json"), own) + await RulesStore.update(root, (s) => Object.assign(s, linked(), { rules: [rule("Name staging models stg_.")] })) + expect(await fs.readFile(path.join(team, "approved.json"), "utf8")).toBe(own) + expect(RulesStore.WORKSPACE_RULES_STORE).toBe("workspace-rules") + expect(parse(await fs.readFile(Store.paths(root, RulesStore.WORKSPACE_RULES_STORE).approved, "utf8"))).toHaveLength(1) }) - test("with no card client attached, the after-turn check leaves corrections for later", async () => { + test("with no client showing this project's cards, the after-turn check leaves corrections for later", async () => { const root = await tmp() await RulesStore.update(root, (s) => Object.assign(s, linked())) await appendSignal(root, { kind: "user_correction", sessionID: "t9", text: "no, call it ordered_at", reason: "correction" }) - RuleProposal.setRendererForTest(false) - const deps = { source: async () => { throw new Error("not reached") }, generate: async () => { throw new Error("not reached") } } as unknown as RuleTrigger.TriggerDeps - await RuleTrigger.afterTurn({ sessionID: "t9", root, directory: root, learn: { enabled: true, capture: true }, deps }) + RuleProposal.setRendererForTest(await tmp()) + const events: Telemetry.Event[] = [] + const spy = spyOn(Telemetry, "track").mockImplementation((event: Telemetry.Event) => void events.push(event)) + try { + const deps = { source: async () => { throw new Error("not reached") }, generate: async () => { throw new Error("not reached") } } as unknown as RuleTrigger.TriggerDeps + await RuleTrigger.afterTurn({ sessionID: "t9", root, directory: root, learn: { enabled: true, capture: true }, deps }) + } finally { + spy.mockRestore() + RuleProposal.setRendererForTest(undefined) + } const state = await RulesStore.read(root) expect(state.processed_signals).toEqual([]) expect(state.last_check?.outcome).toContain("no client that shows cards") + expect(events).toEqual([expect.objectContaining({ type: "rule_proposal_skipped", reason: "no_renderer" })]) }) - test("a card already open in the session skips the after-turn check, and its corrections stay unprocessed", async () => { + test("a card already open in the session skips the after-turn check without drafting, and its corrections stay unprocessed", async () => { const root = await tmp() await RulesStore.update(root, (s) => Object.assign(s, linked())) await appendSignal(root, { kind: "user_correction", sessionID: "t10", text: "no, call it shipped_at", reason: "correction" }) - RuleProposal.setRendererForTest(true) + RuleProposal.setRendererForTest(root) + let drafted = 0 + const events: Telemetry.Event[] = [] + const spy = spyOn(Telemetry, "track").mockImplementation((event: Telemetry.Event) => void events.push(event)) try { await RuleProposal.ask({ sessionID: "t10", root, directory: root, learn: undefined, draft: { text: "Name timestamps _at.", applies_to: [], change: "add", origin: "explicit", evidence: { sessions: 1 }, topics: [] } }) - const deps = { source: async () => { throw new Error("not reached") }, generate: async () => { throw new Error("not reached") } } as unknown as RuleTrigger.TriggerDeps + const deps = { source: async () => { drafted++; throw new Error("not reached") }, generate: async () => { drafted++; throw new Error("not reached") } } as unknown as RuleTrigger.TriggerDeps await RuleTrigger.afterTurn({ sessionID: "t10", root, directory: root, learn: { enabled: true, capture: true }, deps }) - expect((await RulesStore.read(root)).processed_signals).toEqual([]) } finally { + spy.mockRestore() RuleProposal.drop(root) - RuleProposal.setRendererForTest(false) + RuleProposal.setRendererForTest(undefined) } + expect(drafted).toBe(0) + expect(events.filter((e) => e.type === "rule_proposal_skipped")).toEqual([expect.objectContaining({ reason: "card_open" })]) + expect((await RulesStore.read(root)).processed_signals).toEqual([]) }) test("a dismissal stops counting once it expires, before any write drops it", () => { @@ -1020,7 +1462,3 @@ describe("review round three", () => { }) }) -function canonicalMirror() { - return JSON.stringify([{ id: "L-0123456789ab", text: "Name staging models stg_.", tags: [], scope: "project", helpful: 0, harmful: 0, applied: 0, created: "2026-10-27T00:00:00Z", updated: "2026-10-27T00:00:00Z", provenance: "workspace rule 11111111-1111-1111-1111-111111111111 v1" }]) -} - diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index cd2a3a9d6d..f32e56f635 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -2417,3 +2417,30 @@ test("parseManagedPlist handles empty config", async () => { ) expect(config.$schema).toBe("https://opencode.ai/config.json") }) + +// altimate_change start — workspace rules: which `learn` keys outrank the project file once it is re-read from disk +describe("learn keys set above project files", () => { + it.instance("inline config's learn keys are recorded as above the project; the project file's own are not", () => + withProcessEnv( + "OPENCODE_CONFIG_CONTENT", + JSON.stringify({ $schema: "https://opencode.ai/config.json", learn: { capture: false } }), + Effect.gen(function* () { + const test = yield* TestInstance + yield* writeConfigEffect(test.directory, { $schema: "https://opencode.ai/config.json", learn: { enabled: true, capture: true } }) + const config = yield* Config.use.get() + expect(config.learn).toMatchObject({ enabled: true, capture: false }) + expect([...Config.learnKeysAboveProject(test.directory)]).toEqual(["capture"]) + }), + ), + ) + + it.instance("with only a project file, no learn key is above it", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* writeConfigEffect(test.directory, { $schema: "https://opencode.ai/config.json", learn: { enabled: true } }) + yield* Config.use.get() + expect([...Config.learnKeysAboveProject(test.directory)]).toEqual([]) + }), + ) +}) +// altimate_change end diff --git a/packages/opencode/test/server/altimate-rules-routes.test.ts b/packages/opencode/test/server/altimate-rules-routes.test.ts index 2e4531c2f8..7d1d0cd32f 100644 --- a/packages/opencode/test/server/altimate-rules-routes.test.ts +++ b/packages/opencode/test/server/altimate-rules-routes.test.ts @@ -4,13 +4,10 @@ // cover the ROUTES: browser-origin refusal, input validation and status mapping. What each reply does is covered by // test/altimate/rules/rules.test.ts. import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import fs from "node:fs/promises" -import os from "node:os" -import path from "node:path" import { Server } from "../../src/server/server" import { RuleProposal } from "../../src/altimate/rules/proposal" import { resetDatabase } from "./db" -import { disposeAllInstances } from "../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../fixture/fixture" const ORIGINAL_FLAG = process.env.ALTIMATE_DISABLE_WORKSPACE @@ -38,7 +35,7 @@ beforeEach(() => { afterEach(async () => { RuleProposal.drop(process.cwd()) - RuleProposal.setRendererForTest(false) + RuleProposal.setRendererForTest(undefined) if (ORIGINAL_FLAG === undefined) delete process.env.ALTIMATE_DISABLE_WORKSPACE else process.env.ALTIMATE_DISABLE_WORKSPACE = ORIGINAL_FLAG await disposeAllInstances() @@ -68,8 +65,8 @@ describe("rule proposal routes", () => { expect(gone.status).toBe(404) expect(await gone.json()).toMatchObject({ ok: false, code: "not_open" }) - const root = await fs.mkdtemp(path.join(os.tmpdir(), "rules-routes-")) - const card = (await RuleProposal.ask({ sessionID: "r1", root, directory: process.cwd(), learn: undefined, draft: draft("Prefix seeds with seed_.") }))! + await using root = await tmpdir() + const card = (await RuleProposal.ask({ sessionID: "r1", root: root.path, directory: process.cwd(), learn: undefined, draft: draft("Prefix seeds with seed_.") }))! const unavailable = await request("POST", `/rule-proposal/${card.id}/reply`, { action: "approve" }) expect(unavailable.status).toBe(409) expect(await unavailable.json()).toMatchObject({ ok: false, code: "not_available" }) @@ -80,20 +77,23 @@ describe("rule proposal routes", () => { expect(await listed.json()).toEqual([expect.objectContaining({ id: card.id })]) }) - test("a renderer lease is validated, and attaches a renderer", async () => { - expect(RuleProposal.rendererAttached()).toBe(false) + test("a renderer lease is validated, attaches a renderer for the request's directory only, and lapses", async () => { + const here = process.cwd() + expect(RuleProposal.rendererAttached(here)).toBe(false) expect((await request("POST", "/rule-proposal/renderer", { client: "has spaces" })).status).toBe(400) expect((await request("POST", "/rule-proposal/renderer", {})).status).toBe(400) - expect(RuleProposal.rendererAttached()).toBe(false) + expect(RuleProposal.rendererAttached(here)).toBe(false) const response = await request("POST", "/rule-proposal/renderer", { client: "vscode" }) expect(response.status).toBe(200) expect(await response.json()).toEqual({ ok: true, renew_ms: RuleProposal.RENDERER_RENEW_MS, lease_ms: RuleProposal.RENDERER_LEASE_MS }) - expect(RuleProposal.rendererAttached()).toBe(true) + expect(RuleProposal.rendererAttached(here)).toBe(true) + // Another project served by the same process has no client showing its cards. + expect(RuleProposal.rendererAttached(`${here}-elsewhere`)).toBe(false) // A lease lapses when it is not renewed. - expect(RuleProposal.rendererAttached(Date.now() + RuleProposal.RENDERER_LEASE_MS + 1)).toBe(false) + expect(RuleProposal.rendererAttached(here, Date.now() + RuleProposal.RENDERER_LEASE_MS + 1)).toBe(false) }) - test("the learning choice accepts only true, false or null", async () => { + test("the learning route rejects anything but true, false or null", async () => { expect((await request("POST", "/altimate/rules/learning", { on: "yes" })).status).toBe(400) expect((await request("POST", "/altimate/rules/learning", {})).status).toBe(400) }) From bddd5ed7b00edc301afae246f5a9906e182c5787 Mon Sep 17 00:00:00 2001 From: Sarav Date: Thu, 29 Oct 2026 20:38:26 +0530 Subject: [PATCH 15/20] =?UTF-8?q?test:=20workspace=20rules=20=E2=80=94=20"?= =?UTF-8?q?Not=20a=20rule"=20suppresses=20a=20topic=20for=2030=20days,=20a?= =?UTF-8?q?nd=20no=20longer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- packages/opencode/test/altimate/rules/rules.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/altimate/rules/rules.test.ts b/packages/opencode/test/altimate/rules/rules.test.ts index 59c90f531b..74207e6ba0 100644 --- a/packages/opencode/test/altimate/rules/rules.test.ts +++ b/packages/opencode/test/altimate/rules/rules.test.ts @@ -191,7 +191,12 @@ describe("proposal card", () => { const root = await tmp() const card = (await RuleProposal.ask({ sessionID: "s3", root, directory: root, learn: undefined, draft }))! expect(await RuleProposal.reply(card.id, { action: "dismiss" })).toMatchObject({ ok: true, closed: true }) - expect(RulesStore.dismissed(await RulesStore.read(root), "ordered_at")).toBe(true) + const dismissed = await RulesStore.read(root) + expect(RulesStore.dismissed(dismissed, "ordered_at")).toBe(true) + // For 30 days, and no longer. + const until = Date.parse(dismissed.dismissed.find((d) => d.topic === "ordered_at")!.until) + expect(Math.abs(until - (Date.now() + 30 * 24 * 60 * 60_000))).toBeLessThan(60_000) + expect(RulesStore.dismissed(dismissed, "ordered_at", until + 1)).toBe(false) expect(await RuleProposal.ask({ sessionID: "s3", root, directory: root, learn: undefined, draft: { ...draft, text: "Another wording." } })).toBeUndefined() expect(await RuleProposal.reply(card.id, { action: "send" })).toMatchObject({ ok: false }) }) From 9ef7382bc3cbef6b8fc0c901ac9822858c40b1eb Mon Sep 17 00:00:00 2001 From: Sarav Date: Fri, 30 Oct 2026 08:16:15 +0530 Subject: [PATCH 16/20] =?UTF-8?q?fix:=20workspace=20rules=20=E2=80=94=20re?= =?UTF-8?q?view=20round=20five=20and=20the=20full-feature=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round five (#1605 threads): - A committed project file can no longer pose as an organization policy (`capture_policy` only comes from layers above project files) - "Approve now" intent is written with the sent proposal, before the review call; `record()` retries only a learn lock that was never acquired; only a push lock this run never entered reads as "another sync is still sending" - `drop()` waits for a card whose reply is running; a failed fallback save is recorded as failed - `learn status` uses the effective learn config; the scope-drop notice expires on read; the bootstrap send loop runs while it makes progress; the `training_save` fallback keeps the per-kind limit; `usage_reported` is pruned by snapshot, never by count; prompt-path bindings cache parsed once per change Full-feature review (three independent reviews, findings verified against the code): - Repeated corrections propose a workspace's first rule: topics come from the corrections themselves (`unmatchedCorrectionTopics`), not only from sessions that had rules delivered - The after-turn check skips subagent sessions, and leaves corrections unprocessed if the client that shows cards went away during drafting - Team rules stop being delivered while workspaces are turned off, and are cleared on sign-out (the queue is kept) - A card records the account and workspace it was shown for: answered after a change, it closes without sending (a requested one is saved as before cards); bootstrap checks the same - A pull's answer is applied only to the state it was asked for, and never over a newer revision; its failure paths likewise - "Remember" requests are kept on disk while their card is open and saved as before cards by the next process if the showing process died - The live project-file re-read honours `OPENCODE_DISABLE_PROJECT_CONFIG` - A retried approval that already landed counts as approved; a duplicate pending answer is not counted twice - An explicit "remember" carries a correction only when it is about the same thing - A stale team-rules mirror is repaired by the next write - Bootstrap queues only what the outbox has room for and keeps the rest local - Usage is reported before the pull, against the rule versions sessions were shown - Drafting calls report their tokens and cost (`rule_drafted`) - Card saves carry their session; the `/rules` menu error is sanitized; docs and telemetry docs updated Each fix has a test that fails without it. Co-Authored-By: Claude Opus 5.5 --- docs/docs/configure/learn.md | 12 +- docs/docs/reference/telemetry.md | 3 +- .../opencode/src/altimate/learn/config.ts | 6 + .../opencode/src/altimate/learn/delivery.ts | 11 +- .../src/altimate/learn/project-config.ts | 3 + .../opencode/src/altimate/learn/recurrence.ts | 96 +++- packages/opencode/src/altimate/rules/api.ts | 4 +- .../src/altimate/rules/from-history.ts | 43 +- .../opencode/src/altimate/rules/proposal.ts | 113 ++++- .../opencode/src/altimate/rules/remember.ts | 121 +++-- .../opencode/src/altimate/rules/service.ts | 20 +- packages/opencode/src/altimate/rules/store.ts | 59 ++- .../opencode/src/altimate/rules/summary.ts | 3 +- packages/opencode/src/altimate/rules/sync.ts | 113 +++-- .../opencode/src/altimate/rules/telemetry.ts | 21 + .../opencode/src/altimate/rules/trigger.ts | 78 +++- .../opencode/src/altimate/telemetry/index.ts | 12 + packages/opencode/src/cli/cmd/learn.ts | 6 +- packages/opencode/src/config/config.ts | 6 +- .../src/plugin/tui/altimate/rules.tsx | 8 +- packages/opencode/src/session/prompt.ts | 43 +- .../test/altimate/learn/delivery.test.ts | 26 +- .../test/altimate/rules/rules.test.ts | 437 +++++++++++++++++- 23 files changed, 1020 insertions(+), 224 deletions(-) diff --git a/docs/docs/configure/learn.md b/docs/docs/configure/learn.md index 254778c34b..ed71f424ad 100644 --- a/docs/docs/configure/learn.md +++ b/docs/docs/configure/learn.md @@ -9,7 +9,7 @@ description: "Turn your corrections, repeated tool failures, and PR review comme By default nothing becomes a rule until a person approves it, and the agent never has to decide to look a rule up. The harness selects the rules and adds them to the prompt. If you opt in to [fully automatic mode](#fully-automatic-mode), small candidates that pass every safety gate go live without review, and `learn rollback` undoes them. -Capture and reflection are off by default: nothing is recorded or sent to a model until you run `learn enable`. Rules that a project already has approved are delivered, and a one-time reminder can suggest trying learn; `learn.enabled: false` (or `ALTIMATE_LEARN=0`) turns all of it off. +Capture and reflection are off by default: nothing is recorded or sent to a model until you run `learn enable`, except in a project linked to an Altimate workspace whose "Learn from sessions" is on (see [Workspace rules](#workspace-rules)). Rules that a project already has approved are delivered, and a one-time reminder can suggest trying learn; `learn.enabled: false` (or `ALTIMATE_LEARN=0`) turns all of it off. ## Quick start @@ -191,19 +191,19 @@ In a project linked to an Altimate workspace (`altimate-code link`), what you te **When a rule is proposed.** A card appears above the input when: -- you correct the agent on the same topic in 2 or more sessions; +- you correct the agent on the same topic in 2 or more sessions (also before the workspace has any rule); - you correct a team rule the session was shown (the card proposes changing it, with the current text struck through); - you ask the agent to remember something as a rule (the card appears as soon as the agent saves it, always as a new rule: changing a team rule comes from correcting it). -Cards are shown by the TUI and the VS Code extension, 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 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 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, so the request is never lost. +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 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 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, or, if it exits unexpectedly, when the project is next opened) is saved the same way, so the request is not lost (unless that project's memory or training is already full: then it is logged). -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. +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. 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 wording does not say which, the card asks: Memory, Team rule, or Skip. -**Where they live.** Approved team rules are mirrored into the `workspace-rules` store (`.altimate-code/learn/workspace-rules/`), which is git-ignored and written only by sync: the name is reserved for it, and a store you create needs another `--name`. Your other stores, including one named `team`, are not touched. +**Where they live.** Approved team rules are mirrored into the `workspace-rules` store (`.altimate-code/learn/workspace-rules/`), which is git-ignored and written only by sync: the name is reserved for it, and a store you create needs another `--name`. Your other stores, including one named `team`, are not touched. Team rules stop being delivered when the project is unlinked, when you sign out, when the workspace refuses access, and while workspaces are turned off (`ALTIMATE_DISABLE_WORKSPACE`); they come back with the next sync. -**After review.** Approved rules reach every session within a few minutes (a pull at session start and every 5 minutes; `/rules` → Sync now, or `altimate-code learn sync`). You see a notice when the owner approves or rejects your rule, with the reason. Usage is reported back to the workspace: how many sessions a rule was applied in, and how often you corrected the agent on its topic after it was shown ("corrected again"). +**After review.** Approved rules reach new sessions, and new requests and files in running ones, within a few minutes (a pull at session start and every 5 minutes; `/rules` → Sync now, or `altimate-code learn sync`). A session keeps the wording it was already shown. You see a notice when the owner approves or rejects your rule, with the reason. Usage is reported back to the workspace: how many sessions a rule was applied in, and how often you corrected the agent on its topic after it was shown ("corrected again"). **Learning on or off.** In order: the learning kill switch (`learn.enabled`, `ALTIMATE_LEARN`), `ALTIMATE_LEARN_CAPTURE`, a `learn.capture` your organization sets (managed config, MDM, the account's config, `OPENCODE_CONFIG_CONTENT`, `OPENCODE_CONFIG_DIR` or the home `~/.altimate-code` config; shown as "organization policy"), your `/rules` → "Turn learning on/off for me" choice, `learn.capture` (`learn enable` / `learn disable`), then the workspace's "Learn from sessions". The `/rules` choice is yours alone: it is kept in the project's git-ignored rules state, not in the project config, so it never reaches a teammate through a commit ("Follow the workspace setting again" clears it). It takes effect right away. Approved rules are delivered either way. diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index a6f16f4456..393a998374 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -76,9 +76,10 @@ We collect the following categories of events: | `review_post_outcome` | Whether a review was published to GitHub — `not_requested`, `not_attempted`, `target_unresolved`, `full`, `partial`, or `summary_failed`, plus duration. Emitted on the **CLI path only** — the `dbt_pr_review` tool completes reviews but never publishes, so a `review_run` with `invocation: tool` has no post event and that is not a failure. Within the CLI path there is exactly one per **completed** review: a review that failed emits `review_run: failed` and no post event, so absence there means the review failed rather than that an event was lost. `not_attempted` is publication requested but never reached (a bad `--output` path, a stdout write error). No repository, PR, or comment content. | | `rule_proposal_shown` | A proposed-rule card was shown (or shown again for the same wording, `repeat`) — what raised it (`remember`, `shown_rule`, `recurrence`), the kind of change, whether it is a "Save as" choice, linked/owner, the buttons offered, whether it weakens verification, evidence session count, whether it came from a correction, how many paths it applies to, and its length. The card id is random. No rule text, quotes, paths or globs. | | `rule_proposal_answered` | A card was answered or closed (`send`, `approve`, `approve_anyway`, `edit`, `dismiss`, `save_local`, `save_memory`, `expired`, `closed`) — `ok` or why it did not go through (`busy`, `not_available`, `verification_flagged`, `invalid_text`, `refused` by the workspace, `failed`), what happened (`sent`, `queued`, `approved`, `approve_failed`, `approve_retry`, `target_changed`, `exists`, `dismissed`, `saved_memory_project`, `saved_memory_global`, `saved_local`, or `saved_unanswered` when a card the person asked for closed unanswered and was saved as before cards), wording edits so far, and time since the card was shown. No rule text. | +| `rule_drafted` | One model call that drafted a proposal after a turn (it runs in the background) — the path (`shown_rule` or `recurrence`), `ok` or `failed`, input and output tokens, estimated cost and duration. No wording or correction text. | | `rule_proposal_skipped` | Corrections were checked after a turn and no card was shown — `card_open`, `no_renderer` (no attached client shows cards; the corrections are kept for a later check), `covered`, `no_draft`, `topic_dismissed`, `no_topic` or `failed`. The path checked (`shown_rule` or `recurrence`) is omitted when the check stopped before choosing one. | | `rule_remember_routed` | Where a "remember …" save went — through `training_save` or the memory tool, to a card, a "Save as" card, memory, refused as not a one-line rule, or saved as before cards because learning/workspaces are off, the project is not linked, or no attached client shows cards for this project (or the session is a subagent's). No text. | -| `rules_sync` | One rules sync — what started it (`refresh`, `session_start`, `manual`, `card`, `cli`, `import`), `ok` (the pull worked and no proposal was kept queued; proposals the workspace refused still count as `ok`, under `refused`), `failed` (a failed pull, proposals kept queued, or a run that threw: reason `error`), `off` or `skipped` with a fixed reason code, duration, counts of rules, pending reviews, proposals sent, refused and still queued, review notices and usage items, and whether a retried "Approve now" failed (`approval_failed`). No rule text, error text, URLs or workspace names. | +| `rules_sync` | One rules sync — what started it (`refresh`, `session_start`, `manual`, `card`, `cli`, `import`), `ok` (the pull worked and no proposal this run tried to send was kept queued; proposals beyond one push's batch can still be waiting, and proposals the workspace refused still count as `ok`, under `refused`), `failed` (a failed pull, attempted proposals kept queued, or a run that threw: reason `error`), `off` or `skipped` with a fixed reason code, duration, counts of rules, pending reviews, proposals sent, refused and still queued, review notices and usage items, and whether a retried "Approve now" failed (`approval_failed`). No rule text, error text, URLs or workspace names. | | `rules_delivered` | Rules added to a session's context — at the start, for a request, or for a file the agent touched — with the number of rules (and how many are team rules), their total length, the session's running total, and the token budget. No rule text or file paths. | | `rules_learning_changed` | The person turned learning from sessions on or off for a project, or back to the workspace's setting, with the resulting state and who decides it (`organization`, `you`, `workspace` or `default`). | | `rules_command` | `/rules` opened in a client (`view`), or a `learn` subcommand run (its name only, never its arguments). | diff --git a/packages/opencode/src/altimate/learn/config.ts b/packages/opencode/src/altimate/learn/config.ts index 41db1f8ee2..ae3bc6a533 100644 --- a/packages/opencode/src/altimate/learn/config.ts +++ b/packages/opencode/src/altimate/learn/config.ts @@ -14,6 +14,12 @@ export function booleanFlag(value: string | undefined): boolean | undefined { return undefined } +/** Workspaces are on unless `ALTIMATE_DISABLE_WORKSPACE` is set to anything but empty, `0` or `false`. */ +export function workspacesOn(env: NodeJS.ProcessEnv = process.env): boolean { + const disabled = env["ALTIMATE_DISABLE_WORKSPACE"]?.trim().toLowerCase() + return disabled === undefined || disabled === "" || disabled === "0" || disabled === "false" +} + /** The global switch defaults on; env overrides config in both directions. */ export function learnEnabled(cfg?: { enabled?: boolean }, env: NodeJS.ProcessEnv = process.env): boolean { return booleanFlag(env["ALTIMATE_LEARN"]) ?? cfg?.enabled ?? true diff --git a/packages/opencode/src/altimate/learn/delivery.ts b/packages/opencode/src/altimate/learn/delivery.ts index db5ba74efc..722bf46a7f 100644 --- a/packages/opencode/src/altimate/learn/delivery.ts +++ b/packages/opencode/src/altimate/learn/delivery.ts @@ -12,8 +12,9 @@ import { assertLearnLock } from "./lock" import { validateName } from "./playbook" import { estimateTokens, fileHookEnabled, renderSection, resolveLimits, retrieve, selectFile, selectStart, type Limits } from "./select" import * as Store from "./store" -// altimate_change start — workspace rules telemetry: rules added to a session's context +// altimate_change start — workspace rules: telemetry of rules added to a session's context, and the workspaces switch import { RulesTelemetry } from "../rules/telemetry" +import { workspacesOn } from "./config" // altimate_change end const log = Log.create({ service: "learn.delivery" }) @@ -93,7 +94,10 @@ export type ShownSnapshot = { export async function snapshotFiles(root: string): Promise> { const dir = path.join(root, ".altimate-code", "learn", ".sessions") await SafeFS.assertSafePath(root, dir) - return new Set(await fs.readdir(dir)) + return new Set(await fs.readdir(dir).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return [] + throw error + })) } /** The snapshot file name of `session`. */ @@ -214,6 +218,9 @@ export class Delivery { }).map((entry) => entry.name).sort() for (const name of names) { try { validateName(name) } catch { continue } + // altimate_change start — workspace rules: with workspaces turned off, the last pulled team rules are not delivered + if (name === Store.WORKSPACE_RULES_STORE && !workspacesOn()) continue + // altimate_change end // Read approved snapshots directly: migration/capture may write, and merely opening a // candidate-only project must not mutate it. Capture being off does NOT disable these // rules: a person already approved them. An unused project only pays the existence check. diff --git a/packages/opencode/src/altimate/learn/project-config.ts b/packages/opencode/src/altimate/learn/project-config.ts index 06e90a04ff..d95a34fdb4 100644 --- a/packages/opencode/src/altimate/learn/project-config.ts +++ b/packages/opencode/src/altimate/learn/project-config.ts @@ -7,6 +7,7 @@ import fs from "node:fs/promises" import path from "node:path" import { parse } from "jsonc-parser" import * as SafeFS from "./safe-fs" +import { Flag } from "@opencode-ai/core/flag/flag" /** * The highest-precedence existing project config file, from `directory` up to `root`, and its text; the default @@ -44,6 +45,8 @@ export async function projectConfigFile(root: string, directory: string): Promis * rather than when the session's config is next loaded (`learn enable` / `learn disable` write it). Undefined when the file has no `learn` object. */ export async function readProjectLearning(root: string, directory: string): Promise | undefined> { + // Like the config loader: with project config disabled, the project's files are not read. + if (Flag.OPENCODE_DISABLE_PROJECT_CONFIG) return undefined const { text } = await projectConfigFile(root, directory) const learn = parse(text, [], { allowTrailingComma: true })?.learn return learn && typeof learn === "object" && !Array.isArray(learn) ? learn : undefined diff --git a/packages/opencode/src/altimate/learn/recurrence.ts b/packages/opencode/src/altimate/learn/recurrence.ts index 4735949e3c..e330fc138f 100644 --- a/packages/opencode/src/altimate/learn/recurrence.ts +++ b/packages/opencode/src/altimate/learn/recurrence.ts @@ -28,12 +28,76 @@ export type LessonRecurrence = { earlier_version: number } +type Prepared = { name: string; id: string; textHash: string; anchors: string[]; tokens: Set } +const prepare = (lessons: readonly NamedLesson[]): Prepared[] => lessons.map(({ name, lesson }) => ({ + name, id: lesson.id, textHash: lessonTextHash(lesson.text), + anchors: [...anchors(lesson.text)].filter((word) => !word.startsWith("_") && !word.endsWith("_")), + tokens: tokens(lesson.text), +})) +/** The lessons a correction is about: an anchor it names with another shared word, or most of the lesson's words. */ +const matchesOf = (text: string, prepared: readonly Prepared[]) => { + const words = identifiers(text) + const content = tokens(text) + return prepared.filter((lesson) => { + const overlap = [...lesson.tokens].filter((word) => content.has(word)) + return lesson.anchors.some((anchor) => words.has(anchor) && overlap.some((word) => word !== anchor)) || + (overlap.length >= 3 && overlap.length / lesson.tokens.size >= 0.6) + }) +} +type Topics = Map; signals: Set }> +/** Record an unmatched correction under each topic it names: snake_case identifiers and anchors. */ +const addTopics = (topics: Topics, signal: Signal) => { + const names = new Set([...identifiers(signal.text)].filter((word) => word.includes("_"))) + for (const anchor of anchors(signal.text)) names.add(anchor) + for (const topic of names) { + if (!topics.has(topic)) topics.set(topic, { sessions: new Set(), signals: new Set() }) + topics.get(topic)!.sessions.add(signal.sessionID) + topics.get(topic)!.signals.add(signal.id) + } +} +/** Topics corrected in 2 or more sessions, most sessions first. */ +const recurringTopics = (topics: Topics) => [...topics].filter(([, value]) => value.sessions.size >= 2) + .map(([identifier, value]) => ({ identifier, sessions: value.sessions.size, signal_ids: [...value.signals].sort() })) + .sort((a, b) => b.sessions - a.sessions || a.identifier.localeCompare(b.identifier)) + +/** + * Topics corrected in 2 or more sessions that no approved lesson covers, from the corrections alone. Unlike + * `attribute`, a session needs no delivery snapshot: a project with no approved rules yet has none, and its repeated + * corrections must still be able to propose the first one. + */ +export function unmatchedCorrectionTopics(signals: readonly Signal[], lessons: readonly NamedLesson[]) { + const prepared = prepare(lessons) + const topics: Topics = new Map() + for (const signal of signals) + if (signal.kind === "user_correction" && !matchesOf(signal.text, prepared).length) addTopics(topics, signal) + return recurringTopics(topics) +} + +/** Every store's approved lessons (stores whose names are invalid, or unreadable, are skipped). */ +export async function approvedLessons(root: string): Promise { + const dir = path.join(root, ".altimate-code", "learn") + await SafeFS.assertSafePath(root, dir) + const entries = await fs.readdir(dir, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return [] + throw error + }) + const lessons: NamedLesson[] = [] + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (!entry.isDirectory()) continue + try { validateName(entry.name) } catch { continue } + const file = Store.paths(root, entry.name).approved + try { + await SafeFS.assertSafePath(root, file) + for (const lesson of parse(await fs.readFile(file, "utf8"))) lessons.push({ name: entry.name, lesson }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") log.warn("learn recurrence approved store skipped", { name: entry.name, error }) + } + } + return lessons +} + export function attribute(signals: readonly Signal[], snapshots: readonly ShownSnapshot[], lessons: readonly NamedLesson[]) { - const prepared = lessons.map(({ name, lesson }) => ({ - name, id: lesson.id, textHash: lessonTextHash(lesson.text), - anchors: [...anchors(lesson.text)].filter((word) => !word.startsWith("_") && !word.endsWith("_")), - tokens: tokens(lesson.text), - })) + const prepared = prepare(lessons) const sessions = new Map(snapshots.map((snapshot) => [snapshot.session, snapshot.shown])) const rows = new Map; signals: Set }>() + const unmatched: Topics = new Map() for (const signal of signals) { if (signal.kind !== "user_correction" || !sessions.has(signal.sessionID)) continue - const words = identifiers(signal.text) - const content = tokens(signal.text) - const matches = prepared.filter((lesson) => { - const overlap = [...lesson.tokens].filter((word) => content.has(word)) - return lesson.anchors.some((anchor) => words.has(anchor) && overlap.some((word) => word !== anchor)) || - (overlap.length >= 3 && overlap.length / lesson.tokens.size >= 0.6) - }) + const matches = matchesOf(signal.text, prepared) if (!matches.length) { - const topics = new Set([...words].filter((word) => word.includes("_"))) - for (const anchor of anchors(signal.text)) topics.add(anchor) - for (const topic of topics) { - if (!unmatched.has(topic)) unmatched.set(topic, { sessions: new Set(), signals: new Set() }) - unmatched.get(topic)!.sessions.add(signal.sessionID) - unmatched.get(topic)!.signals.add(signal.id) - } + addTopics(unmatched, signal) continue } // Only lessons this session saw in their current text before the correction compete; a lesson never @@ -104,9 +156,7 @@ export function attribute(signals: readonly Signal[], snapshots: readonly ShownS for (const field of Object.keys(counts) as (keyof typeof counts)[]) row[field] = counts[field].size return row }).sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id) || a.textHash.localeCompare(b.textHash)), - unmatched_topics: [...unmatched].filter(([, value]) => value.sessions.size >= 2) - .map(([identifier, value]) => ({ identifier, sessions: value.sessions.size, signal_ids: [...value.signals].sort() })) - .sort((a, b) => b.sessions - a.sessions || a.identifier.localeCompare(b.identifier)).slice(0, 5), + unmatched_topics: recurringTopics(unmatched).slice(0, 5), } } diff --git a/packages/opencode/src/altimate/rules/api.ts b/packages/opencode/src/altimate/rules/api.ts index a811561253..a884b53ccc 100644 --- a/packages/opencode/src/altimate/rules/api.ts +++ b/packages/opencode/src/altimate/rules/api.ts @@ -120,6 +120,8 @@ export class RulesApiError extends Error { message: string, readonly code?: string, readonly status?: number, + /** The server's error detail, for codes whose fields matter (`invalid_status` carries the rule's `status`). */ + readonly detail?: Record, ) { super(message) this.name = "RulesApiError" @@ -154,7 +156,7 @@ export function classify(error: unknown): RulesApiError { return new RulesApiError("transient", messageOf(error.detail, message), code, error.status) } const named = error as { name?: string; detail?: Record } | undefined - if (named?.name === "ConflictError") return new RulesApiError("conflict", messageOf(named.detail, message), codeOf(named.detail), 409) + if (named?.name === "ConflictError") return new RulesApiError("conflict", messageOf(named.detail, message), codeOf(named.detail), 409, named.detail) if (named?.name === "PreconditionFailedError") return new RulesApiError("conflict", messageOf(named.detail, message), codeOf(named.detail), 412) return new RulesApiError("transient", message) } diff --git a/packages/opencode/src/altimate/rules/from-history.ts b/packages/opencode/src/altimate/rules/from-history.ts index 64e3f1b91d..e930e3e888 100644 --- a/packages/opencode/src/altimate/rules/from-history.ts +++ b/packages/opencode/src/altimate/rules/from-history.ts @@ -21,6 +21,8 @@ export async function candidateTexts(root: string, name: string): Promise { if (!RulesSync.rulesEnabled(input.learn)) return undefined const state = await RulesStore.read(input.root) - if (!state.workspace) return undefined + if (!state.workspace || !state.scope) return undefined const [approved, candidate] = await Promise.all([ Store.loadApproved(input.root, input.name), Store.loadCandidateLessons(input.root, input.name), @@ -76,6 +78,7 @@ export async function stagedRules(input: { if (!items.length && !invalid.length) return undefined return { workspace: { name: state.workspace.name, owner: state.workspace.owner_name ?? "the workspace owner" }, + scope: state.scope, items, invalid, localChanges: edited + removed, @@ -91,15 +94,29 @@ export async function sendStaged(input: { staged: Staged /** `candidateTexts` before the run: a candidate that existed before is never discarded. */ before: Set | undefined -}): Promise<{ sent: number; queued: number; refused: number; lost: number; message: string }> { - const ids = await RulesSync.enqueueMany(input.root, input.staged.items.map((item) => ({ item }))) - // A push sends at most a batch: keep sending until these proposals have left the outbox, or a run sends nothing - // more (not ready, or kept back with an error). Bounded by the number of batches they need, plus one. +}): Promise<{ sent: number; queued: number; refused: number; lost: number; held: number; message: string }> { + const { owner, name } = input.staged.workspace + // Only what the outbox has room for is queued; the rest stays in the local candidate (not sent for review). + const room = Math.max(0, RulesStore.MAX_OUTBOX - (await RulesStore.read(input.root)).outbox.length) + const items = input.staged.items.slice(0, room) + const held = input.staged.items.length - items.length + const heldNote = held ? `; ${held} not sent: the queue of proposals waiting to be sent is full (${RulesStore.MAX_OUTBOX}), so they stay in the local candidate` : "" + let ids: string[] + try { + ids = await RulesSync.enqueueMany(input.root, items.map((item) => ({ item })), input.staged.scope) + } catch (error) { + if (!(error instanceof RulesSync.ScopeChangedError)) throw error + const message = `Nothing sent: the account or workspace changed since these rules were listed. The local candidate was kept: \`learn show\`.` + return { sent: 0, queued: 0, refused: 0, lost: 0, held: input.staged.items.length, message } + } + // A push sends the oldest batch of the outbox, which can hold older proposals ahead of these: keep sending while these + // are still queued and each run moves the outbox along. Stops when a run is not ready, keeps something back with an + // error, or sends nothing; bounded by the batches a full outbox needs. let report = await RulesSync.runFresh(input.root, input.directory, input.learn, "import") let state = await RulesStore.read(input.root) - for (let round = 1; round <= Math.ceil(ids.length / RulesSync.MAX_PROPOSALS); round++) { + for (let round = 1; round <= Math.ceil(RulesStore.MAX_OUTBOX / RulesSync.MAX_PROPOSALS); round++) { const waiting = ids.some((id) => state.outbox.some((entry) => entry.id === id)) - if (!waiting || report.context.status !== "ready" || report.push?.error) break + if (!waiting || report.context.status !== "ready" || report.push?.error || !report.push?.sent.length) break report = await RulesSync.runFresh(input.root, input.directory, input.learn, "import") state = await RulesStore.read(input.root) } @@ -111,24 +128,24 @@ export async function sendStaged(input: { const lost = outcomes.length - queued - refused - sent // The workspace's review queue replaces a candidate this run created, once it holds every rule of it: only when the // candidate is nothing but the adds that were sent (no local edits or removals, nothing invalid, refused or queued). - const discard = !input.before && queued === 0 && refused === 0 && lost === 0 && input.staged.invalid.length === 0 && + const discard = !input.before && queued === 0 && refused === 0 && lost === 0 && held === 0 && input.staged.invalid.length === 0 && input.staged.localChanges === 0 if (discard) await Store.reject(input.root, input.name) - const { owner, name } = input.staged.workspace - // Why some stayed queued, as the run said it: sync not ready (not signed in, link lost …), or kept back with an error. + // Why some stayed queued, as the run said it: sync not ready (not signed in, link lost …), kept back with an error, or + // not reached yet behind older proposals. const why = report.context.status !== "ready" ? report.context.reason - : report.push?.error ?? "the workspace could not be reached" + : report.push?.error ?? "behind older queued proposals" const message = `${sent} ${sent === 1 ? "rule" : "rules"} sent for ${owner}'s review in ${name}` + (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"})` : "") + "." + + (lost ? `; ${lost} not sent (${RulesStore.scopeDropNote(state) ?? "the account or workspace changed"})` : "") + heldNote + "." + (discard ? "" : input.staged.localChanges ? ` The local candidate was kept: it also changes ${input.staged.localChanges} local ${input.staged.localChanges === 1 ? "rule" : "rules"} (\`learn show\`, \`learn promote\`).` : " The local candidate was kept: `learn show`.") - return { sent, queued, refused, lost, message } + return { sent, queued, refused, lost, held, message } } export * as RulesFromHistory from "./from-history" diff --git a/packages/opencode/src/altimate/rules/proposal.ts b/packages/opencode/src/altimate/rules/proposal.ts index 197d3a78f1..acff41ca4b 100644 --- a/packages/opencode/src/altimate/rules/proposal.ts +++ b/packages/opencode/src/altimate/rules/proposal.ts @@ -56,51 +56,89 @@ interface Pending { /** For a card the person asked for: saves it as it would have been saved without a card, when the card closes * unanswered (it expired, or its instance went away). Bound to the asking instance. */ unanswered?: (text: string) => Promise - /** Closed (expired, or its instance went away) while a reply was running: the reply settles it when it ends. */ - closed?: { message: string; why: "expired" | "closed"; here: string | undefined } + /** The account and workspace the card was shown for: a send or approve is queued only for them. */ + scope?: RulesStore.Scope + /** The request is kept on disk (`RulesStore.RememberRequest`) until the card is answered or its fallback ran. */ + persisted?: boolean + /** Closed (expired, or its instance went away) while a reply was running: the reply settles it when it ends, then + * calls `done`, which resolves the promise `close` returned (`drop` waits on it). */ + closed?: { message: string; why: "expired" | "closed"; here: string | undefined; done: () => void } } /** An unanswered card lasts a day; after that it is dropped rather than re-offered to every new client. */ export const CARD_TTL_MS = 24 * 60 * 60_000 /** Remove a card no client can answer any more, and tell clients so they stop showing it. Resolves once a requested - * card's fallback save has landed. A card whose reply is running is only marked: the reply's outcome decides. */ + * card is saved: by its fallback, or, for a card whose reply is running, once that reply ended (it saved the card, or + * failed and the fallback saved it). */ function close(id: string, entry: Pending, message: string, why: "expired" | "closed", here: string | undefined): Promise { pending.delete(id) - if (entry.busy) { - entry.closed = { message, why, here } - return Promise.resolve() - } + if (entry.busy) return new Promise((done) => void (entry.closed = { message, why, here, done })) return settle(id, entry, message, why, here) } +/** What a "remember" card keeps on disk so the request survives the process (`RulesRemember.recoverRequests`). */ +export type RequestRecord = Pick + +async function persistRequest(entry: Pending, request: RequestRecord) { + const record: RulesStore.RememberRequest = { + ...request, + card: entry.card.id, + text: entry.card.text, + directory: entry.directory, + pid: process.pid, + at: new Date().toISOString(), + } + // Best effort: the card and its in-memory fallback work without it; only a crash would then lose the request. + await RulesStore.update(entry.root, (s) => { + s.remember_requests = [...s.remember_requests.filter((r) => r.card !== record.card), record] + }).then( + () => void (entry.persisted = true), + (error) => log.warn("remember request not kept on disk", { card: record.card, error }), + ) +} + +/** The card was answered, or its fallback ran: its request no longer needs keeping. */ +async function forgetRequest(id: string, entry: Pending) { + if (!entry.persisted) return + entry.persisted = false + await RulesStore.update(entry.root, (s) => { + s.remember_requests = s.remember_requests.filter((r) => r.card !== id) + }).catch((error) => log.warn("remember request not cleared", { id, error })) +} + /** A closed card nobody answered: save a requested one as before cards, record it, tell its clients. */ -function settle(id: string, entry: Pending, message: string, why: "expired" | "closed", here: string | undefined): Promise { - // A "remember" the person asked for is never silently lost: unanswered, it is saved as it was before cards. +async function settle(id: string, entry: Pending, message: string, why: "expired" | "closed", here: string | undefined): Promise { + // The Bus is per project: announced from another project's request it would reach the wrong clients. The card's own + // clients then learn it is gone from their next list or reply (`not_open`). + if (here === entry.directory) + void publish(RuleEvents.Event.Replied, { id, sessionID: entry.card.sessionID, action: "expired", message }) + // A "remember" the person asked for is saved as it was before cards. That save can still fail (the project's memory + // or training is full): recorded as a failed close, not as saved. const saved = entry.unanswered - ? entry.unanswered(entry.card.text).catch((error) => log.warn("unanswered card not saved", { id, error })) - : Promise.resolve() + ? await entry.unanswered(entry.card.text).then(() => true, (error) => { + log.warn("unanswered card not saved", { id, error }) + return false + }) + : undefined + // Saved, or failed for good (the project's memory or training is full): either way nothing is left to recover. + await forgetRequest(id, entry) RulesTelemetry.answered({ card: entry.card, trigger: entry.trigger, action: why, - status: "ok", - ...(entry.unanswered ? { outcome: "saved_unanswered" as const } : {}), + status: saved === false ? "failed" : "ok", + ...(saved ? { outcome: "saved_unanswered" as const } : {}), edits: entry.edits, shownAt: entry.at, }) - // The Bus is per project: announced from another project's request it would reach the wrong clients. The card's own - // clients then learn it is gone from their next list or reply (`not_open`). - if (here === entry.directory) - void publish(RuleEvents.Event.Replied, { id, sessionID: entry.card.sessionID, action: "expired", message }) - return saved } /** After a reply that did not close the card: if the card was closed meanwhile, settle it now. */ async function settleIfClosed(id: string, entry: Pending): Promise { if (!entry.closed) return undefined - const { message, why, here } = entry.closed - await settle(id, entry, message, why, here) + const { message, why, here, done } = entry.closed + await settle(id, entry, message, why, here).finally(done) return { ok: false, code: "not_open", error: "This proposal is no longer open." } } @@ -207,6 +245,8 @@ export async function ask(input: { draft: Draft trigger?: CardTrigger unanswered?: Pending["unanswered"] + /** With `unanswered`: what to keep on disk, so the request is saved even if this process dies. */ + request?: RequestRecord }) { expire(input.directory) const trigger = input.trigger ?? (input.draft.origin === "explicit" ? "remember" : "recurrence") @@ -233,11 +273,17 @@ export async function ask(input: { existing.learn = input.learn await publish(RuleEvents.Event.Updated, existing.card) } else await publish(RuleEvents.Event.Asked, existing.card) + if (input.unanswered && input.request) await persistRequest(existing, input.request) RulesTelemetry.shown(existing.card, existing.trigger, true) return existing.card } const card = build(randomUUID(), input.sessionID, input.draft, state) - pending.set(card.id, { card, root: input.root, directory: input.directory, learn: input.learn, at: Date.now(), trigger, edits: 0, sourceSession: input.sessionID, memory: input.draft.memory, unanswered: input.unanswered }) + const entry: Pending = { + card, root: input.root, directory: input.directory, learn: input.learn, at: Date.now(), trigger, edits: 0, + sourceSession: input.sessionID, memory: input.draft.memory, unanswered: input.unanswered, scope: state.scope, + } + pending.set(card.id, entry) + if (input.unanswered && input.request) await persistRequest(entry, input.request) RulesTelemetry.shown(card, trigger, false) await publish(RuleEvents.Event.Asked, card) return card @@ -326,6 +372,11 @@ export async function reply(id: string, input: { action: ReplyAction; text?: str try { entry.card = next entry.edits += 1 + // A kept request saves the wording as it stands (best effort, like keeping it). + if (entry.persisted) + await RulesStore.update(entry.root, (s) => { + for (const r of s.remember_requests) if (r.card === id) r.text = text + }).catch((error) => log.warn("remember request wording not updated", { id, error })) await publish(RuleEvents.Event.Updated, next) } finally { entry.busy = false @@ -343,6 +394,17 @@ export async function reply(id: string, input: { action: ReplyAction; text?: str message = done.message track("ok", done.outcome) } catch (error) { + if (error instanceof RulesSync.ScopeChangedError) { + // Shown for another account or workspace: it is not sent to this one. The card closes; one the person asked for + // is saved as before cards. + entry.busy = false + track("not_open") + if (entry.closed) return (await settleIfClosed(id, entry))! + pending.delete(id) + const message = "The account or workspace changed since this proposal was shown: it was not sent." + await settle(id, entry, message, "closed", entry.directory) + return { ok: false, code: "not_open", error: message } + } const code = error instanceof RefusedError ? "refused" : "failed" if (code === "failed") log.warn("rule proposal reply failed", { action: input.action, error }) track(code) @@ -353,6 +415,9 @@ export async function reply(id: string, input: { action: ReplyAction; text?: str entry.busy = false } pending.delete(id) + await forgetRequest(id, entry) + // Closed while this ran: the answer saved it, which is what the close was waiting for. + entry.closed?.done() await publish(RuleEvents.Event.Replied, { id, sessionID: card.sessionID, action: input.action, message }) await publish(RuleEvents.Event.Changed, { directory: entry.directory }) return { ok: true, message, closed: true } @@ -377,12 +442,12 @@ async function act(entry: Pending, action: Exclude): Promis const id = entry.memory?.id ?? `notes/${slug(card.text)}` const tags = entry.memory?.tags.length ? entry.memory.tags : ["remembered"] const now = new Date().toISOString() - await MemoryStore.write({ id, scope, tags, created: now, updated: now, content: card.text }) + await MemoryStore.write({ id, scope, tags, created: now, updated: now, content: card.text }, { sessionID: entry.sourceSession }) message = scope === "global" ? "Saved to your memory (all your projects)." : "Saved to this project's memory." outcome = scope === "global" ? "saved_memory_global" : "saved_memory_project" } else if (action === "save_local") { const { TrainingStore } = await import("../training") - await TrainingStore.save({ kind: "rule", name: slug(card.text), scope: "project", content: card.text, source: "proposed rule" }) + await TrainingStore.save({ kind: "rule", name: slug(card.text), scope: "project", content: card.text, source: "proposed rule", sessionID: entry.sourceSession }) message = "Saved as a rule for this project." outcome = "saved_local" } else { @@ -402,7 +467,7 @@ async function act(entry: Pending, action: Exclude): Promis corrections: card.evidence.correction && quotes ? [{ excerpt: card.evidence.correction.excerpt, corrected_at: card.evidence.correction.at, session_ref: hashRef(entry.sourceSession) }] : [], - }, approve) + }, approve, entry.scope) const report = await RulesSync.runFresh(entry.root, entry.directory, entry.learn, "card").catch(() => undefined) const sent = RulesSync.outcomeOf(await RulesStore.read(entry.root), report, outboxId) const owner = card.workspace?.owner_name ?? "the workspace owner" diff --git a/packages/opencode/src/altimate/rules/remember.ts b/packages/opencode/src/altimate/rules/remember.ts index fee3ad3766..add449096f 100644 --- a/packages/opencode/src/altimate/rules/remember.ts +++ b/packages/opencode/src/altimate/rules/remember.ts @@ -5,6 +5,9 @@ // when the person asked to remember) both come here first. import fs from "node:fs/promises" +import { Log } from "@/util/log" + +const log = Log.create({ service: "rules.remember" }) /** The person asked for something to be remembered (not the agent deciding to take a note). */ const ASKED_TO_REMEMBER = /\b(?:remember|don'?t forget|do not forget|keep in mind|note (?:that|this)|save (?:this|that)|from now on)\b/i @@ -60,33 +63,90 @@ export async function proposeTeamRule( const root = await fs.realpath(raw).catch(() => raw) // The project's learn file as it is now, like every other entry point: `learn disable` mid-session takes effect. const learn = await RulesService.effectiveLearn((await Config.get()).learn, root, Instance.directory) - // A subagent's session is not the one a client shows: its card would never be seen. - const subagent = await import("@/session") - .then(async ({ Session }) => { - const { SessionID } = await import("@/session/schema") - return !!(await Session.get(SessionID.make(sessionID))).parentID - }) - .catch(() => false) + // A subagent's session is not the one a client shows: its card would never be seen. Looked up only when a card + // would otherwise be raised. + const subagent = () => + import("@/session") + .then(async ({ Session }) => { + const { SessionID } = await import("@/session/schema") + return !!(await Session.get(SessionID.make(sessionID))).parentID + }) + .catch(() => false) + // What the tool meant to write, kept on disk while the card is open; saved as before cards if it closes unanswered. + const request = { session: sessionID, via, ...(memory ? { memory } : {}), ...(training ? { training } : {}) } // Bound to this instance, so it saves into this project whenever the card closes unanswered. - const unanswered = Instance.bind(async (text: string) => { - const now = new Date().toISOString() - if (via === "memory" && memory) { - const { MemoryStore } = await import("../../memory/store") - await MemoryStore.write({ id: memory.id, scope: "project", tags: memory.tags, created: now, updated: now, content: text }, { sessionID }) - return - } - const [{ TrainingStore }, { RuleProposal }] = await Promise.all([import("../training"), import("./proposal")]) - await TrainingStore.save({ - kind: "rule", - name: training?.name ?? RuleProposal.slug(text), - scope: "project", - content: text, - source: training?.source ?? "remember (card closed unanswered)", - ...(training?.citations ? { citations: training.citations } : {}), - sessionID, - }) + const unanswered = Instance.bind((text: string) => saveAsBefore({ ...request, text })) + return proposeFor({ root, directory: Instance.directory, learn, content, sessionID, said, via, memory, subagent, unanswered, request }) +} + +type Request = Pick + +/** Save a "remember" as the tool would have without a card: the memory tool's block to project memory, or + * `training_save`'s rule to project training (within its per-kind limit). Runs in the asking project's instance. */ +export async function saveAsBefore(request: Request) { + const { text, session: sessionID, memory, training } = request + const now = new Date().toISOString() + if (request.via === "memory" && memory) { + const { MemoryStore } = await import("../../memory/store") + await MemoryStore.write({ id: memory.id, scope: "project", tags: memory.tags, created: now, updated: now, content: text }, { sessionID }) + return + } + const [{ TrainingStore }, { RuleProposal }, { TRAINING_MAX_PATTERNS_PER_KIND }] = await Promise.all([ + import("../training"), + import("./proposal"), + import("../training/types"), + ]) + const name = training?.name ?? RuleProposal.slug(text) + // The same limit `training_save` applies: a new rule past it is not saved (an update of an existing one is). + if (!(await TrainingStore.get("project", "rule", name))) { + const count = await TrainingStore.count({ kind: "rule", scope: "project" }) + if (count.rule >= TRAINING_MAX_PATTERNS_PER_KIND) + throw new Error(`project training already holds ${count.rule} rules (limit ${TRAINING_MAX_PATTERNS_PER_KIND})`) + } + await TrainingStore.save({ + kind: "rule", + name, + scope: "project", + content: text, + source: training?.source ?? "remember (card closed unanswered)", + ...(training?.citations ? { citations: training.citations as import("../../memory/types").MemoryBlock["citations"] } : {}), + sessionID, }) - return proposeFor({ root, directory: Instance.directory, learn, content, sessionID, said, via, memory, subagent, unanswered }) +} + +/** Whether process `pid` is running (a process this one may not signal still counts). */ +function alive(pid: number) { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM" + } +} + +/** + * Save the "remember" requests of `directory` whose card died with its process (a crash, a kill): the card can no longer + * be answered or close on its own, so the request is saved as before cards. Each is claimed in one locked write first, + * so two processes starting together save it once. Runs in that project's instance; never throws. + */ +export async function recoverRequests(root: string, directory: string) { + const { RulesStore } = await import("./store") + const orphaned = (r: import("./store").RememberRequest) => r.directory === directory && r.pid !== process.pid && !alive(r.pid) + if (!(await RulesStore.read(root)).remember_requests.some(orphaned)) return + const claimed: import("./store").RememberRequest[] = [] + await RulesStore.update(root, (s) => { + for (const r of s.remember_requests) + if (orphaned(r)) { + r.pid = process.pid + claimed.push({ ...r }) + } + }) + for (const r of claimed) { + await saveAsBefore(r).catch((error) => log.warn("recovered remember request not saved", { card: r.card, error })) + await RulesStore.update(root, (s) => { + s.remember_requests = s.remember_requests.filter((x) => x.card !== r.card) + }) + } } /** `proposeTeamRule` for an explicit project (`root`, `directory`) and learn config. */ @@ -100,10 +160,12 @@ export async function proposeFor(input: { via?: "training" | "memory" /** From the memory tool: the block it meant to write, used if the person picks Memory on the card. */ memory?: { id: string; tags: string[] } - /** The session is a subagent's: no client shows its cards. */ - subagent?: boolean + /** Whether the session is a subagent's (no client shows its cards); asked only when a card would be raised. */ + subagent?: boolean | (() => Promise) /** Saves the request as before cards if its card closes unanswered. */ unanswered?: (text: string) => Promise + /** With `unanswered`: what to keep on disk while the card is open (`RuleProposal.ask`). */ + request?: import("./proposal").RequestRecord }) { const { root, learn, content, sessionID, said } = input const via = input.via ?? "training" @@ -123,8 +185,9 @@ export async function proposeFor(input: { // Nothing attached to this process shows cards (`altimate-code run`, ACP, a plugin): a card would be lost, so the // tool saves as it did before cards (memory, or project training). const { RuleProposal } = await import("./proposal") - if (input.subagent || !RuleProposal.rendererAttached(input.directory)) return void RulesTelemetry.routed(sessionID, via, "no_renderer") - const result = await RuleTrigger.explicit({ sessionID, root, directory: input.directory, learn, text: content, said, memory: input.memory, unanswered: input.unanswered }) + if (!RuleProposal.rendererAttached(input.directory)) return void RulesTelemetry.routed(sessionID, via, "no_renderer") + if (typeof input.subagent === "function" ? await input.subagent() : input.subagent) return void RulesTelemetry.routed(sessionID, via, "no_renderer") + const result = await RuleTrigger.explicit({ sessionID, root, directory: input.directory, learn, text: content, said, memory: input.memory, unanswered: input.unanswered, request: input.request }) RulesTelemetry.routed( sessionID, via, diff --git a/packages/opencode/src/altimate/rules/service.ts b/packages/opencode/src/altimate/rules/service.ts index 1037b59791..c537156144 100644 --- a/packages/opencode/src/altimate/rules/service.ts +++ b/packages/opencode/src/altimate/rules/service.ts @@ -43,6 +43,8 @@ export async function effectiveLearn(loaded: LearnConfig, root: string, director const above = Config.learnKeysAboveProject(directory) const result: Record = { ...loaded } for (const [key, value] of Object.entries(onDisk ?? {})) if (!above.has(key)) result[key] = value + // Only this function marks a policy: a key named so in any file (a committed project file above all) is not one. + delete result.capture_policy if (above.has("capture")) result.capture_policy = true return result as LearnConfig } @@ -62,7 +64,7 @@ async function linked(root: string, directory: string): Promise { function deps(ctx: InstanceContext, model?: string): RuleTrigger.TriggerDeps { return { source: async (sessionID) => (await import("../learn/session-reflect")).sourceFromSession(sessionID, ctx), - generate: async (source) => { + generate: async (source, onUsage) => { const [{ Provider }, { AppRuntime }, { InstanceRef }, { DEFAULT_TIMEOUT_MS, providerGenerate }, { learnModel }] = await Promise.all([ import("@/provider/provider"), import("@/effect/app-runtime"), @@ -72,7 +74,7 @@ function deps(ctx: InstanceContext, model?: string): RuleTrigger.TriggerDeps { ]) const chosen = learnModel(model) return AppRuntime.runPromise( - providerGenerate(chosen ? Provider.parseModel(chosen) : source.model, DEFAULT_TIMEOUT_MS).pipe(Effect.provideService(InstanceRef, ctx)), + providerGenerate(chosen ? Provider.parseModel(chosen) : source.model, DEFAULT_TIMEOUT_MS, undefined, onUsage).pipe(Effect.provideService(InstanceRef, ctx)), ) }, } @@ -97,6 +99,12 @@ export async function sessionStart(root: string, directory: string, loaded: Lear await RulesSync.sessionStart(root, directory, learn, { startIfDue: hasState }) } +/** Whether `sessionID` is a subagent's (it has a parent). Unknown counts as not. */ +async function subagent(sessionID: string): Promise { + const [{ Session }, { SessionID }] = await Promise.all([import("@/session"), import("@/session/schema")]) + return Session.get(SessionID.make(sessionID)).then((session) => !!session.parentID, () => false) +} + export async function start(ctx: InstanceContext): Promise { try { const root = await fs.realpath(ctx.worktree !== "/" ? ctx.worktree : ctx.directory) @@ -154,12 +162,20 @@ export async function start(ctx: InstanceContext): Promise { void work().catch((error) => log.warn(`rules ${what} failed`, { error: error instanceof Error ? error.message : String(error) })) await followLearning(await learnConfig(ctx)).catch((error) => log.warn("rules learning setup failed", { error: String(error) })) safely("refresh", refresh)() + // "Remember" requests whose card died with another process (a crash): saved as before cards, into this project. + safely("remember recovery", Instance.bind(async () => { + const { RulesRemember } = await import("./remember") + await RulesRemember.recoverRequests(root, ctx.directory) + }))() const timer = setInterval(safely("refresh", refresh), RulesSync.REFRESH_MS) timer.unref?.() const onIdle = Instance.bind(async (sessionID: string) => { if (disposed) return const learn = await learnConfig(ctx) if (!RulesSync.rulesEnabled(learn)) return + // A subagent's session is not one a client shows: a card raised there would never be seen, and its corrections + // would be marked handled. They are left unprocessed. + if (await subagent(sessionID)) return const { flushCapture } = await import("../learn/capture") await flushCapture() await RuleTrigger.afterTurn({ sessionID, root, directory: ctx.directory, learn, deps: deps(ctx, learn?.model) }) diff --git a/packages/opencode/src/altimate/rules/store.ts b/packages/opencode/src/altimate/rules/store.ts index 308eca0824..89c76b2209 100644 --- a/packages/opencode/src/altimate/rules/store.ts +++ b/packages/opencode/src/altimate/rules/store.ts @@ -30,9 +30,6 @@ const MAX_NOTICES = 50 const MAX_SENT = 200 /** Safety cap for the signal id list; `processed_signals` is also pruned to live signals. */ const MAX_TRACKED = 5000 -/** Safety cap for `usage_reported`, which is pruned to sessions that still have a snapshot (`reportUsage`). Above what - * the snapshots it is checked against can hold (200 sessions × 40 rules), so a live pair is never trimmed. */ -const MAX_USAGE_PAIRS = 50_000 /** How long the "queued proposals were dropped" notice of an account or workspace change stays shown. */ const SCOPE_DROP_NOTICE_MS = 7 * 24 * 60 * 60_000 /** "Not a rule" keeps a topic from being proposed again in this repository for this long. */ @@ -94,6 +91,28 @@ export const Notice = z.object({ }) export type Notice = z.infer +/** A "remember …" the person asked for, shown as a card and not yet answered: kept on disk so it is saved as before + * cards even if the process that showed the card dies (`RulesRemember.recoverRequests`). Removed once the card is + * answered, or its fallback ran. */ +export const RememberRequest = z.object({ + card: z.string(), + text: z.string(), + session: z.string(), + /** The instance directory that asked: the request is saved into that project. */ + directory: z.string(), + via: z.enum(["training", "memory"]), + /** The memory tool's block (`via: "memory"`). */ + memory: z.object({ id: z.string(), tags: z.array(z.string()) }).optional(), + /** `training_save`'s entry (`via: "training"`). */ + training: z.object({ name: z.string(), source: z.string().optional(), citations: z.array(z.unknown()).optional() }).optional(), + /** The process that shows the card: while it lives, its own fallback saves the request. */ + pid: z.number().int(), + at: z.string(), +}) +export type RememberRequest = z.infer +/** Requests kept at most; the oldest beyond it are dropped (each is also bounded by its card's day). */ +const MAX_REMEMBER_REQUESTS = 50 + export const State = z.object({ version: z.literal(1), scope: Scope.optional(), @@ -113,7 +132,8 @@ export const State = z.object({ notices: z.array(Notice).default([]), dismissed: z.array(z.object({ topic: z.string(), until: z.string() })).default([]), /** (session, rule version) pairs already reported as `applied`, as `session|public_id`: a session keeps showing - * rules on later turns (requests, files), and each rule it showed counts once. */ + * rules on later turns (requests, files), and each rule it showed counts once. Kept while the session has a snapshot + * file (`reportUsage` prunes the rest), never trimmed by count: a trimmed live pair would be counted again. */ usage_reported: z.array(z.string()).default([]), /** Per rule, the "corrected again" count already reported. */ corrected_reported: z.record(z.string(), z.number().int()).default({}), @@ -142,6 +162,7 @@ export const State = z.object({ action: z.enum(["approve", "approve_anyway"]), attempts: z.number().int().default(0), })).default([]), + remember_requests: z.array(RememberRequest).default([]), /** A usage report sent but not yet acknowledged: resent with the same `batch_id` so the server counts it once. */ pending_usage: z.object({ batch_id: z.string(), @@ -228,28 +249,26 @@ export async function update(root: string, change: (state: State) => State | voi await SafeFS.mkdir(root, dir(root)) const raw = await readRaw(root) let current = parse(raw) - let recovered = false if (!current) { // Written by another version or damaged: keep it for inspection rather than overwrite it. await Store.writeAtomic(root, `${stateFile(root)}.unreadable-${Date.now()}`, raw!, 0o600) current = structuredClone(EMPTY) - recovered = true } const rulesBefore = JSON.stringify(current.rules) const next = change(current) ?? current next.notices = next.notices.slice(-MAX_NOTICES) next.sent = capSent(next.sent) - next.usage_reported = next.usage_reported.slice(-MAX_USAGE_PAIRS) next.processed_signals = next.processed_signals.slice(-MAX_TRACKED) + next.remember_requests = next.remember_requests.slice(-MAX_REMEMBER_REQUESTS) const now = Date.now() next.dismissed = next.dismissed.filter((d) => Date.parse(d.until) > now) - if (next.scope_drop && !(now - Date.parse(next.scope_drop.at) < SCOPE_DROP_NOTICE_MS)) delete next.scope_drop + if (next.scope_drop && !scopeDropShown(next.scope_drop, now)) delete next.scope_drop await Store.writeAtomic(root, stateFile(root), JSON.stringify(next, null, 2) + "\n") // Only a linked project (or one whose rules just changed, e.g. a reset) has a mirror to write: in a project with no // workspace, only the state file is written (a dismissal, a learning choice), never an `approved.json` that delivery - // would read. After an unreadable state was set aside, an existing - // mirror no longer matches any state: rewrite it, so rules nothing vouches for stop being delivered. - const staleMirror = recovered && (await fs.access(Store.paths(root, WORKSPACE_RULES_STORE).approved).then(() => true, () => false)) + // would read. A mirror left behind by a write that failed after the state was saved (or by an unreadable state set + // aside) no longer matches the state: it is rewritten, so rules nothing vouches for stop being delivered. + const staleMirror = !next.workspace && JSON.stringify(next.rules) === rulesBefore && (await mirrorDiffers(root, next.rules)) if (next.workspace || JSON.stringify(next.rules) !== rulesBefore || staleMirror) { await ensureIgnore(root) await writeMirror(root, next.rules) @@ -258,6 +277,13 @@ export async function update(root: string, change: (state: State) => State | voi }, LOCK) } +/** An existing mirror (not someone's own playbook) whose rules are not `rules`. */ +async function mirrorDiffers(root: string, rules: readonly RuleOut[]) { + const previous = await fs.readFile(Store.paths(root, WORKSPACE_RULES_STORE).approved, "utf8").catch(() => undefined) + if (previous === undefined || !isMirror(previous)) return false + return canonical(parseLessons(previous)) !== canonical(rules.map(toLesson)) +} + async function writeMirror(root: string, rules: readonly RuleOut[]) { const file = Store.paths(root, WORKSPACE_RULES_STORE).approved const text = canonical(rules.map(toLesson)) @@ -318,13 +344,18 @@ export function cleared(state: State, options: { keepOutbox?: boolean } = {}): S : {}), // A drop notice outlives a later reset: it is about proposals the person was told were queued. ...(state.scope_drop ? { scope_drop: state.scope_drop } : {}), + // A "remember" is saved locally whatever the workspace: no reset loses it. + remember_requests: state.remember_requests, } } -/** The notice for proposals dropped by an account or workspace change, while it is shown. */ -export function scopeDropNote(state: State): string | undefined { +const scopeDropShown = (drop: NonNullable, now: number) => now - Date.parse(drop.at) < SCOPE_DROP_NOTICE_MS + +/** The notice for proposals dropped by an account or workspace change, while it is shown. Checked on read: a project + * nothing writes to any more (unlinked) still stops showing it. */ +export function scopeDropNote(state: State, now = Date.now()): string | undefined { const drop = state.scope_drop - if (!drop) return undefined + if (!drop || !scopeDropShown(drop, now)) return undefined return `${drop.count} queued ${drop.count === 1 ? "proposal was" : "proposals were"} for another account or workspace and ${drop.count === 1 ? "was" : "were"} not sent` } diff --git a/packages/opencode/src/altimate/rules/summary.ts b/packages/opencode/src/altimate/rules/summary.ts index 77f8385655..08f0f5b581 100644 --- a/packages/opencode/src/altimate/rules/summary.ts +++ b/packages/opencode/src/altimate/rules/summary.ts @@ -17,7 +17,8 @@ export interface Summary { mine_pending: number queued: number learning: boolean - /** Who decided `learning`: the person's own setting, or the workspace's "Learn from sessions". */ + /** Who decided `learning`: an organization policy (`learn.capture` above project files), the person's own setting, + * the workspace's "Learn from sessions", or the default. */ learning_source: RuleTrigger.LearningSource /** The person's `/rules` choice, when they made one (absent: following config or the workspace). */ personal_learning?: boolean diff --git a/packages/opencode/src/altimate/rules/sync.ts b/packages/opencode/src/altimate/rules/sync.ts index f3ebdaf7ec..609e948964 100644 --- a/packages/opencode/src/altimate/rules/sync.ts +++ b/packages/opencode/src/altimate/rules/sync.ts @@ -17,7 +17,7 @@ import { randomUUID } from "node:crypto" import fs from "node:fs/promises" import { Log } from "@/util/log" import { Flock } from "@opencode-ai/core/util/flock" -import { learnEnabled } from "../learn/config" +import { learnEnabled, workspacesOn } from "../learn/config" import * as SafeFS from "../learn/safe-fs" import type { ActAs } from "../workspace/api-client" import { Bus } from "@/bus" @@ -49,8 +49,7 @@ const principals = new Map() /** Rules need learning on and workspaces on. */ export function rulesEnabled(learn: LearnConfig, env: NodeJS.ProcessEnv = process.env): boolean { - const disabled = env["ALTIMATE_DISABLE_WORKSPACE"]?.trim().toLowerCase() - return learnEnabled(learn, env) && (disabled === undefined || disabled === "" || disabled === "0" || disabled === "false") + return learnEnabled(learn, env) && workspacesOn(env) } /** Resolve the gate and scope. Binding resolution may ask the server; never call this under the learn lock. */ @@ -122,6 +121,9 @@ export async function adopt(root: string, ctx: Ready): Promise<{ state: State; s return { state: after, same: !changed } } +/** The state belongs to this run's account and workspace, or to none yet. */ +const ownedBy = (state: State, ctx: Ready) => !state.scope || RulesStore.sameScope(state.scope, ctx.scope) + export async function pull(root: string, ctx: Ready): Promise { const { state: before, same } = await adopt(root, ctx) try { @@ -131,7 +133,11 @@ export async function pull(root: string, ctx: Ready): Promise { ...(same && before.cursor ? { reviewed_since: before.cursor } : {}), }) const fresh: State["notices"] = [] + let applied = true const state = await RulesStore.update(root, (state) => { + // Applied only to the state it was asked for: another process may have adopted another account or workspace, or + // 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.settings = response.settings @@ -161,13 +167,15 @@ export async function pull(root: string, ctx: Ready): Promise { if (sent) Object.assign(sent, { status: reviewed.status, reason: notice.reason, reviewer: notice.reviewer, updated_at: notice.at }) } }) + if (!applied) log.info("rules pull answer superseded; not applied") return { ok: true, rules: state.rules.length, pending: state.pending_count, notices: fresh } } catch (error) { + // Like an answer, a failure only changes the state it was for. if (error instanceof RulesApiError && (error.kind === "workspace_not_found" || error.kind === "repo_not_bound" || error.kind === "forbidden")) { // The link is gone or access was taken away: stop delivering its rules. Queued proposals stay (same scope). - await RulesStore.reset(root, { keepOutbox: true }) + await RulesStore.update(root, (state) => (ownedBy(state, ctx) ? RulesStore.cleared(state, { keepOutbox: true }) : undefined)) } else { - await RulesStore.update(root, (state) => { state.last_error = errorText(error) }) + await RulesStore.update(root, (state) => { if (ownedBy(state, ctx)) state.last_error = errorText(error) }) } log.warn("rules pull failed", { error: errorText(error) }) return { ok: false, error: errorText(error), rules: before.rules.length, pending: before.pending_count, notices: [] } @@ -247,6 +255,11 @@ const retryableApproval = (error: unknown) => { return kind === "transient" || kind === "auth" } const MAX_APPROVAL_ATTEMPTS = 5 +/** The review was refused because the rule is already active (`invalid_status`, status `active`). */ +const alreadyActive = (error: unknown) => { + const failure = error instanceof RulesApiError ? error : RulesApi.classify(error) + return failure.code === "invalid_status" && failure.detail?.status === "active" +} const MAX_PUSH_OUTCOMES = 200 /** Per-item answers that are about now, not about the proposal: it stays queued and is sent again later. */ @@ -256,19 +269,23 @@ const RETRY_LATER = new Set(["too_many_pending"]) * the request itself is refused. Until the outbox changes it is sent as one batch, not split again on every refresh. */ const refusedWhole = new Map() -/** `RulesStore.update`, tried again when the learn lock is busy: what it records was already sent. */ +/** `RulesStore.update`, tried again while the learn lock is busy: what it records was already sent. Only a lock that was + * never acquired is retried: then `change` did not run. Any other failure may come after the state was written (the + * mirror), and running `change` again would apply it twice. */ async function record(root: string, change: (state: State) => State | void): Promise { for (let attempt = 1; ; attempt++) { try { return await RulesStore.update(root, change) } catch (error) { - if (attempt >= RECORD_ATTEMPTS) throw error + if (attempt >= RECORD_ATTEMPTS || !LEARN_LOCK_BUSY.test(errorText(error))) throw error log.warn("rules state busy; recording again", { attempt, error: errorText(error) }) await new Promise((resolve) => setTimeout(resolve, 500 * attempt)) } } } const RECORD_ATTEMPTS = 3 +/** `Flock`'s message when the learn lock (`withLearnLock`) could not be acquired in time. */ +const LEARN_LOCK_BUSY = /^Timed out waiting for lock: learn-state$/ /** * Send the outbox. An item leaves it once the server answered for it, or refused it (`rejected`, reported @@ -279,14 +296,20 @@ export async function push(root: string, ctx: Ready): Promise { // cannot stop two of them from reading the same outbox and posting it twice. A run that finds another one sending // waits for it; the outcome it recorded per outbox id then answers this run's card. await SafeFS.mkdir(root, RulesStore.dir(root)) + let entered = false try { - return await Flock.withLock("rules-push", () => pushLocked(root, ctx), { + return await Flock.withLock("rules-push", () => { + entered = true + return pushLocked(root, ctx) + }, { dir: RulesStore.dir(root), staleMs: 60_000, timeoutMs: PUSH_LOCK_WAIT_MS, }) } catch (error) { - if (!/Timed out waiting for lock/.test(errorText(error))) throw error + // Only a push lock this run never got means another sender: anything thrown while sending (the learn lock too) is + // a failure of this run. + if (entered) throw error const queued = (await RulesStore.read(root)).outbox.length return { sent: [], queued, ...(queued ? { error: "another sync is still sending proposals" } : {}) } } @@ -297,7 +320,7 @@ const PUSH_LOCK_WAIT_MS = 30_000 async function pushLocked(root: string, ctx: Ready): Promise { const state = await RulesStore.read(root) // The state was taken over by another account or workspace since this run started: none of it is this run's to send. - if (state.scope && !RulesStore.sameScope(state.scope, ctx.scope)) return { sent: [], queued: 0 } + if (!ownedBy(state, ctx)) return { sent: [], queued: 0 } const batch = state.outbox.slice(0, MAX_PROPOSALS) const retried = await retryApprovals(root, ctx, state.pending_approvals) if (!batch.length) return { sent: [], queued: 0, ...(retried ? { approval_error: retried } : {}) } @@ -343,11 +366,12 @@ async function pushLocked(root: string, ctx: Ready): Promise { if (entry.approve && result.status === "pending" && result.public_id && result.version) approvals.push({ entry, publicId: result.public_id, version: result.version }) } - // Recorded before any "Approve now": what was sent leaves the outbox even if an approval or a later write fails. + // Recorded before any "Approve now": what was sent leaves the outbox, and an approval to make is kept as pending, so a + // crash or a failed write before the approval answers retries it on the next push instead of losing it. const now = new Date().toISOString() let foreign = false const after = await record(root, (s) => { - if (s.scope && !RulesStore.sameScope(s.scope, ctx.scope)) return void (foreign = true) + if (!ownedBy(s, ctx)) return void (foreign = true) s.outbox = s.outbox.filter((entry) => !done.has(entry.id)) for (const entry of s.outbox) { const error = failed.get(entry.id) @@ -356,20 +380,28 @@ async function pushLocked(root: string, ctx: Ready): Promise { entry.last_error = error } recordOutcomes(s, sent) + for (const approval of approvals) + if (!s.pending_approvals.some((p) => p.public_id === approval.publicId)) + s.pending_approvals.push({ public_id: approval.publicId, version: approval.version, action: approval.entry.approve!, attempts: 0 }) for (const row of sent) { if (!row.public_id || row.status === "exists") continue const existing = s.sent.find((x) => x.public_id === row.public_id) const change = batch.find((b) => b.id === row.id)?.item.change ?? "add" if (existing) Object.assign(existing, { status: row.status, updated_at: now }) - else s.sent.push({ public_id: row.public_id, text: row.text, change, status: row.status, sent_at: now, updated_at: now }) - if (row.status === "pending") s.pending_count += 1 + else { + s.sent.push({ public_id: row.public_id, text: row.text, change, status: row.status, sent_at: now, updated_at: now }) + // A proposal the server already had (a resend answered as a duplicate) is counted by the next pull, not again. + if (row.status === "pending") s.pending_count += 1 + } } }) if (foreign) { log.warn("rules state changed scope while proposals were sent; their answers were not recorded") return { sent: [], queued: after.outbox.length } } - const pendingApprovals: RulesStore.State["pending_approvals"] = [] + /** Approvals answered here, for good or not: they leave `pending_approvals`; a passing failure stays with one attempt. */ + const settled = new Set() + const retry = new Set() for (const approval of approvals) { const row = sent.find((s) => s.public_id === approval.publicId) try { @@ -378,19 +410,20 @@ async function pushLocked(root: string, ctx: Ready): Promise { expected_version: approval.version, }) if (row) Object.assign(row, { status: reviewed.status, approved: true }) + settled.add(approval.publicId) } catch (error) { if (row) row.approve_error = errorText(error) if (retryableApproval(error)) { if (row) row.approve_retry = true - pendingApprovals.push({ public_id: approval.publicId, version: approval.version, action: approval.entry.approve!, attempts: 1 }) - } + retry.add(approval.publicId) + } else settled.add(approval.publicId) } } if (approvals.length) await record(root, (s) => { - if (s.scope && !RulesStore.sameScope(s.scope, ctx.scope)) return - for (const approval of pendingApprovals) - if (!s.pending_approvals.some((p) => p.public_id === approval.public_id)) s.pending_approvals.push(approval) + if (!ownedBy(s, ctx)) return + s.pending_approvals = s.pending_approvals.flatMap((p) => + settled.has(p.public_id) ? [] : retry.has(p.public_id) ? [{ ...p, attempts: Math.max(p.attempts, 1) }] : [p]) const answered = sent.filter((row) => approvals.some((a) => a.publicId === row.public_id)) recordOutcomes(s, answered) for (const row of answered) { @@ -429,6 +462,11 @@ async function retryApprovals(root: string, ctx: Ready, pending: RulesStore.Stat }) settled.set(approval.public_id, { status: reviewed.status }) } catch (error) { + // An earlier attempt landed but its answer was lost: the rule is already active, which is what was asked. + if (alreadyActive(error)) { + settled.set(approval.public_id, { status: "active" }) + continue + } last = `approving a proposal failed: ${errorText(error)}` if (retryableApproval(error) && approval.attempts + 1 < MAX_APPROVAL_ATTEMPTS) retry.set(approval.public_id, approval.attempts + 1) else settled.set(approval.public_id, { reason: `"Approve now" failed: ${errorText(error)}. Approve it in the workspace's Rules tab.` }) @@ -476,11 +514,25 @@ function fromRecorded(id: string, recorded: Recorded): SentRow { } } -/** Queue proposals for the next push, in one write. Returns their outbox ids, in order. Refuses past `MAX_OUTBOX`. */ -export async function enqueueMany(root: string, items: readonly { item: ProposalItem; approve?: "approve" | "approve_anyway" }[]): Promise { +/** The account or workspace changed since the proposal was shown: it is not queued for the new one. */ +export class ScopeChangedError extends Error { + constructor() { + super("the account or workspace changed since this was shown") + this.name = "ScopeChangedError" + } +} + +/** Queue proposals for the next push, in one write. Returns their outbox ids, in order. Refuses past `MAX_OUTBOX`. + * `scope`: the scope they were shown for; checked in the same write, so they never join another scope's outbox. */ +export async function enqueueMany( + root: string, + items: readonly { item: ProposalItem; approve?: "approve" | "approve_anyway" }[], + scope?: Scope, +): Promise { const ids = items.map(() => randomUUID()) const at = new Date().toISOString() await RulesStore.update(root, (state) => { + if (scope && !RulesStore.sameScope(state.scope, scope)) throw new ScopeChangedError() if (state.outbox.length + items.length > RulesStore.MAX_OUTBOX) throw new Error( items.length > RulesStore.MAX_OUTBOX @@ -494,8 +546,8 @@ export async function enqueueMany(root: string, items: readonly { item: Proposal } /** Queue one proposal; it is sent by the next push. Returns its outbox id. */ -export async function enqueue(root: string, item: ProposalItem, approve?: "approve" | "approve_anyway"): Promise { - return (await enqueueMany(root, [{ item, ...(approve ? { approve } : {}) }]))[0] +export async function enqueue(root: string, item: ProposalItem, approve?: "approve" | "approve_anyway", scope?: Scope): Promise { + return (await enqueueMany(root, [{ item, ...(approve ? { approve } : {}) }], scope))[0] } /** @@ -629,7 +681,8 @@ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: nu let stale = false const after = await RulesStore.update(root, (s) => { if (s.pending_usage) return - stale = pairs.some((pair) => s.usage_reported.includes(pair)) || + const reportedNow = new Set(s.usage_reported) + stale = pairs.some((pair) => reportedNow.has(pair)) || Object.keys(corrected).some((key) => (s.corrected_reported[key] ?? 0) !== (state.corrected_reported[key] ?? 0)) if (!stale) s.pending_usage = batch }) @@ -686,19 +739,23 @@ async function runUntracked(root: string, directory: string, learn: LearnConfig) const key = await canonicalRoot(root) const ctx = await context(directory, learn) if (ctx.status !== "ready") { - if (ctx.status === "skipped" && ctx.unlinked) { + // Unlinked, or signed out: nothing vouches for the pulled rules any more, so they stop being delivered (the queue + // stays for when the link or the sign-in returns). Other skips (unconfirmed for now) keep them. + if (ctx.status === "skipped" && (ctx.unlinked || ctx.code === "not_signed_in")) { const state = await RulesStore.read(root) if (state.workspace) await RulesStore.reset(root, { keepOutbox: true }) } failedAt.set(key, Date.now()) return { context: ctx } } - await adopt(root, ctx) + const { state } = await adopt(root, ctx) const pushed = await push(root, ctx) + // Usage before the pull: it is counted against the rule versions sessions were shown, which a pull that edits or + // retires a rule would replace. Only for a workspace the last pull reached (`workspace` is cleared when it is gone). + const usage = state.workspace ? await reportUsage(root, ctx).catch((error) => ({ items: 0, error: errorText(error) })) : undefined const pulled = await pull(root, ctx) if (pulled.ok) failedAt.delete(key) else failedAt.set(key, Date.now()) - const usage = pulled.ok ? await reportUsage(root, ctx).catch((error) => ({ items: 0, error: errorText(error) })) : undefined return { context: ctx, push: pushed, pull: pulled, ...(usage ? { usage } : {}) } } diff --git a/packages/opencode/src/altimate/rules/telemetry.ts b/packages/opencode/src/altimate/rules/telemetry.ts index acd1a561df..b8ee84d868 100644 --- a/packages/opencode/src/altimate/rules/telemetry.ts +++ b/packages/opencode/src/altimate/rules/telemetry.ts @@ -77,6 +77,27 @@ export function skipped( send({ type: "rule_proposal_skipped", timestamp: Date.now(), session_id: sessionID, ...(trigger ? { trigger } : {}), reason }) } +/** One drafting model call after a turn: its token and cost usage, whether it answered (no wording or text). */ +export function drafted(input: { + sessionID: string + trigger: Of<"rule_drafted">["trigger"] + ok: boolean + usage: { inputTokens: number; outputTokens: number; estimatedCost: number } + startedAt: number +}) { + send({ + type: "rule_drafted", + timestamp: Date.now(), + session_id: input.sessionID, + trigger: input.trigger, + status: input.ok ? "ok" : "failed", + input_tokens: input.usage.inputTokens, + output_tokens: input.usage.outputTokens, + estimated_cost: input.usage.estimatedCost, + duration_ms: Date.now() - input.startedAt, + }) +} + export function routed(sessionID: string, via: "training" | "memory", outcome: Of<"rule_remember_routed">["outcome"]) { send({ type: "rule_remember_routed", timestamp: Date.now(), session_id: sessionID, via, outcome }, false) } diff --git a/packages/opencode/src/altimate/rules/trigger.ts b/packages/opencode/src/altimate/rules/trigger.ts index ef1d216541..7583ae3959 100644 --- a/packages/opencode/src/altimate/rules/trigger.ts +++ b/packages/opencode/src/altimate/rules/trigger.ts @@ -17,8 +17,9 @@ import { lint } from "../learn/curator" import { readShownSnapshots } from "../learn/delivery" import { buildDigest, type DigestSource } from "../learn/digest" import * as Playbook from "../learn/playbook" -import { analyzeRecurrence } from "../learn/recurrence" -import { reflect, type Generate } from "../learn/reflect" +import { approvedLessons, unmatchedCorrectionTopics } from "../learn/recurrence" +import { reflect, type Generate, type GenerateUsage } from "../learn/reflect" +import { createUsageTracker } from "../learn/usage" import { readSignals, type Signal } from "../learn/signals" import { RuleClassify } from "./classify" import { RuleProposal, excerpt, type Draft } from "./proposal" @@ -30,8 +31,8 @@ export const RECURRENCE_SESSIONS = 2 const MAX_WHY = 280 export interface TriggerDeps { - /** The model for one drafting call, resolved for this session. */ - generate: (source: DigestSource) => Promise + /** The model for one drafting call, resolved for this session; `onUsage` receives its token and cost usage. */ + generate: (source: DigestSource, onUsage?: (usage: GenerateUsage) => void) => Promise source: (sessionID: string) => Promise } @@ -66,6 +67,8 @@ const STOP = new Set("the and for with that this from into when then than are wa const words = (text: string) => new Set((text.toLowerCase().match(/[a-z][a-z0-9_]{2,}/g) ?? []).filter((w) => !STOP.has(w))) /** Shared content words that make a shown team rule the subject of a correction. */ export const EDIT_OVERLAP = 3 +/** Shared content words that make a correction evidence for a rule the person asked to remember. */ +const RELATED_OVERLAP = 2 /** Path globs per proposal, and their length: the server's lint bounds (a glob longer than a rule is not a path the * person meant). Shared with the history path (`from-history.ts`). */ @@ -96,6 +99,7 @@ const teamBullets = (state: RulesStore.State): Playbook.Bullet[] => async function draft(input: { deps: TriggerDeps sessionID: string + trigger: "shown_rule" | "recurrence" signals: readonly Signal[] state: RulesStore.State target?: string @@ -105,14 +109,25 @@ async function draft(input: { note?: (outcome: string) => void }): Promise<{ text: string; change: Draft["change"]; replaces?: Draft["replaces"]; reason: string } | "covered" | undefined> { const source = await input.deps.source(input.sessionID) - const generate = await input.deps.generate(source) + // The model call runs in the background after a turn: its tokens and cost are reported, failed or not. + const tracker = createUsageTracker() + const startedAt = Date.now() + let ok = false const bullets = teamBullets(input.state) - const deltas = await reflect({ - digest: buildDigest(source), - feedback: input.signals.map((signal) => signal.text).join("\n\n"), - kind: "user", - bullets: input.target ? bullets.filter((b) => b.id === input.target) : bullets, - }, generate) + const deltas = await (async () => { + try { + const result = await reflect({ + digest: buildDigest(source), + feedback: input.signals.map((signal) => signal.text).join("\n\n"), + kind: "user", + bullets: input.target ? bullets.filter((b) => b.id === input.target) : bullets, + }, await input.deps.generate(source, tracker.add)) + ok = true + return result + } finally { + RulesTelemetry.drafted({ sessionID: input.sessionID, trigger: input.trigger, ok, usage: tracker.usage, startedAt }) + } + })() log.info("rule draft", { session: input.sessionID, target: input.target, deltas: deltas.map((d) => d.op) }) const rules = new Map(input.state.rules.map((rule) => [RulesStore.lessonId(rule.rule_key), rule])) for (const delta of deltas) { @@ -168,6 +183,15 @@ export async function afterTurn(input: { return RulesTelemetry.skipped(input.sessionID, undefined, "card_open") // Marked processed once the check completes; a failed check is retried after the next turn. checked = fresh.map((signal) => signal.id) + // The model call takes a while: if the client that shows cards went away meanwhile, the card would not be seen, + // so the corrections are left for a later check instead. + const stillShown = () => { + if (RuleProposal.rendererAttached(input.directory)) return true + checked = [] + outcome = "the client that shows cards went away during the check; corrections kept for a later check" + RulesTelemetry.skipped(input.sessionID, undefined, "no_renderer") + return false + } const newest = fresh[fresh.length - 1] const correction = { excerpt: excerpt(newest.text), at: newest.correctedAt ?? newest.at } @@ -185,10 +209,11 @@ export async function afterTurn(input: { .sort((a, b) => b.overlap - a.overlap)[0] log.info("rule edit candidate", { session: input.sessionID, hit: hit?.id, overlap: hit?.overlap, shown: shownIds.size }) if (hit) { - const drafted = await draft({ deps: input.deps, sessionID: input.sessionID, signals: fresh, state, target: hit.id, note }) + const drafted = await draft({ deps: input.deps, sessionID: input.sessionID, trigger: "shown_rule", signals: fresh, state, target: hit.id, note }) // The shown rule already says it: no card, and no second drafting call for recurrence. if (drafted === "covered") return RulesTelemetry.skipped(input.sessionID, "shown_rule", "covered") if (drafted) { + if (!stillShown()) return const card = await RuleProposal.ask({ sessionID: input.sessionID, root: input.root, directory: input.directory, learn: input.learn, trigger: "shown_rule", draft: { @@ -209,22 +234,22 @@ export async function afterTurn(input: { return RulesTelemetry.skipped(input.sessionID, "shown_rule", "no_draft") } - // (a) The same topic corrected in 2 or more sessions, with no rule covering it. - const recurrence = await analyzeRecurrence(input.root) + // (a) The same topic corrected in 2 or more sessions, with no rule covering it. From the corrections themselves: + // a project with no approved rules yet has no delivery snapshots, and must still be able to get its first rule. + const all = await readSignals(input.root, Playbook.DEFAULT_NAME) + const topics = unmatchedCorrectionTopics(all, await approvedLessons(input.root)) const freshIds = new Set(fresh.map((signal) => signal.id)) - const topic = recurrence.unmatched_topics.find((t) => - t.sessions >= RECURRENCE_SESSIONS && t.signal_ids.some((id) => freshIds.has(id)) && !RulesStore.dismissed(state, t.identifier)) + const candidates = topics.filter((t) => t.sessions >= RECURRENCE_SESSIONS && t.signal_ids.some((id) => freshIds.has(id))) + const topic = candidates.find((t) => !RulesStore.dismissed(state, t.identifier)) if (!topic) { if (!hit) outcome = "no team rule shown on this subject and no topic corrected in 2+ sessions" - const dismissed = recurrence.unmatched_topics.some((t) => - t.sessions >= RECURRENCE_SESSIONS && t.signal_ids.some((id) => freshIds.has(id)) && RulesStore.dismissed(state, t.identifier)) - return RulesTelemetry.skipped(input.sessionID, "recurrence", dismissed ? "topic_dismissed" : "no_topic") + return RulesTelemetry.skipped(input.sessionID, "recurrence", candidates.length ? "topic_dismissed" : "no_topic") } - const all = await readSignals(input.root, Playbook.DEFAULT_NAME) const backing = all.filter((signal) => topic.signal_ids.includes(signal.id)) - const drafted = await draft({ deps: input.deps, sessionID: input.sessionID, signals: backing, state, addOnly: true, note }) + const drafted = await draft({ deps: input.deps, sessionID: input.sessionID, trigger: "recurrence", signals: backing, state, addOnly: true, note }) if (!drafted || drafted === "covered" || drafted.change !== "add") return RulesTelemetry.skipped(input.sessionID, "recurrence", drafted === "covered" ? "covered" : "no_draft") + if (!stillShown()) return const card = await RuleProposal.ask({ sessionID: input.sessionID, root: input.root, directory: input.directory, learn: input.learn, trigger: "recurrence", draft: { @@ -272,6 +297,8 @@ export async function explicit(input: { memory?: { id: string; tags: string[] } /** Saves the request as before cards if the card closes unanswered (`RuleProposal.ask`). */ unanswered?: (text: string) => Promise + /** What to keep on disk while the card is open (`RuleProposal.ask`). */ + request?: import("./proposal").RequestRecord }): Promise< | { status: "shown"; card: Awaited>; choose: boolean } | { status: "invalid"; reason: string; kind: "rule" | "ambiguous" } @@ -283,14 +310,17 @@ export async function explicit(input: { if (kind === "memory") return { status: "memory", cue: cue ?? "a fact or preference" } const reason = lint(text) if (reason) return { status: "invalid", reason, kind } - const signals = (await readSignals(input.root, Playbook.DEFAULT_NAME).catch(() => [] as Signal[])) + // The session's latest correction is evidence only when it is about the same thing: an unrelated quote would reach the + // owner (and widen `applies_to`) for a rule it has nothing to do with. + const said = words(text) + const last = (await readSignals(input.root, Playbook.DEFAULT_NAME).catch(() => [] as Signal[])) .filter((signal) => signal.kind === "user_correction" && signal.sessionID === input.sessionID) - const last = signals[signals.length - 1] + .findLast((signal) => [...words(signal.text)].filter((w) => said.has(w)).length >= RELATED_OVERLAP) // Always a new rule: shared words are not a reason to replace a team rule (dbt rules share "model", "test", // "staging"), and the card has no "add instead". Changing a team rule is the correction path's job, where the // drafter decides whether the shown rule is the subject. const card = await RuleProposal.ask({ - sessionID: input.sessionID, root: input.root, directory: input.directory, learn: input.learn, unanswered: input.unanswered, + sessionID: input.sessionID, root: input.root, directory: input.directory, learn: input.learn, unanswered: input.unanswered, request: input.request, draft: { text, applies_to: appliesTo([text, ...(last ? [last.text] : [])]), diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 22c3fb6b59..6bc19ff9f8 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -814,6 +814,18 @@ export namespace Telemetry { edits: number ms_since_shown: number } + | { + /** One model call that drafted a proposal after a turn (it runs in the background, so its cost is reported here). */ + type: "rule_drafted" + timestamp: number + session_id: string + trigger: "shown_rule" | "recurrence" + status: "ok" | "failed" + input_tokens: number + output_tokens: number + estimated_cost: number + duration_ms: number + } | { /** Corrections were checked after a turn and no card was shown. */ type: "rule_proposal_skipped" diff --git a/packages/opencode/src/cli/cmd/learn.ts b/packages/opencode/src/cli/cmd/learn.ts index e4c4b6afb1..47f4c9c197 100644 --- a/packages/opencode/src/cli/cmd/learn.ts +++ b/packages/opencode/src/cli/cmd/learn.ts @@ -388,8 +388,10 @@ const StatusCommand = effectCmd({ ...base, // Read-only and advisory: runs after the lock is released; its failure must not hide the rest of the status. ...await analyzeRecurrence(root, { name }).then((recurrence) => ({ recurrence }), (error) => ({ recurrence_error: error instanceof Error ? error.message : String(error) })), - // altimate_change start — workspace rules (local state only) - rules: await import("../../altimate/rules/summary").then((m) => m.RulesSummary.summary(root, learn)), + // altimate_change start — workspace rules (local state only), with the learn config the service enforces + rules: await Promise.all([import("../../altimate/rules/summary"), import("../../altimate/rules/service")]) + .then(async ([{ RulesSummary }, { RulesService }]) => + RulesSummary.summary(root, await RulesService.effectiveLearn(learn, root, (await import("@/project/instance")).Instance.directory))), // altimate_change end })) if (args.json) return out(JSON.stringify(status, null, 2)) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index db9e4ff7b5..d49756dc52 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -519,7 +519,7 @@ export const layer = Layer.effect( source === "OPENCODE_CONFIG_CONTENT" || source.endsWith("/api/config") || source.startsWith(ConfigManaged.managedConfigDir() + path.sep) || - (!!Flag.OPENCODE_CONFIG_DIR && path.dirname(source) === path.resolve(Flag.OPENCODE_CONFIG_DIR)) || + (!!Flag.OPENCODE_CONFIG_DIR && path.resolve(path.dirname(source)) === path.resolve(Flag.OPENCODE_CONFIG_DIR)) || homeDirs.includes(path.dirname(source)) const noteLearn = (source: string, next: Info | undefined) => { const learn = (next as { learn?: unknown } | undefined)?.learn @@ -883,8 +883,12 @@ export const layer = Layer.effect( // altimate_change start — keep the synchronous view scoped to this config instance const loaded = yield* loadInstanceState(ctx).pipe(Effect.orDie) loadedConfig.set(ctx.directory, loaded.config) + const learnAbove = LEARN_ABOVE_PROJECT.get(ctx.directory) yield* Effect.addFinalizer(() => Effect.sync(() => { if (loadedConfig.get(ctx.directory) === loaded.config) loadedConfig.delete(ctx.directory) + // altimate_change start — workspace rules: a disposed instance's precedence goes with it + if (LEARN_ABOVE_PROJECT.get(ctx.directory) === learnAbove) LEARN_ABOVE_PROJECT.delete(ctx.directory) + // altimate_change end })) return loaded // altimate_change end diff --git a/packages/opencode/src/plugin/tui/altimate/rules.tsx b/packages/opencode/src/plugin/tui/altimate/rules.tsx index 870df8f0d7..b4c3a18741 100644 --- a/packages/opencode/src/plugin/tui/altimate/rules.tsx +++ b/packages/opencode/src/plugin/tui/altimate/rules.tsx @@ -538,12 +538,14 @@ async function showMenu(api: TuiPluginApi) { if (option.value === "sync") { api.ui.toast({ variant: "info", message: "Syncing rules…", duration: 3000 }) const report = await post<{ ok: boolean; error?: string; pull?: { ok: boolean; rules: number; pending: number; error?: string; notices: unknown[] }; push?: { sent: unknown[]; queued?: number; error?: string }; context?: { status: string; reason?: string } }>(api, "/altimate/rules/sync").catch(() => undefined) - // A review notice from this pull is its own toast; do not cover it with the summary. - if (report?.pull?.notices.length) return + // A review notice from this pull is its own toast: the success summary would cover it, but a problem (proposals + // kept queued, a failed pull) is still said. + const reviewed = !!report?.pull?.notices.length if (report?.context && report.context.status !== "ready") api.ui.toast({ variant: "warning", message: `Rules sync skipped: ${printable(report.context.reason ?? report.context.status)}.`, duration: 8000 }) else if (report?.pull?.ok && report.push?.error) api.ui.toast({ variant: "warning", message: `Synced ${report.pull.rules} team ${report.pull.rules === 1 ? "rule" : "rules"}, but ${report.push.queued ?? 0} ${report.push.queued === 1 ? "proposal stays" : "proposals stay"} queued: ${printable(report.push.error)}`, duration: 10000 }) + else if (report?.pull?.ok && reviewed) return else if (report?.pull?.ok) api.ui.toast({ variant: "success", message: `Synced: ${report.pull.rules} team ${report.pull.rules === 1 ? "rule" : "rules"}, ${report.pull.pending} pending review${report.push?.sent.length ? `, ${report.push.sent.length} sent` : ""}.`, duration: 6000 }) else api.ui.toast({ variant: "error", message: `Rules sync failed: ${printable(report?.pull?.error ?? report?.error ?? "the session server did not answer")}`, duration: 8000 }) @@ -577,7 +579,7 @@ const tui: TuiPlugin = async (api) => { namespace: "palette", slashName: "rules", run() { - showMenu(api).catch((err) => api.ui.toast({ variant: "error", message: String(err), duration: 8000 })) + showMenu(api).catch((err) => api.ui.toast({ variant: "error", message: printable(String(err)), duration: 8000 })) }, }, ], diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 6af8683ee9..b6ecd50165 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,6 +1,6 @@ import path from "path" import { Token } from "@/util/token" -import { existsSync, readFileSync, realpathSync } from "node:fs" +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs" import os from "os" import fs from "fs/promises" import z from "zod" @@ -152,28 +152,35 @@ globalThis.AI_SDK_LOG_WARNINGS = false // altimate_change start — workspace rules: sessions that already had their session-start pull in this process const rulesSessionsStarted = new Set() /** Rules state in this project, or a cached workspace binding for this directory: otherwise the project cannot have - * workspace rules, and the prompt path skips them. Synchronous, local checks only (a small JSON read at most). */ -const mayHaveWorkspaceRules = (root: string, directory: string) => { - if (existsSync(path.join(root, ".altimate-code", "learn", "workspace-rules", "rules.json"))) return true + * workspace rules, and the prompt path skips them. Synchronous, local checks only: the bindings cache is parsed once per + * change of the file (size and mtime), not on every prompt. */ +const canonicalDir = (dir: string) => { try { - const cache = JSON.parse(readFileSync(path.join(Global.Path.state, "altimate-workspace-bindings.json"), "utf8")) as { - bindings?: Record - } - const keys = Object.keys(cache.bindings ?? {}) - if (!keys.length) return false - const canonical = (dir: string) => { - try { - return realpathSync(path.resolve(dir)) - } catch { - return path.resolve(dir) - } + return realpathSync(path.resolve(dir)) + } catch { + return path.resolve(dir) + } +} +let boundDirs: { stamp: string; dirs: ReadonlySet } | undefined +const boundDirectories = (): ReadonlySet => { + const file = path.join(Global.Path.state, "altimate-workspace-bindings.json") + try { + const stat = statSync(file) + const stamp = `${stat.size}:${stat.mtimeMs}` + if (boundDirs?.stamp !== stamp) { + const cache = JSON.parse(readFileSync(file, "utf8")) as { bindings?: Record } + boundDirs = { stamp, dirs: new Set(Object.keys(cache.bindings ?? {}).map(canonicalDir)) } } - const here = canonical(directory) - return keys.some((key) => key === here || canonical(key) === here) + return boundDirs.dirs } catch { - return false + return new Set() } } +const mayHaveWorkspaceRules = (root: string, directory: string) => { + if (existsSync(path.join(root, ".altimate-code", "learn", "workspace-rules", "rules.json"))) return true + const dirs = boundDirectories() + return dirs.size > 0 && dirs.has(canonicalDir(directory)) +} // altimate_change end const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format. diff --git a/packages/opencode/test/altimate/learn/delivery.test.ts b/packages/opencode/test/altimate/learn/delivery.test.ts index e8c92b1735..cceb60f260 100644 --- a/packages/opencode/test/altimate/learn/delivery.test.ts +++ b/packages/opencode/test/altimate/learn/delivery.test.ts @@ -505,16 +505,28 @@ test("rules added to a session are reported by tier, as counts only; a cached an expect(JSON.stringify(events)).not.toContain("shipping") }) -test("rules from the workspace-rules store are counted as team rules", async () => { +test("rules from the workspace-rules store are counted as team rules, and are not delivered with workspaces off", async () => { await approve([lesson("L-0101", "Keep the project rules explicit.", { pinned: true })]) await approve([lesson("L-0102", "Name staging models stg_.", { pinned: true })], Store.WORKSPACE_RULES_STORE) - const events: Telemetry.Event[] = [] - const spy = spyOn(Telemetry, "track").mockImplementation((event: Telemetry.Event) => void events.push(event)) + const delivered = async (session: string) => { + const events: Telemetry.Event[] = [] + const spy = spyOn(Telemetry, "track").mockImplementation((event: Telemetry.Event) => void events.push(event)) + try { + await new Delivery(root, { ...limits, core_lessons: 2 }).prepare(session, "first", "staging") + } finally { + spy.mockRestore() + } + return events.find((e): e is Extract => e.type === "rules_delivered") + } + const env = process.env.ALTIMATE_DISABLE_WORKSPACE try { - await new Delivery(root, { ...limits, core_lessons: 2 }).prepare("session", "first", "staging") + delete process.env.ALTIMATE_DISABLE_WORKSPACE + expect(await delivered("on")).toMatchObject({ tier: "start", rules: 2, team_rules: 1 }) + // The last pulled team rules are not delivered while workspaces are turned off; the person's own rules are. + process.env.ALTIMATE_DISABLE_WORKSPACE = "1" + expect(await delivered("off")).toMatchObject({ tier: "start", rules: 1, team_rules: 0 }) } finally { - spy.mockRestore() + if (env === undefined) delete process.env.ALTIMATE_DISABLE_WORKSPACE + else process.env.ALTIMATE_DISABLE_WORKSPACE = env } - const start = events.find((e): e is Extract => e.type === "rules_delivered") - expect(start).toMatchObject({ tier: "start", rules: 2, team_rules: 1 }) }) diff --git a/packages/opencode/test/altimate/rules/rules.test.ts b/packages/opencode/test/altimate/rules/rules.test.ts index 74207e6ba0..4ceea8614e 100644 --- a/packages/opencode/test/altimate/rules/rules.test.ts +++ b/packages/opencode/test/altimate/rules/rules.test.ts @@ -26,7 +26,7 @@ import { MemoryStore } from "../../../src/memory/store" import { TrainingStore } from "../../../src/altimate/training" import { Instance } from "../../../src/project/instance" import { Session } from "../../../src/session" -import { tmpdir } from "../../fixture/fixture" +import { disposeAllInstances, tmpdir } from "../../fixture/fixture" const tmp = () => fs.mkdtemp(path.join(os.tmpdir(), "rules-")) @@ -421,7 +421,10 @@ describe("the workspace API, by path", () => { const root = await tmp() answer = () => Response.json(syncBody([rule("aaaaaaaaaaaa", "Name staging models stg_.")])) await RulesSync.pull(root, ready()) - // Pretend a batch went out and its answer was lost. + // Pretend a batch went out and its answer was lost. Its sessions still have snapshots, so their pairs stay reported. + const sessions = path.join(root, ".altimate-code", "learn", ".sessions") + await fs.mkdir(sessions, { recursive: true }) + for (const session of ["s1", "s2"]) await fs.writeFile(path.join(sessions, `${Store.sha256(session)}.json`), "{}") await RulesStore.update(root, (s) => { s.pending_usage = { batch_id: "batch-1", items: [{ public_id: "p-aaaaaaaaaaaa", applied: 2, helpful: 0, harmful: 0, corrected_again: 0 }], pairs: ["s1|p-aaaaaaaaaaaa", "s2|p-aaaaaaaaaaaa"], corrected: {} } }) @@ -623,7 +626,7 @@ describe("the workspace API, by path", () => { // The scope this identity resolves to, as an earlier pull would have stored it. await RulesStore.update(root, (s) => Object.assign(s, linked(), { scope: { apiUrl: url, tenant: "t", account: "user:7", datamateId: 1, repoRemote: "https://github.com/acme/demo" } })) const items = Array.from({ length: 60 }, (_, i) => item(`Staged rule ${i}.`)) - const staged = { workspace: { name: "Team", owner: "Olive" }, items, invalid: [], localChanges: 0 } + const staged = { workspace: { name: "Team", owner: "Olive" }, scope: (await RulesStore.read(root)).scope!, items, invalid: [], localChanges: 0 } const result = await RulesFromHistory.sendStaged({ root, directory: root, learn: { enabled: true }, name: "team-playbook", staged, before: new Set() }) expect(result).toMatchObject({ sent: 50, queued: 10, refused: 0, lost: 0 }) expect(result.message).toContain("10 kept queued (50 proposals are waiting for review)") @@ -658,6 +661,10 @@ describe("the workspace API, by path", () => { await RulesStore.update(root, (s) => { s.pending_usage = { batch_id: "batch-y", items: [{ public_id: "p-aaaaaaaaaaaa", applied: 1, helpful: 0, harmful: 0, corrected_again: 0 }], pairs: ["s9|p-aaaaaaaaaaaa"], corrected: { "R-aaaaaaaaaaaa": 1 } } }) + // s9 still has a snapshot, so its pair is kept (a pair whose session is gone is let go). + const sessions = path.join(root, ".altimate-code", "learn", ".sessions") + await fs.mkdir(sessions, { recursive: true }) + await fs.writeFile(path.join(sessions, `${Store.sha256("s9")}.json`), "{}") answer = () => Response.json({ detail: { code: "batch_conflict", message: "conflict" } }, { status: 409 }) await RulesSync.reportUsage(root, ready()) const state = await RulesStore.read(root) @@ -750,7 +757,7 @@ describe("the workspace API, by path", () => { const update = RulesStore.update let failures = 0 const spy = spyOn(RulesStore, "update").mockImplementation((async (...args: Parameters) => { - if (failures++ === 0) throw new Error("Timed out waiting for lock") + if (failures++ === 0) throw new Error("Timed out waiting for lock: learn-state") return update(...args) }) as never) try { @@ -762,6 +769,88 @@ describe("the workspace API, by path", () => { expect((await RulesStore.read(root)).outbox).toEqual([]) }) + test("a write that fails after the state was saved is not applied again, and is not taken for another sender", async () => { + const root = await tmp() + await RulesStore.update(root, (s) => { s.scope = scope() }) + await RulesSync.enqueue(root, item("Counted once.")) + answer = () => Response.json({ results: [{ index: 0, status: "pending", public_id: "p-once", version: 1 }] }) + const update = RulesStore.update + let calls = 0 + // As if the mirror write failed after `rules.json` was written. + const spy = spyOn(RulesStore, "update").mockImplementation((async (...args: Parameters) => { + const written = await update(...args) + if (calls++ === 0) throw new Error("EACCES: approved.json") + return written + }) as never) + try { + await expect(RulesSync.push(root, ready())).rejects.toThrow("EACCES") + } finally { + spy.mockRestore() + } + expect(calls).toBe(1) + const state = await RulesStore.read(root) + expect(state.pending_count).toBe(1) + expect(state.sent.filter((s) => s.public_id === "p-once")).toHaveLength(1) + }) + + test("a learn lock that stays busy fails the push; it is not reported as another sync sending", async () => { + const root = await tmp() + await RulesSync.enqueue(root, item("Lock busy.")) + answer = () => Response.json({ results: [{ index: 0, status: "pending", public_id: "p-busy", version: 1 }] }) + const update = RulesStore.update + const spy = spyOn(RulesStore, "update").mockImplementation((async () => { + throw new Error("Timed out waiting for lock: learn-state") + }) as never) + try { + await expect(RulesSync.push(root, ready())).rejects.toThrow("learn-state") + } finally { + spy.mockRestore() + } + void update + }) + + test("an \"Approve now\" is kept as pending before its review is asked, so a crash cannot lose it", async () => { + const root = await tmp() + await RulesSync.enqueue(root, item("Approve durably."), "approve") + let intent: unknown + answer = async (p) => { + if (p === "/proposals") return Response.json({ results: [{ index: 0, status: "pending", public_id: "p-durable", version: 1 }] }) + // While the review is being asked, the intent is already on disk. + intent = (await RulesStore.read(root)).pending_approvals + return Response.json({ public_id: "p-durable", status: "active", version: 1 }) + } + await RulesSync.push(root, ready()) + expect(intent).toEqual([expect.objectContaining({ public_id: "p-durable", action: "approve" })]) + const state = await RulesStore.read(root) + expect(state.outbox).toEqual([]) + expect(state.pending_approvals).toEqual([]) + expect(state.sent.find((s) => s.public_id === "p-durable")?.status).toBe("active") + }) + + test("a committed project file cannot pose as an organization policy", async () => { + const root = await tmp() + await fs.mkdir(path.join(root, ".altimate-code"), { recursive: true }) + await fs.writeFile(path.join(root, ".altimate-code", "altimate-code.json"), JSON.stringify({ learn: { capture: true, capture_policy: true } })) + const learn = await RulesService.effectiveLearn(undefined, root, root) + expect(learn).toEqual({ capture: true }) + expect(learningDecision(learn, RulesStore.State.parse({ version: 1, personal_learning: false }), {})).toEqual({ on: false, source: "you" }) + }) + + test("the dropped-proposals notice stops showing after a week, without any write", () => { + const state = RulesStore.State.parse({ version: 1, scope_drop: { count: 2, at: "2026-10-01T00:00:00Z" } }) + expect(RulesStore.scopeDropNote(state, Date.parse("2026-10-07T00:00:00Z"))).toContain("2 queued proposals") + expect(RulesStore.scopeDropNote(state, Date.parse("2026-10-09T00:00:00Z"))).toBeUndefined() + }) + + test("with no session snapshots at all, reported usage is let go", async () => { + const root = await tmp() + answer = () => Response.json(syncBody([{ ...rule("aaaaaaaaaaaa", "Name staging models stg_."), public_id: "p-a" }])) + await RulesSync.pull(root, ready()) + await RulesStore.update(root, (s) => { s.usage_reported = ["gone|p-a"] }) + await RulesSync.reportUsage(root, ready()) + expect((await RulesStore.read(root)).usage_reported).toEqual([]) + }) + test("reported usage is kept while its session has a snapshot, and let go once the snapshot is gone", async () => { const root = await tmp() answer = () => Response.json(syncBody([{ ...rule("aaaaaaaaaaaa", "Name staging models stg_."), public_id: "p-a" }])) @@ -791,6 +880,135 @@ describe("the workspace API, by path", () => { expect((await fs.readdir(path.dirname(file))).filter((name) => name.startsWith("approved.json.before"))).toEqual([]) }) + test("bootstrap keeps sending while older queued proposals are ahead of its own", async () => { + const root = await tmp() + using user = asUser(7) + await RulesStore.update(root, (s) => Object.assign(s, linked(), { scope: user.scope })) + await RulesSync.enqueueMany(root, Array.from({ length: 120 }, (_, i) => ({ item: item(`Older ${i}.`) }))) + answer = (p, body) => p === "/proposals" + ? Response.json({ results: body.items.map((it: { text: string }, index: number) => ({ index, status: "pending", public_id: `p-${it.text}`, version: 1 })) }) + : p === "/sync" ? Response.json(syncBody([])) : Response.json({ duplicate: false, applied_items: 0, skipped_items: 0 }) + const staged = { workspace: { name: "Team", owner: "Olive" }, scope: user.scope, items: [item("Mine, last in line.")], invalid: [], localChanges: 0 } + const result = await RulesFromHistory.sendStaged({ root, directory: root, learn: { enabled: true }, name: "team-playbook", staged, before: new Set() }) + expect(result).toMatchObject({ sent: 1, queued: 0 }) + expect((await RulesStore.read(root)).outbox).toEqual([]) + }) + + test("signing out stops delivering the pulled team rules, and keeps the queue", async () => { + const root = await tmp() + await RulesStore.update(root, (s) => Object.assign(s, linked(), { scope: scope(), rules: [rule("aaaaaaaaaaaa", "Name staging models stg_.")] })) + await RulesSync.enqueue(root, item("Still queued.")) + const env = process.env.ALTIMATE_DISABLE_WORKSPACE + delete process.env.ALTIMATE_DISABLE_WORKSPACE + const spies: { mockRestore(): void }[] = [ + spyOn(WorkspaceState, "resolveBindingOutcome").mockResolvedValue({ status: "bound", binding: { datamateId: 1, repoRemote: "https://github.com/acme/demo" } } as never), + spyOn(WorkspaceApi, "captureCredentials").mockResolvedValue(undefined as never), + ] + try { + expect((await RulesSync.run(root, root, { enabled: true })).context).toMatchObject({ status: "skipped", code: "not_signed_in" }) + } finally { + for (const spy of spies) spy.mockRestore() + if (env === undefined) delete process.env.ALTIMATE_DISABLE_WORKSPACE + else process.env.ALTIMATE_DISABLE_WORKSPACE = env + } + const state = await RulesStore.read(root) + expect(state.rules).toEqual([]) + expect(state.workspace).toBeUndefined() + expect(state.outbox.map((e) => e.item.text)).toEqual(["Still queued."]) + expect(parse(await fs.readFile(Store.paths(root, RulesStore.WORKSPACE_RULES_STORE).approved, "utf8"))).toEqual([]) + }) + + test("a pull's answer is not applied to state another process adopted or advanced meanwhile", async () => { + const root = await tmp() + await RulesStore.update(root, (s) => Object.assign(s, linked(), { scope: scope(), revision: 2 })) + answer = async () => { + // Meanwhile another process adopted someone else's scope. + await RulesStore.update(root, (s) => { s.scope = { ...scope(), account: "someone-else" } }) + return Response.json(syncBody([rule("aaaaaaaaaaaa", "Name staging models stg_.")], { revision: 3 })) + } + await RulesSync.pull(root, ready()) + let state = await RulesStore.read(root) + expect(state.scope?.account).toBe("someone-else") + expect(state.rules).toEqual([]) + // An older revision than the one already applied is not applied either. + const newer = await tmp() + await RulesStore.update(newer, (s) => Object.assign(s, linked(), { scope: scope(), revision: 9, rules: [rule("bbbbbbbbbbbb", "Seeds live under seeds/reference.")] })) + answer = () => Response.json(syncBody([], { revision: 4 })) + await RulesSync.pull(newer, ready()) + state = await RulesStore.read(newer) + expect(state.revision).toBe(9) + expect(state.rules.map((r) => r.text)).toEqual(["Seeds live under seeds/reference."]) + }) + + test("a retried \"Approve now\" whose earlier attempt landed counts as approved", async () => { + const root = await tmp() + await RulesStore.update(root, (s) => { + Object.assign(s, linked(), { scope: scope() }) + s.sent.push({ public_id: "p-landed", text: "Approved already.", change: "add", status: "pending", sent_at: "2026-10-28T00:00:00Z", updated_at: "2026-10-28T00:00:00Z" }) + s.pending_approvals.push({ public_id: "p-landed", version: 1, action: "approve", attempts: 1 }) + }) + answer = () => Response.json({ detail: { code: "invalid_status", message: "The rule is active", status: "active", version: 1 } }, { status: 409 }) + const pushed = await RulesSync.push(root, ready()) + expect(pushed.approval_error).toBeUndefined() + const state = await RulesStore.read(root) + expect(state.pending_approvals).toEqual([]) + expect(state.sent[0]).toMatchObject({ status: "active" }) + expect(state.sent[0].reason).toBeUndefined() + }) + + test("a resend the server answers as an already-pending proposal is not counted twice", async () => { + const root = await tmp() + await RulesStore.update(root, (s) => { + Object.assign(s, linked(), { scope: scope(), pending_count: 1 }) + s.sent.push({ public_id: "p-dup", text: "Once.", change: "add", status: "pending", sent_at: "2026-10-28T00:00:00Z", updated_at: "2026-10-28T00:00:00Z" }) + }) + await RulesSync.enqueue(root, item("Once.")) + answer = () => Response.json({ results: [{ index: 0, status: "pending", public_id: "p-dup", version: 1, duplicate: true }] }) + await RulesSync.push(root, ready()) + expect((await RulesStore.read(root)).pending_count).toBe(1) + }) + + test("usage is reported before a pull that retires the rule it counts", async () => { + const root = await tmp() + using user = asUser(7) + await RulesStore.update(root, (s) => Object.assign(s, linked(), { scope: user.scope, rules: [{ ...rule("aaaaaaaaaaaa", "Name staging models stg_."), public_id: "p-old" }] })) + const dir = path.join(root, ".altimate-code", "learn", ".sessions") + await fs.mkdir(dir, { recursive: true }) + const lesson = { id: RulesStore.lessonId("R-aaaaaaaaaaaa"), text: "Name staging models stg_.", tags: [], scope: "project", helpful: 0, harmful: 0, applied: 0, created: "2026-10-27T00:00:00Z", updated: "2026-10-27T00:00:00Z" } + await fs.writeFile(path.join(dir, `${Store.sha256("s-old")}.json`), JSON.stringify({ + version: 1, session: "s-old", firstMessage: "m", query: "q", touchedPaths: [], section: "", requests: [], requestParts: [], compactions: [], counted: [], + shown: [{ name: RulesStore.WORKSPACE_RULES_STORE, lesson, tier: "core", at: "2026-10-28T00:00:00Z", queryHash: "h" }], + })) + seen.length = 0 + answer = (p) => p === "/sync" ? Response.json(syncBody([])) : Response.json({ duplicate: false, applied_items: 1, skipped_items: 0 }) + await RulesSync.run(root, root, { enabled: true }, "manual") + expect(seen.find((r) => r.path === "/usage")?.body.items).toEqual([expect.objectContaining({ public_id: "p-old", applied: 1 })]) + expect((await RulesStore.read(root)).rules).toEqual([]) + }) + + test("bootstrap queues only what the outbox has room for and keeps the rest local", async () => { + const root = await tmp() + using user = asUser(7) + await RulesStore.update(root, (s) => Object.assign(s, linked(), { scope: user.scope })) + await RulesSync.enqueueMany(root, Array.from({ length: RulesStore.MAX_OUTBOX - 5 }, (_, i) => ({ item: item(`Older ${i}.`) }))) + // The workspace is unreachable for this run: everything stays queued. + answer = () => new Response("unavailable", { status: 503 }) + const staged = { workspace: { name: "Team", owner: "Olive" }, scope: user.scope, items: Array.from({ length: 8 }, (_, i) => item(`Mine ${i}.`)), invalid: [], localChanges: 0 } + const result = await RulesFromHistory.sendStaged({ root, directory: root, learn: { enabled: true }, name: "team-playbook", staged, before: new Set() }) + expect(result).toMatchObject({ held: 3, queued: 5 }) + expect(result.message).toContain("3 not sent: the queue of proposals waiting to be sent is full") + expect((await RulesStore.read(root)).outbox).toHaveLength(RulesStore.MAX_OUTBOX) + }) + + test("bootstrap does not queue rules listed for another account or workspace", async () => { + const root = await tmp() + await RulesStore.update(root, (s) => Object.assign(s, linked(), { scope: { ...scope(), account: "someone-else" } })) + const staged = { workspace: { name: "Team", owner: "Olive" }, scope: scope(), items: [item("Listed for the other one.")], invalid: [], localChanges: 0 } + const result = await RulesFromHistory.sendStaged({ root, directory: root, learn: { enabled: true }, name: "team-playbook", staged, before: new Set() }) + expect(result).toMatchObject({ sent: 0, held: 1 }) + expect((await RulesStore.read(root)).outbox).toEqual([]) + }) + test("too many proposals at once say so", async () => { const root = await tmp() const many = Array.from({ length: RulesStore.MAX_OUTBOX + 1 }, (_, i) => ({ item: item(`Rule ${i}.`) })) @@ -1180,8 +1398,9 @@ describe("remember, whichever save tool the agent picks", () => { const card = (await RuleProposal.ask({ sessionID: "b1", root, directory: root, learn: undefined, draft: asked("Prefix seeds with seed_."), unanswered: async (text) => void saved.push(text) }))! const replying = RuleProposal.reply(card.id, { action: "send" }) await gate.entered - await RuleProposal.drop(root) + const dropping = RuleProposal.drop(root) gate.release() + await dropping expect(await replying).toMatchObject({ ok: true }) expect(saved).toEqual([]) expect((await RulesStore.read(root)).outbox.map((e) => e.item.text)).toEqual(["Prefix seeds with seed_."]) @@ -1203,10 +1422,15 @@ describe("remember, whichever save tool the agent picks", () => { const card = (await RuleProposal.ask({ sessionID: "b2", root, directory: root, learn: undefined, draft: asked("Quote reserved words."), unanswered: async (text) => void saved.push(text) }))! const replying = RuleProposal.reply(card.id, { action: "send" }) await gate.entered - await RuleProposal.drop(root) + // Dropping waits for the running reply: its fallback has to land before the instance goes. + let dropped = false + const dropping = RuleProposal.drop(root).then(() => void (dropped = true)) + await Bun.sleep(20) + expect(dropped).toBe(false) gate.release() - expect(await replying).toMatchObject({ ok: false, code: "not_open" }) + await dropping expect(saved).toEqual(["Quote reserved words."]) + expect(await replying).toMatchObject({ ok: false, code: "not_open" }) } finally { enqueue.mockRestore() } @@ -1227,24 +1451,55 @@ describe("remember, whichever save tool the agent picks", () => { await using dir = await tmpdir({ git: true }) const said = "Remember as a rule: always use CTEs in staging models." const content = "Always use CTEs in staging models." - const directory = await Instance.provide({ directory: dir.path, fn: async () => { - const root = await fs.realpath(Instance.worktree) - await RulesStore.update(root, (s) => Object.assign(s, linked())) - RuleProposal.setRendererForTest(Instance.directory) - const session = await Session.create({}) - const result = await RulesRemember.proposeTeamRule(content, session.id, said, "training", undefined, { name: "staging-ctes", source: "the agent", citations: [] }) - expect(result?.title).toContain("Team rule proposed") - expect(await TrainingStore.get("project", "rule", "staging-ctes")).toBeUndefined() - return Instance.directory - } }) - // Closed from outside the instance (as on dispose): the fallback still writes into the project that asked. - await RuleProposal.drop(directory) - await Instance.provide({ directory: dir.path, fn: async () => { - const entry = await TrainingStore.get("project", "rule", "staging-ctes") - expect(entry?.content).toContain(content) - expect(entry?.meta.source).toBe("the agent") - } }) - RuleProposal.setRendererForTest(undefined) + let directory: string | undefined + try { + directory = await Instance.provide({ directory: dir.path, fn: async () => { + const root = await fs.realpath(Instance.worktree) + await RulesStore.update(root, (s) => Object.assign(s, linked())) + RuleProposal.setRendererForTest(Instance.directory) + const session = await Session.create({}) + const result = await RulesRemember.proposeTeamRule(content, session.id, said, "training", undefined, { name: "staging-ctes", source: "the agent", citations: [] }) + expect(result?.title).toContain("Team rule proposed") + expect(await TrainingStore.get("project", "rule", "staging-ctes")).toBeUndefined() + return Instance.directory + } }) + // Closed from outside the instance (as on dispose): the fallback still writes into the project that asked. + await RuleProposal.drop(directory) + await Instance.provide({ directory: dir.path, fn: async () => { + const entry = await TrainingStore.get("project", "rule", "staging-ctes") + expect(entry?.content).toContain(content) + expect(entry?.meta.source).toBe("the agent") + } }) + } finally { + if (directory) await RuleProposal.drop(directory) + RuleProposal.setRendererForTest(undefined) + await disposeAllInstances() + } + }) + + test("the fallback keeps training_save's per-kind limit: a new rule past it is not saved", async () => { + await using dir = await tmpdir({ git: true }) + const count = spyOn(TrainingStore, "count").mockResolvedValue({ rule: 50 } as never) + let directory: string | undefined + try { + directory = await Instance.provide({ directory: dir.path, fn: async () => { + const root = await fs.realpath(Instance.worktree) + await RulesStore.update(root, (s) => Object.assign(s, linked())) + RuleProposal.setRendererForTest(Instance.directory) + const session = await Session.create({}) + await RulesRemember.proposeTeamRule("Never select star in marts.", session.id, "Remember as a rule: never select star in marts.", "training", undefined, { name: "no-star" }) + return Instance.directory + } }) + await RuleProposal.drop(directory) + await Instance.provide({ directory: dir.path, fn: async () => { + expect(await TrainingStore.get("project", "rule", "no-star")).toBeUndefined() + } }) + } finally { + count.mockRestore() + if (directory) await RuleProposal.drop(directory) + RuleProposal.setRendererForTest(undefined) + await disposeAllInstances() + } }) test("a project fact is classified as project-scoped memory", () => { @@ -1467,3 +1722,135 @@ describe("learning precedence, stores and the after-turn check", () => { }) }) + +describe("cards across the feature's edges", () => { + const env = { ...process.env } + beforeAll(() => { + delete process.env.ALTIMATE_DISABLE_WORKSPACE + delete process.env.ALTIMATE_LEARN + }) + afterAll(() => { + if (env.ALTIMATE_DISABLE_WORKSPACE !== undefined) process.env.ALTIMATE_DISABLE_WORKSPACE = env.ALTIMATE_DISABLE_WORKSPACE + if (env.ALTIMATE_LEARN !== undefined) process.env.ALTIMATE_LEARN = env.ALTIMATE_LEARN + RuleProposal.setRendererForTest(undefined) + }) + const linkedRoot = async () => { + const root = await tmp() + await RulesStore.update(root, (s) => Object.assign(s, linked())) + RuleProposal.setRendererForTest(root) + return root + } + const add = (text: string) => ({ deltas: [{ op: "ADD", text, reason: "corrected in two sessions" }] }) + const deps = (result: unknown, during?: () => void): RuleTrigger.TriggerDeps => ({ + source: async () => ({ prompts: ["fix the orders model"], calls: [] }), + generate: async (_source, onUsage) => async () => { + during?.() + onUsage?.({ inputTokens: 120, outputTokens: 30, estimatedCost: 0.002 } as never) + return result + }, + }) + + test("the same correction in two sessions proposes a workspace's first rule, and the drafting call's usage is reported", async () => { + const root = await linkedRoot() + await appendSignal(root, { kind: "user_correction", sessionID: "c1", text: "no, the column is ordered_at", reason: "correction" }) + await appendSignal(root, { kind: "user_correction", sessionID: "c2", text: "use ordered_at for the order time", reason: "correction" }) + const events: Telemetry.Event[] = [] + const spy = spyOn(Telemetry, "track").mockImplementation((event: Telemetry.Event) => void events.push(event)) + try { + await RuleTrigger.afterTurn({ sessionID: "c2", root, directory: root, learn: { enabled: true, capture: true }, deps: deps(add("Name order timestamps ordered_at.")) }) + } finally { + spy.mockRestore() + } + expect(RuleProposal.list(root, "c2")).toEqual([expect.objectContaining({ text: "Name order timestamps ordered_at.", change: "add" })]) + expect(events).toContainEqual(expect.objectContaining({ type: "rule_drafted", trigger: "recurrence", status: "ok", input_tokens: 120, output_tokens: 30 })) + await RuleProposal.drop(root) + }) + + test("a client that goes away while the card is drafted leaves the corrections for a later check", async () => { + const root = await linkedRoot() + await appendSignal(root, { kind: "user_correction", sessionID: "g1", text: "no, call it shipped_at", reason: "correction" }) + await appendSignal(root, { kind: "user_correction", sessionID: "g2", text: "shipped_at, not ship_date", reason: "correction" }) + const other = await tmp() + await RuleTrigger.afterTurn({ sessionID: "g2", root, directory: root, learn: { enabled: true, capture: true }, deps: deps(add("Name ship timestamps shipped_at."), () => RuleProposal.setRendererForTest(other)) }) + expect(RuleProposal.list(root)).toEqual([]) + const state = await RulesStore.read(root) + expect(state.processed_signals).toEqual([]) + expect(state.last_check?.outcome).toContain("went away") + }) + + test("an explicit remember carries a correction only when it is about the same thing", async () => { + const root = await linkedRoot() + await appendSignal(root, { kind: "user_correction", sessionID: "e1", text: "no, the column is ordered_at", reason: "correction" }) + const unrelated = await RuleTrigger.explicit({ sessionID: "e1", root, directory: root, learn: undefined, text: "Never use FLOAT for money columns.", said: "Remember as a rule: never use FLOAT for money columns." }) + expect(unrelated.status === "shown" && unrelated.card?.evidence.correction).toBeFalsy() + await appendSignal(root, { kind: "user_correction", sessionID: "e1", text: "money columns must not be FLOAT, use integer cents", reason: "correction" }) + await RuleProposal.drop(root) + const related = await RuleTrigger.explicit({ sessionID: "e1", root, directory: root, learn: undefined, text: "Never use FLOAT for money columns.", said: "Remember as a rule: never use FLOAT for money columns." }) + expect(related.status === "shown" && related.card?.evidence.correction?.excerpt).toContain("integer cents") + await RuleProposal.drop(root) + }) + + test("a card answered after the account or workspace changed is not sent there; a requested one is saved as before", async () => { + const root = await linkedRoot() + const saved: string[] = [] + const card = (await RuleProposal.ask({ sessionID: "s1", root, directory: root, learn: undefined, unanswered: async (text) => void saved.push(text), draft: { text: "Prefix seeds with seed_.", applies_to: [], change: "add", origin: "explicit", evidence: { sessions: 1 }, topics: [] } }))! + await RulesStore.update(root, (s) => { s.scope = { ...s.scope!, account: "someone-else" } }) + expect(await RuleProposal.reply(card.id, { action: "send" })).toMatchObject({ ok: false, code: "not_open" }) + expect((await RulesStore.read(root)).outbox).toEqual([]) + expect(saved).toEqual(["Prefix seeds with seed_."]) + expect(RuleProposal.list(root)).toEqual([]) + }) + + test("a requested card is kept on disk while open, cleared once answered", async () => { + const root = await linkedRoot() + const card = (await RuleProposal.ask({ sessionID: "k1", root, directory: root, learn: undefined, unanswered: async () => {}, request: { session: "k1", via: "training" }, draft: { text: "Quote reserved words.", applies_to: [], change: "add", origin: "explicit", evidence: { sessions: 1 }, topics: [] } }))! + expect((await RulesStore.read(root)).remember_requests).toEqual([expect.objectContaining({ card: card.id, text: "Quote reserved words.", pid: process.pid })]) + await RuleProposal.reply(card.id, { action: "dismiss" }) + expect((await RulesStore.read(root)).remember_requests).toEqual([]) + }) + + test("a requested card whose process died is saved as before by the next process, once", async () => { + await using dir = await tmpdir({ git: true }) + const dead = Bun.spawn(["true"]) + await dead.exited + try { + await Instance.provide({ directory: dir.path, fn: async () => { + const root = await fs.realpath(Instance.worktree) + await RulesStore.update(root, (s) => { + s.remember_requests.push({ card: "c-dead", text: "Alias every joined table.", session: "x1", directory: Instance.directory, via: "training", training: { name: "alias-joins" }, pid: dead.pid, at: new Date().toISOString() }) + s.remember_requests.push({ card: "c-live", text: "Still open here.", session: "x2", directory: Instance.directory, via: "training", pid: process.pid, at: new Date().toISOString() }) + }) + await RulesRemember.recoverRequests(root, Instance.directory) + await RulesRemember.recoverRequests(root, Instance.directory) + expect((await TrainingStore.get("project", "rule", "alias-joins"))?.content).toContain("Alias every joined table.") + // A card this process still shows is its own to save. + expect((await RulesStore.read(root)).remember_requests.map((r) => r.card)).toEqual(["c-live"]) + } }) + } finally { + await disposeAllInstances() + } + }) + + test("with project config disabled, the live re-read ignores the project file", async () => { + const root = await tmp() + await fs.mkdir(path.join(root, ".altimate-code"), { recursive: true }) + await fs.writeFile(path.join(root, ".altimate-code", "altimate-code.json"), JSON.stringify({ learn: { capture: true } })) + const flag = process.env.OPENCODE_DISABLE_PROJECT_CONFIG + process.env.OPENCODE_DISABLE_PROJECT_CONFIG = "1" + try { + expect(await RulesService.effectiveLearn(undefined, root, root)).toBeUndefined() + } finally { + if (flag === undefined) delete process.env.OPENCODE_DISABLE_PROJECT_CONFIG + else process.env.OPENCODE_DISABLE_PROJECT_CONFIG = flag + } + }) + + test("a team-rules mirror left behind by a failed write is repaired by the next write", async () => { + const root = await tmp() + const file = Store.paths(root, RulesStore.WORKSPACE_RULES_STORE).approved + await fs.mkdir(path.dirname(file), { recursive: true }) + await fs.writeFile(file, JSON.stringify([{ ...RulesStore.toLesson(rule("Stale team rule.")) }])) + await RulesStore.update(root, (s) => { s.personal_learning = false }) + expect(parse(await fs.readFile(file, "utf8"))).toEqual([]) + }) +}) From f5a35a92059b590e1b7e07008c727972e1eede1c Mon Sep 17 00:00:00 2001 From: Sarav Date: Fri, 30 Oct 2026 12:06:21 +0530 Subject: [PATCH 17/20] =?UTF-8?q?fix:=20workspace=20rules=20=E2=80=94=20re?= =?UTF-8?q?view=20round=20six?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Remember" requests (the blocking finding): - A request kept for a card is removed only once saved, or when the save can never succeed (`SaveRefusedError`: the project's memory or training is full, a too-long memory); after a passing failure it stays and the next start saves it, on unanswered close and in recovery alike - Requests carry a per-process boot id: a request is this process's own only with its boot id, so a PID reused by a restart or another process no longer hides it; one older than a card can live is recovered too - No live request is evicted: with 50 requested cards open, a new request is saved without a card (`requests_full`); clearing an answered request is retried; recovery removal failures no longer stop the loop - `training_save` content that reads as a memory is saved to memory directly (an agent such as `analyst` may not have the memory tool) Sync: - Only the push lock's own timeout reads as "another sync is still sending"; any other failure to take it fails the run - Usage reporting checks scope ownership like push and pull: a batch built for another scope is neither sent nor marked reported - Reported usage pairs are kept for 30 days after their session's snapshot was last written (snapshots are never deleted) - A review row or `repo_identity` this version cannot read no longer fails the pull; pulled rules must pass the rule lint and be normalized text, else they are skipped and counted - Bootstrap decides what fits in the outbox inside the same locked write; held rules are said not to be offered again Cards, delivery and capture: - A card answered after its day is closed (a requested one saved), not answered; a card sent while rules sync is unavailable says why instead of promising a later send - A resumed session that was shown team rules is rebuilt without them while workspaces are off - `learn enable` mid-instance starts capture (never a second one beside a running capture) - The after-turn check reads its session's snapshot by name and the signals once; its `no_renderer` skip keeps its path Cleanups: the kill-switch parse is shared with the core flag (`killSwitchSet`), `prompt.ts` reuses `canonicalDirectory` and stamps the bindings cache with inode and ctime, recurrence loads stores through one helper, the subagent lookup is shared, 412 errors keep their detail, renames (`saveWithoutCard`, `processAlive`, `triggerDeps`), `rule_drafted` in the telemetry completeness test, docs. Each fix has a test that fails without it. Co-Authored-By: Claude Opus 5.5 --- docs/docs/configure/learn.md | 4 +- docs/docs/reference/telemetry.md | 2 +- packages/core/src/flag/flag.ts | 14 +- .../opencode/src/altimate/learn/capture.ts | 9 + .../opencode/src/altimate/learn/config.ts | 6 +- .../opencode/src/altimate/learn/delivery.ts | 45 ++++- .../opencode/src/altimate/learn/recurrence.ts | 58 +++--- packages/opencode/src/altimate/rules/api.ts | 28 ++- .../src/altimate/rules/from-history.ts | 13 +- .../opencode/src/altimate/rules/proposal.ts | 61 ++++-- .../opencode/src/altimate/rules/remember.ts | 96 ++++++--- .../opencode/src/altimate/rules/service.ts | 22 +-- packages/opencode/src/altimate/rules/store.ts | 17 +- packages/opencode/src/altimate/rules/sync.ts | 66 ++++--- .../opencode/src/altimate/rules/trigger.ts | 17 +- .../opencode/src/altimate/telemetry/index.ts | 2 +- packages/opencode/src/session/prompt.ts | 21 +- .../test/altimate/learn/delivery.test.ts | 21 ++ .../test/altimate/rules/rules.test.ts | 184 +++++++++++++++++- .../opencode/test/telemetry/telemetry.test.ts | 3 +- 20 files changed, 523 insertions(+), 166 deletions(-) diff --git a/docs/docs/configure/learn.md b/docs/docs/configure/learn.md index ed71f424ad..94264363c3 100644 --- a/docs/docs/configure/learn.md +++ b/docs/docs/configure/learn.md @@ -195,9 +195,9 @@ In a project linked to an Altimate workspace (`altimate-code link`), what you te - you correct a team rule the session was shown (the card proposes changing it, with the current text struck through); - you ask the agent to remember something as a rule (the card appears as soon as the agent saves it, always as a new rule: changing a team rule comes from correcting it). -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 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 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, or, if it exits unexpectedly, when the project is next opened) is saved the same way, so the request is not lost (unless that project's memory or training is already full: then it is logged). +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 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 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 the project's rules state 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. 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 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. **Rule or memory.** A personal preference ("I prefer CTEs") or a fact ("our warehouse is Snowflake") is saved to memory without review. When the wording does not say which, the card asks: Memory, Team rule, or Skip. diff --git a/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index 393a998374..b0f9369bb0 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -78,7 +78,7 @@ We collect the following categories of events: | `rule_proposal_answered` | A card was answered or closed (`send`, `approve`, `approve_anyway`, `edit`, `dismiss`, `save_local`, `save_memory`, `expired`, `closed`) — `ok` or why it did not go through (`busy`, `not_available`, `verification_flagged`, `invalid_text`, `refused` by the workspace, `failed`), what happened (`sent`, `queued`, `approved`, `approve_failed`, `approve_retry`, `target_changed`, `exists`, `dismissed`, `saved_memory_project`, `saved_memory_global`, `saved_local`, or `saved_unanswered` when a card the person asked for closed unanswered and was saved as before cards), wording edits so far, and time since the card was shown. No rule text. | | `rule_drafted` | One model call that drafted a proposal after a turn (it runs in the background) — the path (`shown_rule` or `recurrence`), `ok` or `failed`, input and output tokens, estimated cost and duration. No wording or correction text. | | `rule_proposal_skipped` | Corrections were checked after a turn and no card was shown — `card_open`, `no_renderer` (no attached client shows cards; the corrections are kept for a later check), `covered`, `no_draft`, `topic_dismissed`, `no_topic` or `failed`. The path checked (`shown_rule` or `recurrence`) is omitted when the check stopped before choosing one. | -| `rule_remember_routed` | Where a "remember …" save went — through `training_save` or the memory tool, to a card, a "Save as" card, memory, refused as not a one-line rule, or saved as before cards because learning/workspaces are off, the project is not linked, or no attached client shows cards for this project (or the session is a subagent's). No text. | +| `rule_remember_routed` | Where a "remember …" save went — through `training_save` or the memory tool, to a card, a "Save as" card, memory, refused as not a one-line rule, or saved as before cards because learning/workspaces are off, the project is not linked, or no attached client shows cards for this project (or the session is a subagent's), or too many requested cards are already open (`requests_full`). No text. | | `rules_sync` | One rules sync — what started it (`refresh`, `session_start`, `manual`, `card`, `cli`, `import`), `ok` (the pull worked and no proposal this run tried to send was kept queued; proposals beyond one push's batch can still be waiting, and proposals the workspace refused still count as `ok`, under `refused`), `failed` (a failed pull, attempted proposals kept queued, or a run that threw: reason `error`), `off` or `skipped` with a fixed reason code, duration, counts of rules, pending reviews, proposals sent, refused and still queued, review notices and usage items, and whether a retried "Approve now" failed (`approval_failed`). No rule text, error text, URLs or workspace names. | | `rules_delivered` | Rules added to a session's context — at the start, for a request, or for a file the agent touched — with the number of rules (and how many are team rules), their total length, the session's running total, and the token budget. No rule text or file paths. | | `rules_learning_changed` | The person turned learning from sessions on or off for a project, or back to the workspace's setting, with the resulting state and who decides it (`organization`, `you`, `workspace` or `default`). | diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 3ecf527eab..d85627f1b5 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -25,6 +25,15 @@ export function truthy(key: string) { return value === "true" || value === "1" } +// altimate_change start — the one fail-closed kill-switch parse, shared with readers that take an explicit environment +/** A kill switch fails closed: any value but empty, `0` or `false` turns the feature off, so `yes` or `on` cannot leave + * it running while the operator believes it is off. */ +export function killSwitchSet(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase() + return normalized !== undefined && normalized !== "" && normalized !== "0" && normalized !== "false" +} +// altimate_change end + /** An Effect `Config` boolean with the same documented-name-first rule, for the flags * that are resolved through the ambient ConfigProvider rather than `process.env`. The * documented value is read as a string and judged by `truthy`'s rule, so a set-but-invalid @@ -122,10 +131,7 @@ export const Flag = { // runtime `--` middleware can flip it between plugin activation and command execution. // Unrelated to OPENCODE_EXPERIMENTAL_WORKSPACES (multi-instance control plane). get ALTIMATE_DISABLE_WORKSPACE() { - // A kill switch fails closed: any value but empty, `0` or `false` turns workspaces off, so `yes` - // or `on` cannot leave them running while the operator believes they are off. - const value = env("ALTIMATE_DISABLE_WORKSPACE")?.trim().toLowerCase() - return value !== undefined && value !== "" && value !== "0" && value !== "false" + return killSwitchSet(env("ALTIMATE_DISABLE_WORKSPACE")) }, /** * Workspace precedence escape hatch, set by `--integrations=local`. When on, the diff --git a/packages/opencode/src/altimate/learn/capture.ts b/packages/opencode/src/altimate/learn/capture.ts index d53725ace8..ae2d2b3857 100644 --- a/packages/opencode/src/altimate/learn/capture.ts +++ b/packages/opencode/src/altimate/learn/capture.ts @@ -247,6 +247,13 @@ export class Capture { // Keep every active capture drainable when more than one instance is open. const active = new Set() +/** Instance directories whose capture runs, whoever started it. */ +const running = new Set() + +/** Whether capture runs for the instance at `directory`. */ +export function captureRunning(directory: string): boolean { + return running.has(directory) +} // altimate_change start — workspace rules: a per-project gate the rules service sets from the full learning decision // (the person's /rules choice can turn learning off while config still has learn.capture on). No gate: record. @@ -324,6 +331,7 @@ export async function startCapture( }, }) active.add(capture) + running.add(ctx.directory) const subscriptions: Array<() => void> = [] // altimate_change start — one stop path for disposal and for workspace rules // One stop, shared: a second caller (the instance disposer overlapping a settings change) waits for the @@ -335,6 +343,7 @@ export async function startCapture( await capture.flush() await flushWrites() active.delete(capture) + running.delete(ctx.directory) unregister() })()) const unregister = registerDisposer(async (directory) => { diff --git a/packages/opencode/src/altimate/learn/config.ts b/packages/opencode/src/altimate/learn/config.ts index ae3bc6a533..82ece552e7 100644 --- a/packages/opencode/src/altimate/learn/config.ts +++ b/packages/opencode/src/altimate/learn/config.ts @@ -1,4 +1,5 @@ // altimate_change - new file +import { killSwitchSet } from "@opencode-ai/core/flag/flag" interface LearnConfig { enabled?: boolean @@ -14,10 +15,9 @@ export function booleanFlag(value: string | undefined): boolean | undefined { return undefined } -/** Workspaces are on unless `ALTIMATE_DISABLE_WORKSPACE` is set to anything but empty, `0` or `false`. */ +/** Workspaces are on unless the `ALTIMATE_DISABLE_WORKSPACE` kill switch is set (`Flag`'s parse, with `env` explicit). */ export function workspacesOn(env: NodeJS.ProcessEnv = process.env): boolean { - const disabled = env["ALTIMATE_DISABLE_WORKSPACE"]?.trim().toLowerCase() - return disabled === undefined || disabled === "" || disabled === "0" || disabled === "false" + return !killSwitchSet(env["ALTIMATE_DISABLE_WORKSPACE"]) } /** The global switch defaults on; env overrides config in both directions. */ diff --git a/packages/opencode/src/altimate/learn/delivery.ts b/packages/opencode/src/altimate/learn/delivery.ts index 722bf46a7f..05b9ee938f 100644 --- a/packages/opencode/src/altimate/learn/delivery.ts +++ b/packages/opencode/src/altimate/learn/delivery.ts @@ -89,20 +89,42 @@ export type ShownSnapshot = { shown: { name: string; id: string; textHash: string; tier: Shown["tier"]; at: string }[] } -/** The snapshot file names (`.json`) present now, all of them: a session whose file is gone can never be - * read again. */ -export async function snapshotFiles(root: string): Promise> { +/** The snapshot files present now (`.json`), all of them, with when each was last written (ms). */ +export async function snapshotFiles(root: string): Promise> { const dir = path.join(root, ".altimate-code", "learn", ".sessions") await SafeFS.assertSafePath(root, dir) - return new Set(await fs.readdir(dir).catch((error: NodeJS.ErrnoException) => { + const names = await fs.readdir(dir).catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return [] throw error - })) + }) + return new Map(await Promise.all(names.map(async (name) => + [name, await fs.lstat(path.join(dir, name)).then((stat) => stat.mtimeMs, () => 0)] as const))) } /** The snapshot file name of `session`. */ export const snapshotFile = (session: string) => Store.sha256(session) + ".json" +async function readSnapshotFile(root: string, file: string): Promise { + const state = State.parse(JSON.parse((await read(root, file)) ?? "")) + if (path.basename(file) !== Store.sha256(state.session) + ".json") throw new Error("Learn session state belongs to another session") + return { session: state.session, shown: state.shown.map(({ name, lesson, tier, at }) => ({ + name, id: lesson.id, textHash: lessonTextHash(lesson.text), tier, at, + })) } +} + +/** One session's snapshot, or undefined when it has none (or it cannot be read). */ +export async function readShownSnapshot(root: string, session: string): Promise { + const dir = path.join(root, ".altimate-code", "learn", ".sessions") + await SafeFS.assertSafePath(root, dir) + const file = path.join(dir, snapshotFile(session)) + if ((await read(root, file)) === undefined) return undefined + return readSnapshotFile(root, file).catch((error) => { + if (error instanceof SafeFS.UnsafeLearnPathError) throw error + log.warn("learn session snapshot skipped", { file, error }) + return undefined + }) +} + export async function readShownSnapshots(root: string, options: { limit?: number } = {}) { const dir = path.join(root, ".altimate-code", "learn", ".sessions") const coverage = { sessions_analyzed: 0, sessions_skipped_malformed: 0, truncated: false } @@ -120,11 +142,7 @@ export async function readShownSnapshots(root: string, options: { limit?: number coverage.truncated = files.length > limit for (const { file } of files.sort((a, b) => b.mtime - a.mtime || a.file.localeCompare(b.file)).slice(0, limit)) { try { - const state = State.parse(JSON.parse((await read(root, file)) ?? "")) - if (path.basename(file) !== Store.sha256(state.session) + ".json") throw new Error("Learn session state belongs to another session") - snapshots.push({ session: state.session, shown: state.shown.map(({ name, lesson, tier, at }) => ({ - name, id: lesson.id, textHash: lessonTextHash(lesson.text), tier, at, - })) }) + snapshots.push(await readSnapshotFile(root, file)) } catch (error) { if (error instanceof SafeFS.UnsafeLearnPathError) throw error coverage.sessions_skipped_malformed++ @@ -198,6 +216,13 @@ export class Delivery { // valid snapshots retain their byte-identical prefix across resume and approved edits. const grandfathered = new Map[]>() for (const { name, lesson } of state.shown) { + // altimate_change start — workspace rules: a session that was shown team rules is rebuilt without them while + // workspaces are off, so a resumed session does not keep delivering them from its cached section and notes + if (name === Store.WORKSPACE_RULES_STORE && !workspacesOn()) { + log.info("learn session snapshot rebuilt without team rules", { session }) + return undefined + } + // altimate_change end if (lesson.text.length > MAX_TEXT && !grandfathered.has(name)) grandfathered.set(name, await Store.grandfathered(this.root, name, { migrate: false }).catch(() => [])) const safe = sanitize(name, lesson, grandfathered.get(name)) diff --git a/packages/opencode/src/altimate/learn/recurrence.ts b/packages/opencode/src/altimate/learn/recurrence.ts index e330fc138f..68ed0b2add 100644 --- a/packages/opencode/src/altimate/learn/recurrence.ts +++ b/packages/opencode/src/altimate/learn/recurrence.ts @@ -73,27 +73,36 @@ export function unmatchedCorrectionTopics(signals: readonly Signal[], lessons: r return recurringTopics(topics) } -/** Every store's approved lessons (stores whose names are invalid, or unreadable, are skipped). */ -export async function approvedLessons(root: string): Promise { +/** The learn store names in `root`, sorted: directories with a valid name (only `name`, when given). */ +async function storeNames(root: string, name?: string): Promise { const dir = path.join(root, ".altimate-code", "learn") await SafeFS.assertSafePath(root, dir) const entries = await fs.readdir(dir, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return [] throw error }) - const lessons: NamedLesson[] = [] - for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { - if (!entry.isDirectory()) continue - try { validateName(entry.name) } catch { continue } - const file = Store.paths(root, entry.name).approved - try { - await SafeFS.assertSafePath(root, file) - for (const lesson of parse(await fs.readFile(file, "utf8"))) lessons.push({ name: entry.name, lesson }) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") log.warn("learn recurrence approved store skipped", { name: entry.name, error }) - } + return entries.sort((a, b) => a.name.localeCompare(b.name)).flatMap((entry) => { + if (!entry.isDirectory() || (name !== undefined && entry.name !== name)) return [] + try { validateName(entry.name) } catch { return [] } + return [entry.name] + }) +} + +/** One store's approved lessons; an unreadable store is skipped. */ +async function storeLessons(root: string, name: string): Promise { + const file = Store.paths(root, name).approved + try { + await SafeFS.assertSafePath(root, file) + return parse(await fs.readFile(file, "utf8")).map((lesson) => ({ name, lesson })) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") log.warn("learn recurrence approved store skipped", { name, error }) + return [] } - return lessons +} + +/** Every store's approved lessons (stores whose names are invalid, or unreadable, are skipped). */ +export async function approvedLessons(root: string): Promise { + return (await Promise.all((await storeNames(root)).map((name) => storeLessons(root, name)))).flat() } export function attribute(signals: readonly Signal[], snapshots: readonly ShownSnapshot[], lessons: readonly NamedLesson[]) { @@ -166,27 +175,12 @@ export async function analyzeRecurrence(root: string, options: { limit?: number; const { coverage } = read const snapshots = options.name === undefined ? read.snapshots : read.snapshots.map((snapshot) => ({ ...snapshot, shown: snapshot.shown.filter((entry) => entry.name === options.name) })) - const dir = path.join(root, ".altimate-code", "learn") - await SafeFS.assertSafePath(root, dir) - const entries = await fs.readdir(dir, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => { - if (error.code === "ENOENT") return [] - throw error - }) const lessons: NamedLesson[] = [] const signals: Signal[] = [] const sessions = new Set(snapshots.map((snapshot) => snapshot.session)) - for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { - if (!entry.isDirectory()) continue - try { validateName(entry.name) } catch { continue } - if (options.name !== undefined && entry.name !== options.name) continue - if (sessions.size) signals.push(...(await readSignalsSnapshot(root, entry.name)).filter((signal) => sessions.has(signal.sessionID))) - const file = Store.paths(root, entry.name).approved - try { - await SafeFS.assertSafePath(root, file) - for (const lesson of parse(await fs.readFile(file, "utf8"))) lessons.push({ name: entry.name, lesson }) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") log.warn("learn recurrence approved store skipped", { name: entry.name, error }) - } + for (const name of await storeNames(root, options.name)) { + if (sessions.size) signals.push(...(await readSignalsSnapshot(root, name)).filter((signal) => sessions.has(signal.sessionID))) + lessons.push(...(await storeLessons(root, name))) } return { ...attribute(signals, snapshots, lessons), coverage } } diff --git a/packages/opencode/src/altimate/rules/api.ts b/packages/opencode/src/altimate/rules/api.ts index a884b53ccc..147df737c4 100644 --- a/packages/opencode/src/altimate/rules/api.ts +++ b/packages/opencode/src/altimate/rules/api.ts @@ -8,6 +8,7 @@ // call as `transient` (the server may already have applied it, so the client keeps what it sent and retries). // Pulled rules are parsed one by one: an invalid rule is skipped, not a reason to freeze every rule. import z from "zod" +import { lint, normalizeText } from "../learn/curator" import { altimateRequest, ForbiddenError, NotFoundError, WorkspaceApiError, type ActAs } from "../workspace/api-client" export const Settings = z.object({ @@ -46,18 +47,24 @@ export type Reviewed = z.infer export const SyncResponse = z.object({ unchanged: z.boolean(), revision: z.number().int(), - repo_identity: z.string(), + // Not used by the client: never a reason to fail a pull. + repo_identity: z.string().nullable().optional(), workspace_name: z.string(), owner_name: z.string().nullable().optional(), is_owner: z.boolean(), settings: Settings, pending_count: z.number().int(), - // Each rule is validated on its own in `sync` below. + // Each rule and each review is validated on its own in `sync` below: one this version cannot read is skipped, not a + // failed pull. rules: z.array(z.unknown()), - my_reviewed: z.array(Reviewed), + my_reviewed: z.array(z.unknown()), cursor: z.string(), }) -export type SyncResponse = Omit, "rules"> & { rules: RuleOut[]; skipped: number } +export type SyncResponse = Omit, "rules" | "my_reviewed"> & { + rules: RuleOut[] + my_reviewed: Reviewed[] + skipped: number +} export const ORIGINS = ["session", "explicit", "bootstrap", "import_reviews"] as const export type Origin = (typeof ORIGINS)[number] @@ -157,7 +164,7 @@ export function classify(error: unknown): RulesApiError { } const named = error as { name?: string; detail?: Record } | undefined if (named?.name === "ConflictError") return new RulesApiError("conflict", messageOf(named.detail, message), codeOf(named.detail), 409, named.detail) - if (named?.name === "PreconditionFailedError") return new RulesApiError("conflict", messageOf(named.detail, message), codeOf(named.detail), 412) + if (named?.name === "PreconditionFailedError") return new RulesApiError("conflict", messageOf(named.detail, message), codeOf(named.detail), 412, named.detail) return new RulesApiError("transient", message) } @@ -172,11 +179,20 @@ async function call(schema: z.ZodType, actAs: ActAs, method: string, datam export async function sync(actAs: ActAs, datamateId: number, body: { repo_remote: string; known_revision?: number; reviewed_since?: string }): Promise { const raw = await call(SyncResponse, actAs, "POST", datamateId, "/sync", body) + // A rule goes into every teammate's prompt: like a proposal or a local promote, it must pass the rule lint (one line, + // no commands, links or secrets; the length limit is the server's) and be normalized text. One that fails is skipped + // and counted, and the pull keeps no revision, so it is fetched again once fixed. const rules = raw.rules.flatMap((rule) => { const parsed = RuleOut.safeParse(rule) + if (!parsed.success) return [] + const text = parsed.data.text + return lint(text, { grandfathered: true }) || normalizeText(text).trim() !== text ? [] : [parsed.data] + }) + const reviewed = raw.my_reviewed.flatMap((row) => { + const parsed = Reviewed.safeParse(row) return parsed.success ? [parsed.data] : [] }) - return { ...raw, rules, skipped: raw.rules.length - rules.length } + return { ...raw, rules, my_reviewed: reviewed, skipped: raw.rules.length - rules.length } } export async function propose(actAs: ActAs, datamateId: number, body: { repo_remote: string; items: ProposalItem[] }) { diff --git a/packages/opencode/src/altimate/rules/from-history.ts b/packages/opencode/src/altimate/rules/from-history.ts index e930e3e888..24882c777c 100644 --- a/packages/opencode/src/altimate/rules/from-history.ts +++ b/packages/opencode/src/altimate/rules/from-history.ts @@ -96,19 +96,20 @@ export async function sendStaged(input: { before: Set | undefined }): Promise<{ sent: number; queued: number; refused: number; lost: number; held: number; message: string }> { const { owner, name } = input.staged.workspace - // Only what the outbox has room for is queued; the rest stays in the local candidate (not sent for review). - const room = Math.max(0, RulesStore.MAX_OUTBOX - (await RulesStore.read(input.root)).outbox.length) - const items = input.staged.items.slice(0, room) - const held = input.staged.items.length - items.length - const heldNote = held ? `; ${held} not sent: the queue of proposals waiting to be sent is full (${RulesStore.MAX_OUTBOX}), so they stay in the local candidate` : "" + // Only what the outbox has room for is queued, decided in the same write; the rest stays in the local candidate. let ids: string[] try { - ids = await RulesSync.enqueueMany(input.root, items.map((item) => ({ item })), input.staged.scope) + ids = await RulesSync.enqueueMany(input.root, input.staged.items.map((item) => ({ item })), input.staged.scope, { upTo: true }) } catch (error) { if (!(error instanceof RulesSync.ScopeChangedError)) throw error const message = `Nothing sent: the account or workspace changed since these rules were listed. The local candidate was kept: \`learn show\`.` return { sent: 0, queued: 0, refused: 0, lost: 0, held: input.staged.items.length, message } } + const held = input.staged.items.length - ids.length + // Not offered again by a later run (they are in the candidate it starts from): said, with the way to keep them. + const heldNote = held + ? `; ${held} not sent: the queue of proposals waiting to be sent is full (${RulesStore.MAX_OUTBOX}). They stay in the local candidate and are not offered for review again: keep them with \`learn promote\`` + : "" // A push sends the oldest batch of the outbox, which can hold older proposals ahead of these: keep sending while these // are still queued and each run moves the outbox along. Stops when a run is not ready, keeps something back with an // error, or sends nothing; bounded by the batches a full outbox needs. diff --git a/packages/opencode/src/altimate/rules/proposal.ts b/packages/opencode/src/altimate/rules/proposal.ts index acff41ca4b..0c5431d2bd 100644 --- a/packages/opencode/src/altimate/rules/proposal.ts +++ b/packages/opencode/src/altimate/rules/proposal.ts @@ -80,6 +80,15 @@ function close(id: string, entry: Pending, message: string, why: "expired" | "cl /** What a "remember" card keeps on disk so the request survives the process (`RulesRemember.recoverRequests`). */ export type RequestRecord = Pick +/** A save that can never succeed as asked (the project's memory or training is full, the text is too long): retrying + * it is pointless, so its request is not kept for recovery. Any other failure is kept and retried. */ +export class SaveRefusedError extends Error { + constructor(message: string) { + super(message) + this.name = "SaveRefusedError" + } +} + async function persistRequest(entry: Pending, request: RequestRecord) { const record: RulesStore.RememberRequest = { ...request, @@ -87,24 +96,39 @@ async function persistRequest(entry: Pending, request: RequestRecord) { text: entry.card.text, directory: entry.directory, pid: process.pid, + boot: RulesStore.PROCESS_BOOT, at: new Date().toISOString(), } - // Best effort: the card and its in-memory fallback work without it; only a crash would then lose the request. + // Best effort: the card and its in-memory fallback work without it; only a crash would then lose the request. Never + // past the limit: a live record is not evicted (`proposeFor` saves without a card when it is full). + let kept = false await RulesStore.update(entry.root, (s) => { - s.remember_requests = [...s.remember_requests.filter((r) => r.card !== record.card), record] + const others = s.remember_requests.filter((r) => r.card !== record.card) + if (others.length >= RulesStore.MAX_REMEMBER_REQUESTS) return + s.remember_requests = [...others, record] + kept = true }).then( - () => void (entry.persisted = true), + () => void (entry.persisted = kept), (error) => log.warn("remember request not kept on disk", { card: record.card, error }), ) } -/** The card was answered, or its fallback ran: its request no longer needs keeping. */ +/** The card was answered, or its request was saved: it no longer needs keeping. Retried, since a record left behind + * would be saved again by recovery; removing it twice is harmless. */ async function forgetRequest(id: string, entry: Pending) { if (!entry.persisted) return - entry.persisted = false - await RulesStore.update(entry.root, (s) => { - s.remember_requests = s.remember_requests.filter((r) => r.card !== id) - }).catch((error) => log.warn("remember request not cleared", { id, error })) + for (let attempt = 1; attempt <= 3; attempt++) { + try { + await RulesStore.update(entry.root, (s) => { + s.remember_requests = s.remember_requests.filter((r) => r.card !== id) + }) + entry.persisted = false + return + } catch (error) { + log.warn("remember request not cleared", { id, attempt, error }) + await new Promise((resolve) => setTimeout(resolve, 200 * attempt)) + } + } } /** A closed card nobody answered: save a requested one as before cards, record it, tell its clients. */ @@ -113,16 +137,18 @@ async function settle(id: string, entry: Pending, message: string, why: "expired // clients then learn it is gone from their next list or reply (`not_open`). if (here === entry.directory) void publish(RuleEvents.Event.Replied, { id, sessionID: entry.card.sessionID, action: "expired", message }) - // A "remember" the person asked for is saved as it was before cards. That save can still fail (the project's memory - // or training is full): recorded as a failed close, not as saved. + // A "remember" the person asked for is saved as it was before cards. That save can still fail: recorded as a failed + // close. Its kept request is cleared only once saved, or when the save can never succeed (`SaveRefusedError`); + // after any other failure it stays, and the next process opening the project saves it (`recoverRequests`). + let refused = false const saved = entry.unanswered ? await entry.unanswered(entry.card.text).then(() => true, (error) => { + refused = error instanceof SaveRefusedError log.warn("unanswered card not saved", { id, error }) return false }) : undefined - // Saved, or failed for good (the project's memory or training is full): either way nothing is left to recover. - await forgetRequest(id, entry) + if (saved !== false || refused) await forgetRequest(id, entry) RulesTelemetry.answered({ card: entry.card, trigger: entry.trigger, @@ -332,6 +358,11 @@ export async function reply(id: string, input: { action: ReplyAction; text?: str const entry = pending.get(id) if (!entry || (directory !== undefined && entry.directory !== directory)) return { ok: false, code: "not_open", error: "This proposal is no longer open." } + // Past its day: closed now (a requested one is saved as before cards), not answered. + if (!entry.busy && Date.now() - entry.at > CARD_TTL_MS) { + await close(id, entry, "This proposal expired.", "expired", entry.directory) + return { ok: false, code: "not_open", error: "This proposal expired." } + } const track = (status: "ok" | ReplyFailure, outcome?: AnswerOutcome) => { if (status !== "not_open") RulesTelemetry.answered({ card: entry.card, trigger: entry.trigger, action: input.action, status, outcome, edits: entry.edits, shownAt: entry.at }) @@ -472,7 +503,11 @@ async function act(entry: Pending, action: Exclude): Promis const sent = RulesSync.outcomeOf(await RulesStore.read(entry.root), report, outboxId) const owner = card.workspace?.owner_name ?? "the workspace owner" const ws = card.workspace?.name ?? "the workspace" - if (sent.status === "queued") { + if (sent.status === "queued" && report && report.context.status !== "ready") { + // Not sent, and not until rules sync is available again (learning or workspaces off, signed out, not linked). + message = `Kept queued: rules sync is not available (${report.context.reason}); it is sent on a later sync once it is.` + outcome = "queued" + } 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.` diff --git a/packages/opencode/src/altimate/rules/remember.ts b/packages/opencode/src/altimate/rules/remember.ts index add449096f..7d7fc7c1ba 100644 --- a/packages/opencode/src/altimate/rules/remember.ts +++ b/packages/opencode/src/altimate/rules/remember.ts @@ -65,43 +65,64 @@ export async function proposeTeamRule( const learn = await RulesService.effectiveLearn((await Config.get()).learn, root, Instance.directory) // A subagent's session is not the one a client shows: its card would never be seen. Looked up only when a card // would otherwise be raised. - const subagent = () => - import("@/session") - .then(async ({ Session }) => { - const { SessionID } = await import("@/session/schema") - return !!(await Session.get(SessionID.make(sessionID))).parentID - }) - .catch(() => false) + const subagent = () => subagentSession(sessionID) // What the tool meant to write, kept on disk while the card is open; saved as before cards if it closes unanswered. const request = { session: sessionID, via, ...(memory ? { memory } : {}), ...(training ? { training } : {}) } // Bound to this instance, so it saves into this project whenever the card closes unanswered. - const unanswered = Instance.bind((text: string) => saveAsBefore({ ...request, text })) - return proposeFor({ root, directory: Instance.directory, learn, content, sessionID, said, via, memory, subagent, unanswered, request }) + const unanswered = Instance.bind((text: string) => saveWithoutCard({ ...request, text })) + // A `training_save` that reads as a memory is saved to memory here: the calling agent may not have the memory tool. + const saveMemory = Instance.bind(async (text: string) => { + const [{ MemoryStore }, { RuleClassify }, { RuleProposal }] = await Promise.all([ + import("../../memory/store"), + import("./classify"), + import("./proposal"), + ]) + const scope = RuleClassify.memoryScope(text) + const now = new Date().toISOString() + await MemoryStore.write({ id: `notes/${RuleProposal.slug(text)}`, scope, tags: ["remembered"], created: now, updated: now, content: text }, { sessionID }) + return scope + }) + return proposeFor({ root, directory: Instance.directory, learn, content, sessionID, said, via, memory, subagent, unanswered, request, saveMemory }) +} + +/** Whether `sessionID` is a subagent's (it has a parent): no client shows its cards. Unknown counts as not. */ +export async function subagentSession(sessionID: string): Promise { + const [{ Session }, { SessionID }] = await Promise.all([import("@/session"), import("@/session/schema")]) + return Session.get(SessionID.make(sessionID)).then((session) => !!session.parentID, () => false) } type Request = Pick /** Save a "remember" as the tool would have without a card: the memory tool's block to project memory, or - * `training_save`'s rule to project training (within its per-kind limit). Runs in the asking project's instance. */ -export async function saveAsBefore(request: Request) { + * `training_save`'s rule to project training. A save that can never succeed (a limit the tool enforces) throws + * `SaveRefusedError`; anything else is worth retrying. Runs in the asking project's instance. */ +export async function saveWithoutCard(request: Request) { const { text, session: sessionID, memory, training } = request const now = new Date().toISOString() + const { SaveRefusedError, slug } = await import("./proposal") if (request.via === "memory" && memory) { - const { MemoryStore } = await import("../../memory/store") + const [{ MemoryStore, isExpired }, { MEMORY_MAX_BLOCK_SIZE, MEMORY_MAX_BLOCKS_PER_SCOPE }] = await Promise.all([ + import("../../memory/store"), + import("../../memory/types"), + ]) + // The memory tool's own limits, checked first so that only they read as refused. + if (text.length > MEMORY_MAX_BLOCK_SIZE) throw new SaveRefusedError(`longer than a memory block allows (${MEMORY_MAX_BLOCK_SIZE})`) + const blocks = await MemoryStore.list("project", { includeExpired: true }) + if (!blocks.some((b) => b.id === memory.id) && blocks.filter((b) => !isExpired(b)).length >= MEMORY_MAX_BLOCKS_PER_SCOPE) + throw new SaveRefusedError(`project memory already holds ${MEMORY_MAX_BLOCKS_PER_SCOPE} blocks`) await MemoryStore.write({ id: memory.id, scope: "project", tags: memory.tags, created: now, updated: now, content: text }, { sessionID }) return } - const [{ TrainingStore }, { RuleProposal }, { TRAINING_MAX_PATTERNS_PER_KIND }] = await Promise.all([ + const [{ TrainingStore }, { TRAINING_MAX_PATTERNS_PER_KIND }] = await Promise.all([ import("../training"), - import("./proposal"), import("../training/types"), ]) - const name = training?.name ?? RuleProposal.slug(text) + const name = training?.name ?? slug(text) // The same limit `training_save` applies: a new rule past it is not saved (an update of an existing one is). if (!(await TrainingStore.get("project", "rule", name))) { const count = await TrainingStore.count({ kind: "rule", scope: "project" }) if (count.rule >= TRAINING_MAX_PATTERNS_PER_KIND) - throw new Error(`project training already holds ${count.rule} rules (limit ${TRAINING_MAX_PATTERNS_PER_KIND})`) + throw new SaveRefusedError(`project training already holds ${count.rule} rules (limit ${TRAINING_MAX_PATTERNS_PER_KIND})`) } await TrainingStore.save({ kind: "rule", @@ -115,7 +136,7 @@ export async function saveAsBefore(request: Request) { } /** Whether process `pid` is running (a process this one may not signal still counts). */ -function alive(pid: number) { +function processAlive(pid: number) { try { process.kill(pid, 0) return true @@ -125,27 +146,38 @@ function alive(pid: number) { } /** - * Save the "remember" requests of `directory` whose card died with its process (a crash, a kill): the card can no longer - * be answered or close on its own, so the request is saved as before cards. Each is claimed in one locked write first, - * so two processes starting together save it once. Runs in that project's instance; never throws. + * Save the "remember" requests of `directory` whose card died with its process (a crash, a kill), or whose card closed + * while its save failed: the request is saved as before cards. A request is this process's own while it carries this + * process's boot id. Another boot's request is orphaned when its process is gone, its PID is now this process's (a + * restart reused it), or it is older than a card can live (another process reused the PID). Each is claimed in one + * locked write first, so two processes starting together save it once. It is removed once saved, or when its save can + * never succeed; after any other failure it stays for the next start. Runs in that project's instance; never throws. */ export async function recoverRequests(root: string, directory: string) { - const { RulesStore } = await import("./store") - const orphaned = (r: import("./store").RememberRequest) => r.directory === directory && r.pid !== process.pid && !alive(r.pid) + const [{ RulesStore }, { CARD_TTL_MS, SaveRefusedError }] = await Promise.all([import("./store"), import("./proposal")]) + const now = Date.now() + const orphaned = (r: import("./store").RememberRequest) => + r.directory === directory && r.boot !== RulesStore.PROCESS_BOOT && + (r.pid === process.pid || !processAlive(r.pid) || !(now - Date.parse(r.at) < CARD_TTL_MS)) if (!(await RulesStore.read(root)).remember_requests.some(orphaned)) return const claimed: import("./store").RememberRequest[] = [] await RulesStore.update(root, (s) => { for (const r of s.remember_requests) if (orphaned(r)) { - r.pid = process.pid + Object.assign(r, { pid: process.pid, boot: RulesStore.PROCESS_BOOT }) claimed.push({ ...r }) } }) for (const r of claimed) { - await saveAsBefore(r).catch((error) => log.warn("recovered remember request not saved", { card: r.card, error })) + const done = await saveWithoutCard(r).then(() => true, (error) => { + log.warn("recovered remember request not saved", { card: r.card, error }) + return error instanceof SaveRefusedError + }) + // Kept: claimed by this boot, so the next start (another boot) tries it again. + if (!done) continue await RulesStore.update(root, (s) => { s.remember_requests = s.remember_requests.filter((x) => x.card !== r.card) - }) + }).catch((error) => log.warn("recovered remember request not cleared", { card: r.card, error })) } } @@ -166,6 +198,9 @@ export async function proposeFor(input: { unanswered?: (text: string) => Promise /** With `unanswered`: what to keep on disk while the card is open (`RuleProposal.ask`). */ request?: import("./proposal").RequestRecord + /** Saves `training_save` content that reads as a memory to memory, returning its scope. Without it the agent is told + * to use the memory tool. */ + saveMemory?: (text: string) => Promise<"global" | "project"> }) { const { root, learn, content, sessionID, said } = input const via = input.via ?? "training" @@ -187,6 +222,9 @@ export async function proposeFor(input: { const { RuleProposal } = await import("./proposal") if (!RuleProposal.rendererAttached(input.directory)) return void RulesTelemetry.routed(sessionID, via, "no_renderer") if (typeof input.subagent === "function" ? await input.subagent() : input.subagent) return void RulesTelemetry.routed(sessionID, via, "no_renderer") + // Too many requested cards open to keep another on disk: saved without a card now, rather than a card a crash could lose. + if (input.request && state.remember_requests.length >= RulesStore.MAX_REMEMBER_REQUESTS) + return void RulesTelemetry.routed(sessionID, via, "requests_full") const result = await RuleTrigger.explicit({ sessionID, root, directory: input.directory, learn, text: content, said, memory: input.memory, unanswered: input.unanswered, request: input.request }) RulesTelemetry.routed( sessionID, @@ -198,6 +236,14 @@ export async function proposeFor(input: { // requested rule that is not one line is sent back to be restated: saved as project memory, it would reach // teammates through the workspace's memory without review. if (via === "memory" && (result.status === "memory" || (result.status === "invalid" && result.kind !== "rule"))) return undefined + if (result.status === "memory" && input.saveMemory) { + const scope = await input.saveMemory(content) + return { + title: "Saved to memory", + metadata: { ...metadata, action: "memory", scope }, + output: `This reads as ${result.cue}, not a team rule, so it was saved to ${scope === "global" ? "your memory (all your projects)" : "this project's memory"} instead. Tell the person it was saved to memory; do not save it again.`, + } + } if (result.status === "memory") return { title: "Not a team rule", diff --git a/packages/opencode/src/altimate/rules/service.ts b/packages/opencode/src/altimate/rules/service.ts index c537156144..e3db7d5e8a 100644 --- a/packages/opencode/src/altimate/rules/service.ts +++ b/packages/opencode/src/altimate/rules/service.ts @@ -61,7 +61,7 @@ async function linked(root: string, directory: string): Promise { return !!(await readLocalBinding(directory).catch(() => null)) } -function deps(ctx: InstanceContext, model?: string): RuleTrigger.TriggerDeps { +function triggerDeps(ctx: InstanceContext, model?: string): RuleTrigger.TriggerDeps { return { source: async (sessionID) => (await import("../learn/session-reflect")).sourceFromSession(sessionID, ctx), generate: async (source, onUsage) => { @@ -99,12 +99,6 @@ export async function sessionStart(root: string, directory: string, loaded: Lear await RulesSync.sessionStart(root, directory, learn, { startIfDue: hasState }) } -/** Whether `sessionID` is a subagent's (it has a parent). Unknown counts as not. */ -async function subagent(sessionID: string): Promise { - const [{ Session }, { SessionID }] = await Promise.all([import("@/session"), import("@/session/schema")]) - return Session.get(SessionID.make(sessionID)).then((session) => !!session.parentID, () => false) -} - export async function start(ctx: InstanceContext): Promise { try { const root = await fs.realpath(ctx.worktree !== "/" ? ctx.worktree : ctx.directory) @@ -128,9 +122,13 @@ export async function start(ctx: InstanceContext): Promise { } const followLearning = async (learn: LearnConfig) => { if (disposed || starting) return - if (captureEnabled(learn)) return void (await gate()) - const on = RuleTrigger.learningEnabled(learn, await RulesStore.read(root)) - if (on && !stopCapture) { + // Capture enabled in config is bootstrap's when it was on at startup; turned on later (`learn enable`, a project + // file edit) nothing runs it, so it is started here, like capture the workspace turned on. Never a second capture + // beside one already running; with learning off, a capture this service did not start is left to the gate. + const on = captureEnabled(learn) || RuleTrigger.learningEnabled(learn, await RulesStore.read(root)) + const running = on && (await import("../learn/capture")).captureRunning(ctx.directory) + if (on && (stopCapture || running)) return void (await gate()) + if (on) { starting = true try { await gate() @@ -175,10 +173,10 @@ export async function start(ctx: InstanceContext): Promise { if (!RulesSync.rulesEnabled(learn)) return // A subagent's session is not one a client shows: a card raised there would never be seen, and its corrections // would be marked handled. They are left unprocessed. - if (await subagent(sessionID)) return + if (await import("./remember").then((m) => m.RulesRemember.subagentSession(sessionID))) return const { flushCapture } = await import("../learn/capture") await flushCapture() - await RuleTrigger.afterTurn({ sessionID, root, directory: ctx.directory, learn, deps: deps(ctx, learn?.model) }) + await RuleTrigger.afterTurn({ sessionID, root, directory: ctx.directory, learn, deps: triggerDeps(ctx, learn?.model) }) }) // A settings change (`/rules` learning on/off) starts or stops capture now, not at the next refresh. const onChanged = Instance.bind(async () => { diff --git a/packages/opencode/src/altimate/rules/store.ts b/packages/opencode/src/altimate/rules/store.ts index 89c76b2209..deb4d85071 100644 --- a/packages/opencode/src/altimate/rules/store.ts +++ b/packages/opencode/src/altimate/rules/store.ts @@ -12,6 +12,7 @@ // // Writes take the learn lock (delivery reads the same directory) and are atomic. import { constants } from "node:fs" +import { randomUUID } from "node:crypto" import fs from "node:fs/promises" import path from "node:path" import z from "zod" @@ -105,13 +106,20 @@ export const RememberRequest = z.object({ memory: z.object({ id: z.string(), tags: z.array(z.string()) }).optional(), /** `training_save`'s entry (`via: "training"`). */ training: z.object({ name: z.string(), source: z.string().optional(), citations: z.array(z.unknown()).optional() }).optional(), - /** The process that shows the card: while it lives, its own fallback saves the request. */ + /** The process that shows the card, and that process's boot id (`PROCESS_BOOT`): a PID can be reused, a boot id is + * not. While that process lives, its own fallback saves the request. */ pid: z.number().int(), + boot: z.string().default(""), at: z.string(), }) export type RememberRequest = z.infer -/** Requests kept at most; the oldest beyond it are dropped (each is also bounded by its card's day). */ -const MAX_REMEMBER_REQUESTS = 50 +/** Requests kept at most. A new card past it is not raised (the tool saves without a card): a live record is never + * evicted. */ +export const MAX_REMEMBER_REQUESTS = 50 + +/** This process's id for `RememberRequest.boot`: one per process, whichever import path loads this module. */ +const BOOT = Symbol.for("altimate.rules.process.boot") +export const PROCESS_BOOT = ((globalThis as Record)[BOOT] ??= randomUUID()) as string export const State = z.object({ version: z.literal(1), @@ -166,6 +174,8 @@ export const State = z.object({ /** A usage report sent but not yet acknowledged: resent with the same `batch_id` so the server counts it once. */ pending_usage: z.object({ batch_id: z.string(), + /** The account and workspace it was built for: only a run for the same scope sends or acknowledges it. */ + scope: Scope.optional(), items: z.array(z.object({ public_id: z.string(), applied: z.number().int(), helpful: z.number().int(), harmful: z.number().int(), corrected_again: z.number().int() })), pairs: z.array(z.string()), corrected: z.record(z.string(), z.number().int()), @@ -259,7 +269,6 @@ export async function update(root: string, change: (state: State) => State | voi next.notices = next.notices.slice(-MAX_NOTICES) next.sent = capSent(next.sent) next.processed_signals = next.processed_signals.slice(-MAX_TRACKED) - next.remember_requests = next.remember_requests.slice(-MAX_REMEMBER_REQUESTS) const now = Date.now() next.dismissed = next.dismissed.filter((d) => Date.parse(d.until) > now) if (next.scope_drop && !scopeDropShown(next.scope_drop, now)) delete next.scope_drop diff --git a/packages/opencode/src/altimate/rules/sync.ts b/packages/opencode/src/altimate/rules/sync.ts index 609e948964..01fba2b003 100644 --- a/packages/opencode/src/altimate/rules/sync.ts +++ b/packages/opencode/src/altimate/rules/sync.ts @@ -31,6 +31,8 @@ export const REFRESH_MS = 5 * 60_000 export const SESSION_WAIT_MS = 2_000 export const MAX_PROPOSALS = 50 const MAX_USAGE_DELTA = 50 +/** How long a reported (session, rule) pair is remembered after its session's snapshot was last written. */ +const USAGE_PAIR_MS = 30 * 24 * 60 * 60_000 /** The server's per-batch limit on usage items (`MAX_USAGE_ITEMS`). */ const MAX_USAGE_ITEMS = 500 @@ -148,7 +150,7 @@ export async function pull(root: string, ctx: Ready): Promise { state.pending_count = response.pending_count state.pulled_at = new Date().toISOString() state.last_error = response.skipped - ? `${response.skipped} team ${response.skipped === 1 ? "rule" : "rules"} could not be read by this version of altimate-code; update it to get them` + ? `${response.skipped} team ${response.skipped === 1 ? "rule" : "rules"} could not be read by this version of altimate-code, or did not pass the rule lint (one line, no commands, links or secrets), and ${response.skipped === 1 ? "is" : "are"} not delivered` : undefined if (!response.unchanged) state.rules = response.rules for (const reviewed of response.my_reviewed) { @@ -296,26 +298,24 @@ export async function push(root: string, ctx: Ready): Promise { // cannot stop two of them from reading the same outbox and posting it twice. A run that finds another one sending // waits for it; the outcome it recorded per outbox id then answers this run's card. await SafeFS.mkdir(root, RulesStore.dir(root)) - let entered = false try { - return await Flock.withLock("rules-push", () => { - entered = true - return pushLocked(root, ctx) - }, { + return await Flock.withLock("rules-push", () => pushLocked(root, ctx), { dir: RulesStore.dir(root), staleMs: 60_000, timeoutMs: PUSH_LOCK_WAIT_MS, }) } catch (error) { - // Only a push lock this run never got means another sender: anything thrown while sending (the learn lock too) is - // a failure of this run. - if (entered) throw error + // Only a push lock still held by another sender after the wait: any other failure (taking the lock, sending, the + // learn lock) is a failure of this run. + if (!PUSH_LOCK_BUSY.test(errorText(error))) throw error const queued = (await RulesStore.read(root)).outbox.length return { sent: [], queued, ...(queued ? { error: "another sync is still sending proposals" } : {}) } } } const PUSH_LOCK_WAIT_MS = 30_000 +/** `Flock`'s message when the push lock was still held by another sender after `PUSH_LOCK_WAIT_MS`. */ +const PUSH_LOCK_BUSY = /^Timed out waiting for lock: rules-push$/ async function pushLocked(root: string, ctx: Ready): Promise { const state = await RulesStore.read(root) @@ -522,17 +522,25 @@ export class ScopeChangedError extends Error { } } -/** Queue proposals for the next push, in one write. Returns their outbox ids, in order. Refuses past `MAX_OUTBOX`. - * `scope`: the scope they were shown for; checked in the same write, so they never join another scope's outbox. */ +/** Queue proposals for the next push, in one write. Returns their outbox ids, in order. Refuses past `MAX_OUTBOX`, or + * with `upTo`, queues only the first ones that fit (the ids returned say which). `scope`: the scope they were shown + * for; checked in the same write, so they never join another scope's outbox. */ export async function enqueueMany( root: string, - items: readonly { item: ProposalItem; approve?: "approve" | "approve_anyway" }[], + all: readonly { item: ProposalItem; approve?: "approve" | "approve_anyway" }[], scope?: Scope, + options: { upTo?: boolean } = {}, ): Promise { - const ids = items.map(() => randomUUID()) + let items = all + let ids = items.map(() => randomUUID()) const at = new Date().toISOString() await RulesStore.update(root, (state) => { if (scope && !RulesStore.sameScope(state.scope, scope)) throw new ScopeChangedError() + if (options.upTo) { + const room = Math.max(0, RulesStore.MAX_OUTBOX - state.outbox.length) + items = all.slice(0, room) + ids = ids.slice(0, room) + } if (state.outbox.length + items.length > RulesStore.MAX_OUTBOX) throw new Error( items.length > RulesStore.MAX_OUTBOX @@ -583,6 +591,8 @@ async function sendUsage(root: string, ctx: Ready, batch: PendingUsage): Promise * `pending_usage`. The one place that decides what "reported" means. */ async function markReported(root: string, batch: PendingUsage) { await RulesStore.update(root, (s) => { + // Another account or workspace took the state over meanwhile: its own reports are not marked by this batch. + if (batch.scope && !RulesStore.sameScope(s.scope, batch.scope)) return const known = new Set(s.usage_reported) s.usage_reported.push(...batch.pairs.filter((pair) => !known.has(pair))) s.corrected_reported = { ...s.corrected_reported, ...batch.corrected } @@ -597,6 +607,10 @@ async function markReported(root: string, batch: PendingUsage) { */ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: number; error?: string }> { const before = await RulesStore.read(root) + // Only this run's account and workspace: a state another process adopted, or a batch built for another scope, is not + // this run's to send (with its credential) or to mark reported. + if (!ownedBy(before, ctx)) return { items: 0 } + if (before.pending_usage?.scope && !RulesStore.sameScope(before.pending_usage.scope, ctx.scope)) return { items: 0 } if (before.pending_usage) { try { await sendUsage(root, ctx, before.pending_usage) @@ -605,20 +619,26 @@ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: nu await dropUsage(root, before.pending_usage, error) } } - const state = await RulesStore.read(root) - if (!state.rules.length) return { items: 0 } - const byLesson = new Map(state.rules.map((rule) => [RulesStore.lessonId(rule.rule_key), rule])) const [{ readShownSnapshots, lessonTextHash, snapshotFiles, snapshotFile }, { analyzeRecurrence }] = await Promise.all([import("../learn/delivery"), import("../learn/recurrence")]) - const { snapshots } = await readShownSnapshots(root) - // A reported pair is kept while its session has a snapshot (any of them, not only the newest read here): a resumed - // session comes back into the newest, and its pairs must still read as reported. A pair whose session is gone can - // never be counted again, so it is let go: the list stays as small as the sessions on disk. + // A reported pair is kept while its session's snapshot exists and was written within `USAGE_PAIR_MS`: a resumed + // session comes back into the newest snapshots, and its pairs must still read as reported. Snapshots are never + // deleted, so age bounds the list; a session resumed after that long may be counted once more. const files = await snapshotFiles(root).catch(() => undefined) const sessionOf = (pair: string) => pair.slice(0, pair.lastIndexOf("|")) - if (files && state.usage_reported.some((pair) => !files.has(snapshotFile(sessionOf(pair))))) + const now = Date.now() + const live = (pair: string) => { + const written = files!.get(snapshotFile(sessionOf(pair))) + return written !== undefined && now - written < USAGE_PAIR_MS + } + const unpruned = await RulesStore.read(root) + if (files && unpruned.usage_reported.some((pair) => !live(pair))) await RulesStore.update(root, (s) => { - s.usage_reported = s.usage_reported.filter((pair) => files.has(snapshotFile(sessionOf(pair)))) + s.usage_reported = s.usage_reported.filter(live) }) + const state = await RulesStore.read(root) + if (!state.rules.length) return { items: 0 } + const byLesson = new Map(state.rules.map((rule) => [RulesStore.lessonId(rule.rule_key), rule])) + const { snapshots } = await readShownSnapshots(root) const reported = new Set(state.usage_reported) const counts = new Map() const pairs: string[] = [] @@ -670,6 +690,7 @@ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: nu } const batch: PendingUsage = { batch_id: randomUUID(), + scope: ctx.scope, items: [...counts].map(([public_id, c]) => ({ public_id, applied: c.applied, helpful: 0, harmful: 0, corrected_again: c.corrected_again })), pairs, corrected, @@ -684,6 +705,7 @@ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: nu const reportedNow = new Set(s.usage_reported) stale = pairs.some((pair) => reportedNow.has(pair)) || Object.keys(corrected).some((key) => (s.corrected_reported[key] ?? 0) !== (state.corrected_reported[key] ?? 0)) + if (!ownedBy(s, ctx)) stale = true if (!stale) s.pending_usage = batch }) if (stale) return { items: 0 } diff --git a/packages/opencode/src/altimate/rules/trigger.ts b/packages/opencode/src/altimate/rules/trigger.ts index 7583ae3959..3b4a6a1e84 100644 --- a/packages/opencode/src/altimate/rules/trigger.ts +++ b/packages/opencode/src/altimate/rules/trigger.ts @@ -14,7 +14,7 @@ import { Log } from "@/util/log" import { booleanFlag, learnEnabled } from "../learn/config" import { lint } from "../learn/curator" -import { readShownSnapshots } from "../learn/delivery" +import { readShownSnapshot } from "../learn/delivery" import { buildDigest, type DigestSource } from "../learn/digest" import * as Playbook from "../learn/playbook" import { approvedLessons, unmatchedCorrectionTopics } from "../learn/recurrence" @@ -169,7 +169,9 @@ export async function afterTurn(input: { const state = await RulesStore.read(input.root) if (!learningEnabled(input.learn, state)) return const processed = new Set(state.processed_signals) - const fresh = (await readSignals(input.root, Playbook.DEFAULT_NAME)).filter((signal) => + // Read once per check: the recurrence path below uses the same signals. + const all = await readSignals(input.root, Playbook.DEFAULT_NAME) + const fresh = all.filter((signal) => signal.kind === "user_correction" && signal.sessionID === input.sessionID && !processed.has(signal.id)) if (!fresh.length) return // Nothing attached would show a card (`altimate-code run`, ACP, a plugin): the corrections are left unprocessed, @@ -185,11 +187,11 @@ export async function afterTurn(input: { checked = fresh.map((signal) => signal.id) // The model call takes a while: if the client that shows cards went away meanwhile, the card would not be seen, // so the corrections are left for a later check instead. - const stillShown = () => { + const stillShown = (trigger: "shown_rule" | "recurrence") => { if (RuleProposal.rendererAttached(input.directory)) return true checked = [] outcome = "the client that shows cards went away during the check; corrections kept for a later check" - RulesTelemetry.skipped(input.sessionID, undefined, "no_renderer") + RulesTelemetry.skipped(input.sessionID, trigger, "no_renderer") return false } const newest = fresh[fresh.length - 1] @@ -197,7 +199,7 @@ export async function afterTurn(input: { // (c) A team rule shown in this session before the correction, on the same subject: propose changing it. // A loose word-overlap filter picks the candidate; the drafter decides (HELPFUL = the rule already says it). - const snapshot = (await readShownSnapshots(input.root)).snapshots.find((s) => s.session === input.sessionID) + const snapshot = await readShownSnapshot(input.root, input.sessionID) const correctedAt = Date.parse(newest.correctedAt ?? newest.at) const shownIds = new Set((snapshot?.shown ?? []) .filter((entry) => entry.name === RulesStore.WORKSPACE_RULES_STORE && !(Date.parse(entry.at) >= correctedAt)) @@ -213,7 +215,7 @@ export async function afterTurn(input: { // The shown rule already says it: no card, and no second drafting call for recurrence. if (drafted === "covered") return RulesTelemetry.skipped(input.sessionID, "shown_rule", "covered") if (drafted) { - if (!stillShown()) return + if (!stillShown("shown_rule")) return const card = await RuleProposal.ask({ sessionID: input.sessionID, root: input.root, directory: input.directory, learn: input.learn, trigger: "shown_rule", draft: { @@ -236,7 +238,6 @@ export async function afterTurn(input: { // (a) The same topic corrected in 2 or more sessions, with no rule covering it. From the corrections themselves: // a project with no approved rules yet has no delivery snapshots, and must still be able to get its first rule. - const all = await readSignals(input.root, Playbook.DEFAULT_NAME) const topics = unmatchedCorrectionTopics(all, await approvedLessons(input.root)) const freshIds = new Set(fresh.map((signal) => signal.id)) const candidates = topics.filter((t) => t.sessions >= RECURRENCE_SESSIONS && t.signal_ids.some((id) => freshIds.has(id))) @@ -249,7 +250,7 @@ export async function afterTurn(input: { const drafted = await draft({ deps: input.deps, sessionID: input.sessionID, trigger: "recurrence", signals: backing, state, addOnly: true, note }) if (!drafted || drafted === "covered" || drafted.change !== "add") return RulesTelemetry.skipped(input.sessionID, "recurrence", drafted === "covered" ? "covered" : "no_draft") - if (!stillShown()) return + if (!stillShown("recurrence")) return const card = await RuleProposal.ask({ sessionID: input.sessionID, root: input.root, directory: input.directory, learn: input.learn, trigger: "recurrence", draft: { diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 6bc19ff9f8..965c957c62 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -843,7 +843,7 @@ export namespace Telemetry { session_id: string via: "training" | "memory" /** `no_renderer`: no client that shows cards is attached, so the tool saved as before cards. */ - outcome: "card" | "choose_card" | "memory" | "invalid" | "learning_off" | "not_linked" | "no_renderer" + outcome: "card" | "choose_card" | "memory" | "invalid" | "learning_off" | "not_linked" | "no_renderer" | "requests_full" } | { /** One rules sync (push queued proposals, pull rules and reviews, report usage). */ diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index b6ecd50165..bd832d27ab 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,6 +1,6 @@ import path from "path" import { Token } from "@/util/token" -import { existsSync, readFileSync, realpathSync, statSync } from "node:fs" +import { existsSync, readFileSync, statSync } from "node:fs" import os from "os" import fs from "fs/promises" import z from "zod" @@ -121,8 +121,9 @@ import { trackWorkspaceToolCall } from "../altimate/workspace/tool-call-telemetr // altimate_change end import { Telemetry } from "@/telemetry" // altimate_change — session telemetry import { ErrorDetail } from "@/altimate/telemetry/error-detail" // altimate_change — error_fingerprint payload -// altimate_change start — the session's bound workspace rides on every telemetry row -import { readLocalBinding } from "@/altimate/workspace/state" +// altimate_change start — the session's bound workspace rides on every telemetry row; workspace rules canonicalize bound +// directories like the bindings cache does +import { canonicalDirectory, readLocalBinding } from "@/altimate/workspace/state" /** The session's workspace use, as session_end fields. Read only: the row is tracked first, then the * turn ends its telemetry entries (`Telemetry.endSession`), so the row itself still carries the @@ -154,22 +155,16 @@ const rulesSessionsStarted = new Set() /** Rules state in this project, or a cached workspace binding for this directory: otherwise the project cannot have * workspace rules, and the prompt path skips them. Synchronous, local checks only: the bindings cache is parsed once per * change of the file (size and mtime), not on every prompt. */ -const canonicalDir = (dir: string) => { - try { - return realpathSync(path.resolve(dir)) - } catch { - return path.resolve(dir) - } -} let boundDirs: { stamp: string; dirs: ReadonlySet } | undefined const boundDirectories = (): ReadonlySet => { const file = path.join(Global.Path.state, "altimate-workspace-bindings.json") try { const stat = statSync(file) - const stamp = `${stat.size}:${stat.mtimeMs}` + // A same-sized atomic replacement within one mtime tick still changes the inode or ctime. + const stamp = `${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}` if (boundDirs?.stamp !== stamp) { const cache = JSON.parse(readFileSync(file, "utf8")) as { bindings?: Record } - boundDirs = { stamp, dirs: new Set(Object.keys(cache.bindings ?? {}).map(canonicalDir)) } + boundDirs = { stamp, dirs: new Set(Object.keys(cache.bindings ?? {}).map(canonicalDirectory)) } } return boundDirs.dirs } catch { @@ -179,7 +174,7 @@ const boundDirectories = (): ReadonlySet => { const mayHaveWorkspaceRules = (root: string, directory: string) => { if (existsSync(path.join(root, ".altimate-code", "learn", "workspace-rules", "rules.json"))) return true const dirs = boundDirectories() - return dirs.size > 0 && dirs.has(canonicalDir(directory)) + return dirs.size > 0 && dirs.has(canonicalDirectory(directory)) } // altimate_change end diff --git a/packages/opencode/test/altimate/learn/delivery.test.ts b/packages/opencode/test/altimate/learn/delivery.test.ts index cceb60f260..10009db846 100644 --- a/packages/opencode/test/altimate/learn/delivery.test.ts +++ b/packages/opencode/test/altimate/learn/delivery.test.ts @@ -505,6 +505,27 @@ test("rules added to a session are reported by tier, as counts only; a cached an expect(JSON.stringify(events)).not.toContain("shipping") }) +test("a resumed session shown team rules drops them while workspaces are off", async () => { + await approve([lesson("L-0201", "Keep the project rules explicit.", { pinned: true })]) + await approve([lesson("L-0202", "Name staging models stg_.", { pinned: true })], Store.WORKSPACE_RULES_STORE) + const env = process.env.ALTIMATE_DISABLE_WORKSPACE + try { + delete process.env.ALTIMATE_DISABLE_WORKSPACE + const on = await new Delivery(root, { ...limits, core_lessons: 2 }).prepare("resumed", "first", "staging") + expect(on.section).toContain("Name staging models") + process.env.ALTIMATE_DISABLE_WORKSPACE = "1" + const off = new Delivery(root, { ...limits, core_lessons: 2 }) + // The cached section is not served; the next request rebuilds it without the team rule. + expect(await off.section("resumed")).not.toContain("Name staging models") + const again = await off.prepare("resumed", "first", "staging") + expect(again.section).not.toContain("Name staging models") + expect(again.section).toContain("Keep the project rules explicit") + } finally { + if (env === undefined) delete process.env.ALTIMATE_DISABLE_WORKSPACE + else process.env.ALTIMATE_DISABLE_WORKSPACE = env + } +}) + test("rules from the workspace-rules store are counted as team rules, and are not delivered with workspaces off", async () => { await approve([lesson("L-0101", "Keep the project rules explicit.", { pinned: true })]) await approve([lesson("L-0102", "Name staging models stg_.", { pinned: true })], Store.WORKSPACE_RULES_STORE) diff --git a/packages/opencode/test/altimate/rules/rules.test.ts b/packages/opencode/test/altimate/rules/rules.test.ts index 4ceea8614e..0156d2cd0b 100644 --- a/packages/opencode/test/altimate/rules/rules.test.ts +++ b/packages/opencode/test/altimate/rules/rules.test.ts @@ -1009,6 +1009,64 @@ describe("the workspace API, by path", () => { expect((await RulesStore.read(root)).outbox).toEqual([]) }) + test("one review row or field this version cannot read, or a rule that fails the lint, does not fail the pull", async () => { + const root = await tmp() + const good = { public_id: "p-ok", rule_key: "R-1", text: "Use cents.", change: "add", status: "active", reviewed_at: "2026-10-28T00:00:00Z", updated_at: "2026-10-28T00:00:00Z" } + answer = () => Response.json(syncBody([ + rule("aaaaaaaaaaaa", "Name staging models stg_."), + rule("bbbbbbbbbbbb", "Line one\nline two."), + ], { repo_identity: null, my_reviewed: [{ ...good, updated_at: null, public_id: "p-bad" }, good] })) + const pulled = await RulesSync.pull(root, ready()) + expect(pulled).toMatchObject({ ok: true, rules: 1 }) + expect(pulled.notices.map((n) => n.public_id)).toEqual(["p-ok"]) + const state = await RulesStore.read(root) + expect(state.revision).toBeUndefined() + expect(state.last_error).toContain("did not pass the rule lint") + }) + + test("usage built for another account or workspace is neither sent nor marked reported", async () => { + const root = await tmp() + answer = () => Response.json(syncBody([rule("aaaaaaaaaaaa", "Name staging models stg_.")])) + await RulesSync.pull(root, ready()) + await RulesStore.update(root, (s) => { + s.pending_usage = { batch_id: "batch-other", scope: { ...scope(), account: "someone-else" }, items: [{ public_id: "p-aaaaaaaaaaaa", applied: 1, helpful: 0, harmful: 0, corrected_again: 0 }], pairs: ["s1|p-aaaaaaaaaaaa"], corrected: {} } + }) + seen.length = 0 + expect(await RulesSync.reportUsage(root, ready())).toEqual({ items: 0 }) + expect(seen.filter((r) => r.path === "/usage")).toEqual([]) + const state = await RulesStore.read(root) + expect(state.pending_usage?.batch_id).toBe("batch-other") + expect(state.usage_reported).toEqual([]) + }) + + test("a reported pair is let go once its session's snapshot is older than a month", async () => { + const root = await tmp() + answer = () => Response.json(syncBody([{ ...rule("aaaaaaaaaaaa", "Name staging models stg_."), public_id: "p-a" }])) + await RulesSync.pull(root, ready()) + const dir = path.join(root, ".altimate-code", "learn", ".sessions") + await fs.mkdir(dir, { recursive: true }) + for (const session of ["old", "recent"]) await fs.writeFile(path.join(dir, `${Store.sha256(session)}.json`), "{}") + const longAgo = new Date(Date.now() - 40 * 24 * 60 * 60_000) + await fs.utimes(path.join(dir, `${Store.sha256("old")}.json`), longAgo, longAgo) + await RulesStore.update(root, (s) => { s.usage_reported = ["old|p-a", "recent|p-a"] }) + await RulesSync.reportUsage(root, ready()) + expect((await RulesStore.read(root)).usage_reported).toEqual(["recent|p-a"]) + }) + + test("a push lock that cannot be taken for another reason fails the push; only a busy one reads as another sender", async () => { + const root = await tmp() + await RulesSync.enqueue(root, item("Lock trouble.")) + const { Flock } = await import("@opencode-ai/core/util/flock") + const spy = spyOn(Flock, "withLock").mockRejectedValueOnce(new Error("EACCES: permission denied, mkdir rules-push.lock")) + try { + await expect(RulesSync.push(root, ready())).rejects.toThrow("EACCES") + spy.mockRejectedValueOnce(new Error("Timed out waiting for lock: rules-push")) + expect(await RulesSync.push(root, ready())).toMatchObject({ queued: 1, error: "another sync is still sending proposals" }) + } finally { + spy.mockRestore() + } + }) + test("too many proposals at once say so", async () => { const root = await tmp() const many = Array.from({ length: RulesStore.MAX_OUTBOX + 1 }, (_, i) => ({ item: item(`Rule ${i}.`) })) @@ -1782,6 +1840,7 @@ describe("cards across the feature's edges", () => { const root = await linkedRoot() await appendSignal(root, { kind: "user_correction", sessionID: "e1", text: "no, the column is ordered_at", reason: "correction" }) const unrelated = await RuleTrigger.explicit({ sessionID: "e1", root, directory: root, learn: undefined, text: "Never use FLOAT for money columns.", said: "Remember as a rule: never use FLOAT for money columns." }) + expect(unrelated.status).toBe("shown") expect(unrelated.status === "shown" && unrelated.card?.evidence.correction).toBeFalsy() await appendSignal(root, { kind: "user_correction", sessionID: "e1", text: "money columns must not be FLOAT, use integer cents", reason: "correction" }) await RuleProposal.drop(root) @@ -1809,20 +1868,24 @@ describe("cards across the feature's edges", () => { expect((await RulesStore.read(root)).remember_requests).toEqual([]) }) - test("a requested card whose process died is saved as before by the next process, once", async () => { + test("a requested card whose process died is saved as before by the next process, once; a PID reused by a restart does not hide it", async () => { await using dir = await tmpdir({ git: true }) const dead = Bun.spawn(["true"]) await dead.exited + const now = new Date().toISOString() try { await Instance.provide({ directory: dir.path, fn: async () => { const root = await fs.realpath(Instance.worktree) await RulesStore.update(root, (s) => { - s.remember_requests.push({ card: "c-dead", text: "Alias every joined table.", session: "x1", directory: Instance.directory, via: "training", training: { name: "alias-joins" }, pid: dead.pid, at: new Date().toISOString() }) - s.remember_requests.push({ card: "c-live", text: "Still open here.", session: "x2", directory: Instance.directory, via: "training", pid: process.pid, at: new Date().toISOString() }) + s.remember_requests.push({ card: "c-dead", text: "Alias every joined table.", session: "x1", directory: Instance.directory, via: "training", training: { name: "alias-joins" }, pid: dead.pid, boot: "earlier", at: now }) + // A restarted server can get the crashed one's PID (PID 1 in a container): its boot id tells them apart. + s.remember_requests.push({ card: "c-restart", text: "Quote reserved identifiers.", session: "x3", directory: Instance.directory, via: "training", training: { name: "quote-reserved" }, pid: process.pid, boot: "before-restart", at: now }) + s.remember_requests.push({ card: "c-live", text: "Still open here.", session: "x2", directory: Instance.directory, via: "training", pid: process.pid, boot: RulesStore.PROCESS_BOOT, at: now }) }) await RulesRemember.recoverRequests(root, Instance.directory) await RulesRemember.recoverRequests(root, Instance.directory) expect((await TrainingStore.get("project", "rule", "alias-joins"))?.content).toContain("Alias every joined table.") + expect((await TrainingStore.get("project", "rule", "quote-reserved"))?.content).toContain("Quote reserved identifiers.") // A card this process still shows is its own to save. expect((await RulesStore.read(root)).remember_requests.map((r) => r.card)).toEqual(["c-live"]) } }) @@ -1831,6 +1894,121 @@ describe("cards across the feature's edges", () => { } }) + test("a request whose save fails for a passing reason is kept for the next start; one that can never be saved is dropped", async () => { + await using dir = await tmpdir({ git: true }) + const now = new Date().toISOString() + try { + await Instance.provide({ directory: dir.path, fn: async () => { + const root = await fs.realpath(Instance.worktree) + await RulesStore.update(root, (s) => { + s.remember_requests.push({ card: "c-busy", text: "Alias every joined table.", session: "y1", directory: Instance.directory, via: "training", training: { name: "alias-busy" }, pid: process.pid, boot: "earlier", at: now }) + }) + const save = spyOn(TrainingStore, "save").mockRejectedValue(new Error("EBUSY: resource busy")) + try { + await RulesRemember.recoverRequests(root, Instance.directory) + } finally { + save.mockRestore() + } + // Kept, claimed by this boot: the next start (another boot) tries again. + expect((await RulesStore.read(root)).remember_requests).toEqual([expect.objectContaining({ card: "c-busy", boot: RulesStore.PROCESS_BOOT })]) + await RulesStore.update(root, (s) => { s.remember_requests[0].boot = "next-start" }) + const count = spyOn(TrainingStore, "count").mockResolvedValue({ rule: 50 } as never) + try { + await RulesRemember.recoverRequests(root, Instance.directory) + } finally { + count.mockRestore() + } + // The per-kind limit can never be met as asked: dropped (logged). + expect((await RulesStore.read(root)).remember_requests).toEqual([]) + } }) + } finally { + await disposeAllInstances() + } + }) + + test("a card whose fallback fails for a passing reason keeps its request; a refused save clears it", async () => { + const root = await linkedRoot() + const draft = (text: string) => ({ text, applies_to: [], change: "add" as const, origin: "explicit" as const, evidence: { sessions: 1 }, topics: [] }) + await RuleProposal.ask({ sessionID: "f1", root, directory: root, learn: undefined, request: { session: "f1", via: "training" }, unanswered: async () => { throw new Error("ENOSPC: no space left") }, draft: draft("Quote reserved words.") }) + await RuleProposal.ask({ sessionID: "f2", root, directory: root, learn: undefined, request: { session: "f2", via: "training" }, unanswered: async () => { throw new RuleProposal.SaveRefusedError("project training already holds 50 rules") }, draft: draft("Alias joined tables.") }) + await RuleProposal.drop(root) + expect((await RulesStore.read(root)).remember_requests.map((r) => r.text)).toEqual(["Quote reserved words."]) + }) + + test("with 50 requested cards open, a new request is saved without a card instead of evicting one", async () => { + const root = await linkedRoot() + await RulesStore.update(root, (s) => { + for (let i = 0; i < RulesStore.MAX_REMEMBER_REQUESTS; i++) + s.remember_requests.push({ card: `c-${i}`, text: `Rule ${i}.`, session: "z", directory: root, via: "training", pid: process.pid, boot: RulesStore.PROCESS_BOOT, at: new Date().toISOString() }) + }) + const result = await RulesRemember.proposeFor({ root, directory: root, learn: undefined, content: "Always use CTEs in staging models.", sessionID: "z1", said: "Remember as a rule: always use CTEs in staging models.", unanswered: async () => {}, request: { session: "z1", via: "training" } }) + expect(result).toBeUndefined() + expect(RuleProposal.list(root, "z1")).toEqual([]) + expect((await RulesStore.read(root)).remember_requests).toHaveLength(RulesStore.MAX_REMEMBER_REQUESTS) + }) + + test("a card answered after its day is closed, not answered; a requested one is saved as before", async () => { + const root = await linkedRoot() + const saved: string[] = [] + const card = (await RuleProposal.ask({ sessionID: "d1", root, directory: root, learn: undefined, unanswered: async (text) => void saved.push(text), draft: { text: "Prefix seeds with seed_.", applies_to: [], change: "add", origin: "explicit", evidence: { sessions: 1 }, topics: [] } }))! + const clock = spyOn(Date, "now").mockReturnValue(Date.now() + RuleProposal.CARD_TTL_MS + 1_000) + try { + expect(await RuleProposal.reply(card.id, { action: "send" })).toMatchObject({ ok: false, code: "not_open", error: "This proposal expired." }) + } finally { + clock.mockRestore() + } + expect((await RulesStore.read(root)).outbox).toEqual([]) + expect(saved).toEqual(["Prefix seeds with seed_."]) + }) + + test("a card sent while rules sync is unavailable says so, instead of promising it is sent when the workspace is reachable", async () => { + const root = await linkedRoot() + const card = (await RuleProposal.ask({ sessionID: "o1", root, directory: root, learn: { enabled: false }, draft: { text: "Prefix seeds with seed_.", applies_to: [], change: "add", origin: "explicit", evidence: { sessions: 1 }, topics: [] } }))! + const result = await RuleProposal.reply(card.id, { action: "send" }) + expect(result).toMatchObject({ ok: true }) + expect(result.ok && result.message).toContain("rules sync is not available") + }) + + test("training_save content that reads as a memory is saved to memory, not redirected to a tool the agent may not have", async () => { + const root = await linkedRoot() + const memories: { text: string }[] = [] + const result = await RulesRemember.proposeFor({ root, directory: root, learn: undefined, content: "I prefer CTEs over subqueries.", sessionID: "m5", said: "Remember: I prefer CTEs over subqueries.", saveMemory: async (text) => { memories.push({ text }); return "global" } }) + expect(result).toMatchObject({ title: "Saved to memory", metadata: { action: "memory", scope: "global" } }) + expect(memories).toEqual([{ text: "I prefer CTEs over subqueries." }]) + }) + + test("the after-turn check proposes an edit of a team rule the session was shown, a retire, or nothing when the rule already says it", async () => { + const root = await linkedRoot() + const text = "Name order timestamp columns ordered_at in staging models." + await RulesStore.update(root, (s) => { s.rules = [rule(text)] }) + const id = RulesStore.lessonId(rule(text).rule_key) + const dir = path.join(root, ".altimate-code", "learn", ".sessions") + await fs.mkdir(dir, { recursive: true }) + const shown = (session: string) => fs.writeFile(path.join(dir, `${Store.sha256(session)}.json`), JSON.stringify({ + version: 1, session, firstMessage: "m", query: "q", touchedPaths: [], section: "", requests: [], requestParts: [], compactions: [], counted: [], + shown: [{ name: RulesStore.WORKSPACE_RULES_STORE, lesson: { id, text, tags: [], scope: "project", helpful: 0, harmful: 0, applied: 0, created: "2026-10-27T00:00:00Z", updated: "2026-10-27T00:00:00Z" }, tier: "core", at: "2026-01-01T00:00:00Z", queryHash: "h" }], + })) + const check = async (session: string, delta: unknown) => { + await shown(session) + await appendSignal(root, { kind: "user_correction", sessionID: session, text: "no, the order timestamp columns in staging models are named order_ts", reason: "correction" }) + await RuleTrigger.afterTurn({ sessionID: session, root, directory: root, learn: { enabled: true, capture: true }, deps: deps({ deltas: [delta] }) }) + const cards = RuleProposal.list(root, session) + await RuleProposal.drop(root) + return cards + } + expect(await check("e-edit", { op: "EDIT", id, text: "Name order timestamp columns order_ts in staging models.", reason: "renamed" })) + .toEqual([expect.objectContaining({ change: "edit", replaces: expect.objectContaining({ text }) })]) + expect(await check("e-retire", { op: "REMOVE", id, reason: "no longer true" })).toEqual([expect.objectContaining({ change: "retire" })]) + const events: Telemetry.Event[] = [] + const spy = spyOn(Telemetry, "track").mockImplementation((event: Telemetry.Event) => void events.push(event)) + try { + expect(await check("e-covered", { op: "HELPFUL", id, reason: "the rule says it" })).toEqual([]) + } finally { + spy.mockRestore() + } + expect(events).toContainEqual(expect.objectContaining({ type: "rule_proposal_skipped", trigger: "shown_rule", reason: "covered" })) + }) + test("with project config disabled, the live re-read ignores the project file", async () => { const root = await tmp() await fs.mkdir(path.join(root, ".altimate-code"), { recursive: true }) diff --git a/packages/opencode/test/telemetry/telemetry.test.ts b/packages/opencode/test/telemetry/telemetry.test.ts index 44de151a15..533e5cec63 100644 --- a/packages/opencode/test/telemetry/telemetry.test.ts +++ b/packages/opencode/test/telemetry/telemetry.test.ts @@ -250,6 +250,7 @@ const ALL_EVENT_TYPES: Telemetry.Event["type"][] = [ "plan_no_tool_generation", "rule_proposal_shown", "rule_proposal_answered", + "rule_drafted", "rule_proposal_skipped", "rule_remember_routed", "rules_sync", @@ -260,7 +261,7 @@ const ALL_EVENT_TYPES: Telemetry.Event["type"][] = [ describe("telemetry.event-types", () => { test("all event types are valid", () => { - expect(ALL_EVENT_TYPES.length).toBe(53) + expect(ALL_EVENT_TYPES.length).toBe(54) }) }) From 8d83a1a55f85b74c6a64a12711cc11840be1089e Mon Sep 17 00:00:00 2001 From: Sarav Date: Fri, 30 Oct 2026 14:08:07 +0530 Subject: [PATCH 18/20] =?UTF-8?q?fix:=20workspace=20rules=20=E2=80=94=20fu?= =?UTF-8?q?ll-feature=20review=20round?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invariants this round enforces, each with a test that fails without it: - A "remember" request leaves disk only with its outcome: a dismissal or a queued send ends it in the same write; a local save ends it after the save; a send the workspace refuses keeps it again (while still busy, so the card cannot close mid-write); an answered card whose request cannot be removed is removed by recovery, never saved - A requested card is shown only once its request is on disk; otherwise there is no card and the tool saves without one ("asked again" likewise, changing the card only after the write) - A claim renews the request's time, and this process renews its own open cards, so no live process's request is taken as ownerless; claims are marked inside the locked write - This process retries its own leftover requests on every refresh, never while their card is open, settling or answered - Usage counts a session within 30 days of its snapshot and remembers its reported pairs for 90, so nothing countable is forgotten and old sessions are not recounted every sync - A usage batch is sent only for the run's own scope, including one another process saved mid-run - A resumed session keeps a team rule only while workspaces are on and the rule is still in the mirror (an edit keeps its frozen wording; an unreadable mirror keeps the snapshot) - A send that is queued (decided from disk when the call threw after its write) drops the card's local fallback, so no second copy is saved on close Also: privacy docs describe what workspace proposals send; the memory destination of `training_save` is documented; staged rules with the same wording are proposed once; one read per session snapshot. Co-Authored-By: Claude Opus 5.5 --- docs/docs/configure/learn.md | 4 +- docs/docs/reference/telemetry.md | 2 +- .../opencode/src/altimate/learn/delivery.ts | 29 +- .../src/altimate/rules/from-history.ts | 2 + .../opencode/src/altimate/rules/proposal.ts | 106 +++++-- .../opencode/src/altimate/rules/remember.ts | 82 ++++-- .../opencode/src/altimate/rules/service.ts | 14 +- packages/opencode/src/altimate/rules/sync.ts | 47 ++- .../test/altimate/learn/delivery.test.ts | 38 +++ .../test/altimate/rules/rules.test.ts | 276 +++++++++++++++++- 10 files changed, 527 insertions(+), 73 deletions(-) diff --git a/docs/docs/configure/learn.md b/docs/docs/configure/learn.md index 94264363c3..e60cb386d3 100644 --- a/docs/docs/configure/learn.md +++ b/docs/docs/configure/learn.md @@ -199,7 +199,7 @@ Cards are shown by the TUI and by the VS Code extension from its release with wo 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. -**Rule or memory.** A personal preference ("I prefer CTEs") or a fact ("our warehouse is Snowflake") is saved to memory without review. When the wording does not say which, the card asks: Memory, Team rule, or Skip. +**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. **Where they live.** Approved team rules are mirrored into the `workspace-rules` store (`.altimate-code/learn/workspace-rules/`), which is git-ignored and written only by sync: the name is reserved for it, and a store you create needs another `--name`. Your other stores, including one named `team`, are not touched. Team rules stop being delivered when the project is unlinked, when you sign out, when the workspace refuses access, and while workspaces are turned off (`ALTIMATE_DISABLE_WORKSPACE`); they come back with the next sync. @@ -552,7 +552,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. -Nothing is published to a workspace unless you run `promote --publish`. 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 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). **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/reference/telemetry.md b/docs/docs/reference/telemetry.md index b0f9369bb0..2fd4ae6584 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -78,7 +78,7 @@ We collect the following categories of events: | `rule_proposal_answered` | A card was answered or closed (`send`, `approve`, `approve_anyway`, `edit`, `dismiss`, `save_local`, `save_memory`, `expired`, `closed`) — `ok` or why it did not go through (`busy`, `not_available`, `verification_flagged`, `invalid_text`, `refused` by the workspace, `failed`), what happened (`sent`, `queued`, `approved`, `approve_failed`, `approve_retry`, `target_changed`, `exists`, `dismissed`, `saved_memory_project`, `saved_memory_global`, `saved_local`, or `saved_unanswered` when a card the person asked for closed unanswered and was saved as before cards), wording edits so far, and time since the card was shown. No rule text. | | `rule_drafted` | One model call that drafted a proposal after a turn (it runs in the background) — the path (`shown_rule` or `recurrence`), `ok` or `failed`, input and output tokens, estimated cost and duration. No wording or correction text. | | `rule_proposal_skipped` | Corrections were checked after a turn and no card was shown — `card_open`, `no_renderer` (no attached client shows cards; the corrections are kept for a later check), `covered`, `no_draft`, `topic_dismissed`, `no_topic` or `failed`. The path checked (`shown_rule` or `recurrence`) is omitted when the check stopped before choosing one. | -| `rule_remember_routed` | Where a "remember …" save went — through `training_save` or the memory tool, to a card, a "Save as" card, memory, refused as not a one-line rule, or saved as before cards because learning/workspaces are off, the project is not linked, or no attached client shows cards for this project (or the session is a subagent's), or too many requested cards are already open (`requests_full`). No text. | +| `rule_remember_routed` | Where a "remember …" save went — through `training_save` or the memory tool, to a card, a "Save as" card, memory, refused as not a one-line rule, or saved as before cards because learning/workspaces are off, the project is not linked, or no attached client shows cards for this project (or the session is a subagent's), or its request could not be kept on disk: too many requested cards are already open, or the write failed (`requests_full`). No text. | | `rules_sync` | One rules sync — what started it (`refresh`, `session_start`, `manual`, `card`, `cli`, `import`), `ok` (the pull worked and no proposal this run tried to send was kept queued; proposals beyond one push's batch can still be waiting, and proposals the workspace refused still count as `ok`, under `refused`), `failed` (a failed pull, attempted proposals kept queued, or a run that threw: reason `error`), `off` or `skipped` with a fixed reason code, duration, counts of rules, pending reviews, proposals sent, refused and still queued, review notices and usage items, and whether a retried "Approve now" failed (`approval_failed`). No rule text, error text, URLs or workspace names. | | `rules_delivered` | Rules added to a session's context — at the start, for a request, or for a file the agent touched — with the number of rules (and how many are team rules), their total length, the session's running total, and the token budget. No rule text or file paths. | | `rules_learning_changed` | The person turned learning from sessions on or off for a project, or back to the workspace's setting, with the resulting state and who decides it (`organization`, `you`, `workspace` or `default`). | diff --git a/packages/opencode/src/altimate/learn/delivery.ts b/packages/opencode/src/altimate/learn/delivery.ts index 05b9ee938f..da465c202b 100644 --- a/packages/opencode/src/altimate/learn/delivery.ts +++ b/packages/opencode/src/altimate/learn/delivery.ts @@ -105,7 +105,9 @@ export async function snapshotFiles(root: string): Promise> export const snapshotFile = (session: string) => Store.sha256(session) + ".json" async function readSnapshotFile(root: string, file: string): Promise { - const state = State.parse(JSON.parse((await read(root, file)) ?? "")) + const raw = await read(root, file) + if (raw === undefined) throw Object.assign(new Error(`no snapshot: ${file}`), { code: "ENOENT" }) + const state = State.parse(JSON.parse(raw)) if (path.basename(file) !== Store.sha256(state.session) + ".json") throw new Error("Learn session state belongs to another session") return { session: state.session, shown: state.shown.map(({ name, lesson, tier, at }) => ({ name, id: lesson.id, textHash: lessonTextHash(lesson.text), tier, at, @@ -117,8 +119,8 @@ export async function readShownSnapshot(root: string, session: string): Promise< const dir = path.join(root, ".altimate-code", "learn", ".sessions") await SafeFS.assertSafePath(root, dir) const file = path.join(dir, snapshotFile(session)) - if ((await read(root, file)) === undefined) return undefined return readSnapshotFile(root, file).catch((error) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined if (error instanceof SafeFS.UnsafeLearnPathError) throw error log.warn("learn session snapshot skipped", { file, error }) return undefined @@ -215,10 +217,13 @@ export class Delivery { // Older versions froze unchecked content. Rebuild unsafe snapshots on the next prepare; // valid snapshots retain their byte-identical prefix across resume and approved edits. const grandfathered = new Map[]>() + // Read once per check, an unreadable mirror (null) included. + let mirrored: Promise | null> | undefined for (const { name, lesson } of state.shown) { - // altimate_change start — workspace rules: a session that was shown team rules is rebuilt without them while - // workspaces are off, so a resumed session does not keep delivering them from its cached section and notes - if (name === Store.WORKSPACE_RULES_STORE && !workspacesOn()) { + // altimate_change start — workspace rules: a session's cached section and notes keep a team rule only while sync + // still vouches for it: workspaces on, and the rule still in the mirror (an edit keeps its id, so its frozen + // wording stays). Otherwise (workspaces off, signed out, unlinked, another workspace, retired) it is rebuilt. + if (name === Store.WORKSPACE_RULES_STORE && (!workspacesOn() || (await (mirrored ??= this.mirroredIds()))?.has(lesson.id) === false)) { log.info("learn session snapshot rebuilt without team rules", { session }) return undefined } @@ -234,6 +239,20 @@ export class Delivery { return state } + // altimate_change start — workspace rules: the ids the team-rules mirror holds now; a missing mirror holds none, and + // one that cannot be read is unknown (null): the snapshot is kept rather than rebuilt on a passing read error + private async mirroredIds(): Promise | null> { + const raw = await read(this.root, Store.paths(this.root, Store.WORKSPACE_RULES_STORE).approved).catch(() => null) + if (raw === null) return null + if (raw === undefined) return new Set() + try { + return new Set(parse(raw).map((lesson) => lesson.id)) + } catch { + return null + } + } + // altimate_change end + private async approved(): Promise { const result: Approved[] = [] const names = (await fs.readdir(this.dir, { withFileTypes: true })) diff --git a/packages/opencode/src/altimate/rules/from-history.ts b/packages/opencode/src/altimate/rules/from-history.ts index 24882c777c..a4b342a060 100644 --- a/packages/opencode/src/altimate/rules/from-history.ts +++ b/packages/opencode/src/altimate/rules/from-history.ts @@ -60,6 +60,8 @@ export async function stagedRules(input: { const invalid: Staged["invalid"] = [] for (const lesson of candidate) { if (known.has(lesson.text) || approvedIds.has(lesson.id)) continue + // The same wording twice in the candidate is proposed once. + known.add(lesson.text) const reason = lint(lesson.text) if (reason) { invalid.push({ text: lesson.text, reason }) diff --git a/packages/opencode/src/altimate/rules/proposal.ts b/packages/opencode/src/altimate/rules/proposal.ts index 0c5431d2bd..7a26318c08 100644 --- a/packages/opencode/src/altimate/rules/proposal.ts +++ b/packages/opencode/src/altimate/rules/proposal.ts @@ -60,6 +60,10 @@ interface Pending { scope?: RulesStore.Scope /** The request is kept on disk (`RulesStore.RememberRequest`) until the card is answered or its fallback ran. */ persisted?: boolean + /** What is kept on disk for a requested card: written again if a reply ends its record but the card stays open. */ + request?: RequestRecord + /** This reply queued the proposal (its request ended in the same write). */ + enqueued?: boolean /** Closed (expired, or its instance went away) while a reply was running: the reply settles it when it ends, then * calls `done`, which resolves the promise `close` returned (`drop` waits on it). */ closed?: { message: string; why: "expired" | "closed"; here: string | undefined; done: () => void } @@ -72,9 +76,11 @@ export const CARD_TTL_MS = 24 * 60 * 60_000 * card is saved: by its fallback, or, for a card whose reply is running, once that reply ended (it saved the card, or * failed and the fallback saved it). */ function close(id: string, entry: Pending, message: string, why: "expired" | "closed", here: string | undefined): Promise { + settling.add(id) pending.delete(id) - if (entry.busy) return new Promise((done) => void (entry.closed = { message, why, here, done })) - return settle(id, entry, message, why, here) + if (entry.busy) + return new Promise((resolve) => void (entry.closed = { message, why, here, done: () => (settling.delete(id), resolve()) })) + return settle(id, entry, message, why, here).finally(() => settling.delete(id)) } /** What a "remember" card keeps on disk so the request survives the process (`RulesRemember.recoverRequests`). */ @@ -89,32 +95,41 @@ export class SaveRefusedError extends Error { } } -async function persistRequest(entry: Pending, request: RequestRecord) { +async function persistRequest(entry: Pending, request: RequestRecord, directory = entry.directory): Promise { const record: RulesStore.RememberRequest = { ...request, card: entry.card.id, text: entry.card.text, - directory: entry.directory, + directory, pid: process.pid, boot: RulesStore.PROCESS_BOOT, at: new Date().toISOString(), } - // Best effort: the card and its in-memory fallback work without it; only a crash would then lose the request. Never - // past the limit: a live record is not evicted (`proposeFor` saves without a card when it is full). + // A card for a request is shown only once its request is on disk (`ask`): never past the limit, so no live record is + // evicted. Not kept (full, or the write failed): the caller saves without a card. let kept = false await RulesStore.update(entry.root, (s) => { const others = s.remember_requests.filter((r) => r.card !== record.card) if (others.length >= RulesStore.MAX_REMEMBER_REQUESTS) return s.remember_requests = [...others, record] kept = true - }).then( - () => void (entry.persisted = kept), - (error) => log.warn("remember request not kept on disk", { card: record.card, error }), - ) + }).catch((error) => log.warn("remember request not kept on disk", { card: record.card, error })) + if (kept) entry.persisted = true + return kept +} + +/** Cards closed but still settling (their fallback, or the reply that was running, has not finished): with the open + * ones, the requests this process still owns (`isOpen`). */ +const settling = new Set() + +/** Whether card `id` is open or still settling in this process: its request is not this process's to recover. */ +export function isOpen(id: string): boolean { + return pending.has(id) || settling.has(id) || answered.has(id) } /** The card was answered, or its request was saved: it no longer needs keeping. Retried, since a record left behind - * would be saved again by recovery; removing it twice is harmless. */ + * would be saved again by recovery; removing it twice is harmless. If every attempt fails, the id is remembered as + * answered: this process's recovery removes the record instead of saving it (`answeredIds`). */ async function forgetRequest(id: string, entry: Pending) { if (!entry.persisted) return for (let attempt = 1; attempt <= 3; attempt++) { @@ -129,8 +144,16 @@ async function forgetRequest(id: string, entry: Pending) { await new Promise((resolve) => setTimeout(resolve, 200 * attempt)) } } + answered.add(id) } +/** Cards answered (or saved) whose kept request could not be removed yet: recovery removes them, never saves them. */ +const answered = new Set() + +/** The answered cards whose kept request is still to remove, and a way to mark one removed. */ +export const answeredIds = () => new Set(answered) +export const forgotten = (id: string) => void answered.delete(id) + /** A closed card nobody answered: save a requested one as before cards, record it, tell its clients. */ async function settle(id: string, entry: Pending, message: string, why: "expired" | "closed", here: string | undefined): Promise { // The Bus is per project: announced from another project's request it would reach the wrong clients. The card's own @@ -285,6 +308,20 @@ export async function ask(input: { if (existing.root === input.root && existing.card.text.toLowerCase() === input.draft.text.toLowerCase()) { // Asked again: the card moves to the session that asked (clients show cards per session), and a client that // missed it gets it back. + // Asked again by a "remember": its request must be on disk before the tool stands down for this card. If it cannot + // be written, or the card closed during the write, no card is returned, the tool saves as before cards, and the + // card is left as it was. Nothing below changes the card until then. + if (input.unanswered && input.request) { + const kept = await persistRequest(existing, input.request, input.directory) + if (kept && !pending.has(existing.card.id)) { + await RulesStore.update(existing.root, (s) => { + s.remember_requests = s.remember_requests.filter((r) => r.card !== existing.card.id) + }).catch((error) => log.warn("remember request not cleared", { id: existing.card.id, error })) + return undefined + } + if (!kept) return undefined + existing.request = input.request + } // The latest request's memory block and fallback win, whichever session asked. if (input.draft.memory) { existing.memory = input.draft.memory @@ -299,7 +336,6 @@ export async function ask(input: { existing.learn = input.learn await publish(RuleEvents.Event.Updated, existing.card) } else await publish(RuleEvents.Event.Asked, existing.card) - if (input.unanswered && input.request) await persistRequest(existing, input.request) RulesTelemetry.shown(existing.card, existing.trigger, true) return existing.card } @@ -307,9 +343,12 @@ export async function ask(input: { const entry: Pending = { card, root: input.root, directory: input.directory, learn: input.learn, at: Date.now(), trigger, edits: 0, sourceSession: input.sessionID, memory: input.draft.memory, unanswered: input.unanswered, scope: state.scope, + ...(input.unanswered && input.request ? { request: input.request } : {}), } + // A requested card is shown only once its request is on disk: otherwise there is no card, and the tool saves as + // before cards (a card the tool had stood down for, lost with a crash, would lose the request). + if (input.unanswered && input.request && !(await persistRequest(entry, input.request))) return undefined pending.set(card.id, entry) - if (input.unanswered && input.request) await persistRequest(entry, input.request) RulesTelemetry.shown(card, trigger, false) await publish(RuleEvents.Event.Asked, card) return card @@ -419,6 +458,7 @@ export async function reply(id: string, input: { action: ReplyAction; text?: str } entry.busy = true + entry.enqueued = false let message: string try { const done = await act(entry, input.action) @@ -431,22 +471,48 @@ export async function reply(id: string, input: { action: ReplyAction; text?: str entry.busy = false track("not_open") if (entry.closed) return (await settleIfClosed(id, entry))! - pending.delete(id) const message = "The account or workspace changed since this proposal was shown: it was not sent." - await settle(id, entry, message, "closed", entry.directory) + await close(id, entry, message, "closed", entry.directory) return { ok: false, code: "not_open", error: message } } const code = error instanceof RefusedError ? "refused" : "failed" if (code === "failed") log.warn("rule proposal reply failed", { action: input.action, error }) track(code) + // The card stays open. A send whose proposal is no longer queued (refused, or the enqueue never happened) has no + // request on disk any more: it is kept again, still busy, so nothing closes the card mid-write. If that cannot be + // written, the card closes so its fallback saves the request now. A proposal still queued keeps it ended. + // An enqueue whose write landed but whose call still threw (the mirror write after it failed) ended the request on + // disk: the record's absence is what says so, not whether the call returned. + if (entry.persisted && !entry.enqueued && entry.request && !(await RulesStore.read(entry.root).then( + (state) => state.remember_requests.some((r) => r.card === id), + () => true, + ))) { + entry.persisted = false + entry.enqueued = true + } + const unqueued = error instanceof RefusedError || !entry.enqueued + // Still queued: the request went to the workspace with the proposal, so the card no longer stands for a "remember". + // Closed unanswered later, it saves nothing locally (that would be a second copy). + if (!unqueued) { + entry.unanswered = undefined + entry.request = undefined + } + if (entry.request && entry.unanswered && !entry.persisted && unqueued && !(await persistRequest(entry, entry.request))) { + entry.busy = false + if (entry.closed) return (await settleIfClosed(id, entry))! + await close(id, entry, "This proposal was closed: it could not be kept.", "closed", entry.directory) + return { ok: false, code: "not_open", error: `Not done: ${error instanceof Error ? error.message : String(error)}. The request was saved as before cards.` } + } entry.busy = false // Closed while this ran: nothing was saved, so the requested card falls back to saving as before cards. return (await settleIfClosed(id, entry)) ?? { ok: false, code, error: `Not done: ${error instanceof Error ? error.message : String(error)}`, card: entry.card } } finally { entry.busy = false } + // Answered: still this process's until its kept request is gone, so recovery never saves it meanwhile. + settling.add(id) pending.delete(id) - await forgetRequest(id, entry) + await forgetRequest(id, entry).finally(() => settling.delete(id)) // Closed while this ran: the answer saved it, which is what the close was waiting for. entry.closed?.done() await publish(RuleEvents.Event.Replied, { id, sessionID: card.sessionID, action: input.action, message }) @@ -463,7 +529,10 @@ async function act(entry: Pending, action: Exclude): Promis const until = new Date(Date.now() + RulesStore.DISMISS_MS).toISOString() await RulesStore.update(entry.root, (state) => { for (const topic of card.topics) state.dismissed.push({ topic, until }) + // The decision and the end of the request in one write: a crash cannot leave a dismissed request to recover. + state.remember_requests = state.remember_requests.filter((r) => r.card !== card.id) }) + entry.persisted = false message = card.topics.length ? "Not a rule: it won't be proposed again for 30 days." : "Dismissed: nothing was saved." } else if (action === "save_memory") { const { MemoryStore } = await import("../../memory/store") @@ -498,7 +567,10 @@ async function act(entry: Pending, action: Exclude): Promis corrections: card.evidence.correction && quotes ? [{ excerpt: card.evidence.correction.excerpt, corrected_at: card.evidence.correction.at, session_ref: hashRef(entry.sourceSession) }] : [], - }, approve, entry.scope) + }, approve, entry.scope, entry.persisted ? card.id : undefined) + // Queued, and the request ended in the same write: a crash from here cannot turn it into a local save. + entry.persisted = false + entry.enqueued = true const report = await RulesSync.runFresh(entry.root, entry.directory, entry.learn, "card").catch(() => undefined) const sent = RulesSync.outcomeOf(await RulesStore.read(entry.root), report, outboxId) const owner = card.workspace?.owner_name ?? "the workspace owner" diff --git a/packages/opencode/src/altimate/rules/remember.ts b/packages/opencode/src/altimate/rules/remember.ts index 7d7fc7c1ba..3bb2def0b5 100644 --- a/packages/opencode/src/altimate/rules/remember.ts +++ b/packages/opencode/src/altimate/rules/remember.ts @@ -145,39 +145,68 @@ function processAlive(pid: number) { } } +/** Requests this process is saving right now (`recoverRequests` from the start and the refresh can overlap). */ +const recovering = new Set() + /** - * Save the "remember" requests of `directory` whose card died with its process (a crash, a kill), or whose card closed - * while its save failed: the request is saved as before cards. A request is this process's own while it carries this - * process's boot id. Another boot's request is orphaned when its process is gone, its PID is now this process's (a - * restart reused it), or it is older than a card can live (another process reused the PID). Each is claimed in one - * locked write first, so two processes starting together save it once. It is removed once saved, or when its save can - * never succeed; after any other failure it stays for the next start. Runs in that project's instance; never throws. + * Save the "remember" requests of `directory` that no card will settle any more: the request is saved as before cards. + * - This process's own (its boot id) whose card is no longer open or settling: its save failed earlier, retried now. + * - Another boot's whose process is gone, whose PID is now this process's (a restart reused it), or that has had no + * owner for longer than a card can live (another process reused the PID). A claim renews `at`, so a claimed request + * is not taken by another process while its claimant lives. + * Each is claimed in one locked write first, so it is saved once. It is removed once saved, or when its save can never + * succeed; after any other failure it stays, retried on the next refresh or start. Runs in that project's instance. */ export async function recoverRequests(root: string, directory: string) { - const [{ RulesStore }, { CARD_TTL_MS, SaveRefusedError }] = await Promise.all([import("./store"), import("./proposal")]) + const [{ RulesStore }, { CARD_TTL_MS, SaveRefusedError, isOpen, answeredIds, forgotten }] = await Promise.all([import("./store"), import("./proposal")]) const now = Date.now() - const orphaned = (r: import("./store").RememberRequest) => - r.directory === directory && r.boot !== RulesStore.PROCESS_BOOT && - (r.pid === process.pid || !processAlive(r.pid) || !(now - Date.parse(r.at) < CARD_TTL_MS)) - if (!(await RulesStore.read(root)).remember_requests.some(orphaned)) return - const claimed: import("./store").RememberRequest[] = [] + const answered = answeredIds() + const ours = (r: import("./store").RememberRequest) => r.directory === directory && r.boot === RulesStore.PROCESS_BOOT + const orphaned = (r: import("./store").RememberRequest) => { + if (r.directory !== directory || recovering.has(r.card)) return false + if (r.boot === RulesStore.PROCESS_BOOT) return !isOpen(r.card) + return r.pid === process.pid || !processAlive(r.pid) || !(now - Date.parse(r.at) < CARD_TTL_MS) + } + // This process's cards still open: their records are renewed, so no other process takes one as ownerless while this + // one lives (a card can stay open past a day when nothing lists cards). + const stale = (r: import("./store").RememberRequest) => ours(r) && isOpen(r.card) && !answered.has(r.card) && !(now - Date.parse(r.at) < CARD_TTL_MS / 4) + const before = (await RulesStore.read(root)).remember_requests + if (!before.some((r) => orphaned(r) || stale(r) || (ours(r) && answered.has(r.card)))) return + let claimed: import("./store").RememberRequest[] = [] + let settled: string[] = [] + // Claimed ids are marked inside the locked write, so an overlapping recovery in this process cannot claim them too. await RulesStore.update(root, (s) => { - for (const r of s.remember_requests) - if (orphaned(r)) { - Object.assign(r, { pid: process.pid, boot: RulesStore.PROCESS_BOOT }) + claimed = [] + // Answered cards whose record could not be removed then: removed now, never saved. Ids with no record left are done. + settled = [...answered].filter((card) => !s.remember_requests.some((r) => r.card === card && !ours(r))) + s.remember_requests = s.remember_requests.filter((r) => !(ours(r) && answered.has(r.card))) + for (const r of s.remember_requests) { + if (stale(r)) r.at = new Date(now).toISOString() + else if (orphaned(r)) { + Object.assign(r, { pid: process.pid, boot: RulesStore.PROCESS_BOOT, at: new Date(now).toISOString() }) claimed.push({ ...r }) + recovering.add(r.card) } + } + }).catch((error) => { + for (const r of claimed) recovering.delete(r.card) + throw error }) - for (const r of claimed) { - const done = await saveWithoutCard(r).then(() => true, (error) => { - log.warn("recovered remember request not saved", { card: r.card, error }) - return error instanceof SaveRefusedError - }) - // Kept: claimed by this boot, so the next start (another boot) tries it again. - if (!done) continue - await RulesStore.update(root, (s) => { - s.remember_requests = s.remember_requests.filter((x) => x.card !== r.card) - }).catch((error) => log.warn("recovered remember request not cleared", { card: r.card, error })) + for (const card of settled) forgotten(card) + try { + for (const r of claimed) { + const done = await saveWithoutCard(r).then(() => true, (error) => { + log.warn("recovered remember request not saved", { card: r.card, error }) + return error instanceof SaveRefusedError + }) + // Kept: claimed by this boot, retried on the next refresh (or the next start, if this process ends first). + if (!done) continue + await RulesStore.update(root, (s) => { + s.remember_requests = s.remember_requests.filter((x) => x.card !== r.card) + }).catch((error) => log.warn("recovered remember request not cleared", { card: r.card, error })) + } + } finally { + for (const r of claimed) recovering.delete(r.card) } } @@ -226,6 +255,9 @@ export async function proposeFor(input: { if (input.request && state.remember_requests.length >= RulesStore.MAX_REMEMBER_REQUESTS) return void RulesTelemetry.routed(sessionID, via, "requests_full") const result = await RuleTrigger.explicit({ sessionID, root, directory: input.directory, learn, text: content, said, memory: input.memory, unanswered: input.unanswered, request: input.request }) + // No card: its request could not be kept on disk (the limit was reached meanwhile, or the write failed). Saved as + // before cards instead, rather than a card a crash could lose. + if (result.status === "shown" && !result.card) return void RulesTelemetry.routed(sessionID, via, "requests_full") RulesTelemetry.routed( sessionID, via, diff --git a/packages/opencode/src/altimate/rules/service.ts b/packages/opencode/src/altimate/rules/service.ts index e3db7d5e8a..190af33f12 100644 --- a/packages/opencode/src/altimate/rules/service.ts +++ b/packages/opencode/src/altimate/rules/service.ts @@ -144,8 +144,17 @@ export async function start(ctx: InstanceContext): Promise { await stop() } } + // "Remember" requests no card will settle: another process's that died, or this one's whose save failed. + const recover = Instance.bind(async () => { + if (disposed) return + // Cards past their day close first (their fallback saves them), then the leftovers are recovered. + const [{ RulesRemember }, { RuleProposal }] = await Promise.all([import("./remember"), import("./proposal")]) + RuleProposal.list(ctx.directory) + await RulesRemember.recoverRequests(root, ctx.directory) + }) const refresh = Instance.bind(async () => { if (disposed) return + await recover().catch((error) => log.warn("rules remember recovery failed", { error: error instanceof Error ? error.message : String(error) })) const learn = await learnConfig(ctx) if (!RulesSync.rulesEnabled(learn) || !(await linked(root, ctx.directory))) return void (await followLearning(learn)) const report = await syncNow(root, ctx.directory, learn).catch((error) => { @@ -160,11 +169,6 @@ export async function start(ctx: InstanceContext): Promise { void work().catch((error) => log.warn(`rules ${what} failed`, { error: error instanceof Error ? error.message : String(error) })) await followLearning(await learnConfig(ctx)).catch((error) => log.warn("rules learning setup failed", { error: String(error) })) safely("refresh", refresh)() - // "Remember" requests whose card died with another process (a crash): saved as before cards, into this project. - safely("remember recovery", Instance.bind(async () => { - const { RulesRemember } = await import("./remember") - await RulesRemember.recoverRequests(root, ctx.directory) - }))() const timer = setInterval(safely("refresh", refresh), RulesSync.REFRESH_MS) timer.unref?.() const onIdle = Instance.bind(async (sessionID: string) => { diff --git a/packages/opencode/src/altimate/rules/sync.ts b/packages/opencode/src/altimate/rules/sync.ts index 01fba2b003..8e65d9072d 100644 --- a/packages/opencode/src/altimate/rules/sync.ts +++ b/packages/opencode/src/altimate/rules/sync.ts @@ -31,8 +31,11 @@ export const REFRESH_MS = 5 * 60_000 export const SESSION_WAIT_MS = 2_000 export const MAX_PROPOSALS = 50 const MAX_USAGE_DELTA = 50 -/** How long a reported (session, rule) pair is remembered after its session's snapshot was last written. */ -const USAGE_PAIR_MS = 30 * 24 * 60 * 60_000 +/** How recently a session's snapshot must have been written for its rules to be counted. */ +const USAGE_COUNT_MS = 30 * 24 * 60 * 60_000 +/** How long a reported (session, rule) pair is remembered after its session's snapshot was last written: longer than + * `USAGE_COUNT_MS`, so nothing countable is ever forgotten. */ +const USAGE_PAIR_MS = 3 * USAGE_COUNT_MS /** The server's per-batch limit on usage items (`MAX_USAGE_ITEMS`). */ const MAX_USAGE_ITEMS = 500 @@ -529,7 +532,8 @@ export async function enqueueMany( root: string, all: readonly { item: ProposalItem; approve?: "approve" | "approve_anyway" }[], scope?: Scope, - options: { upTo?: boolean } = {}, + /** `forget`: the "remember" card these come from; its kept request ends in the same write that queues them. */ + options: { upTo?: boolean; forget?: string } = {}, ): Promise { let items = all let ids = items.map(() => randomUUID()) @@ -549,13 +553,20 @@ export async function enqueueMany( ) items.forEach(({ item, approve }, index) => state.outbox.push({ id: ids[index], created_at: at, attempts: 0, item, ...(approve ? { approve } : {}) })) + if (options.forget) state.remember_requests = state.remember_requests.filter((r) => r.card !== options.forget) }) return ids } /** Queue one proposal; it is sent by the next push. Returns its outbox id. */ -export async function enqueue(root: string, item: ProposalItem, approve?: "approve" | "approve_anyway", scope?: Scope): Promise { - return (await enqueueMany(root, [{ item, ...(approve ? { approve } : {}) }], scope))[0] +export async function enqueue( + root: string, + item: ProposalItem, + approve?: "approve" | "approve_anyway", + scope?: Scope, + forget?: string, +): Promise { + return (await enqueueMany(root, [{ item, ...(approve ? { approve } : {}) }], scope, forget ? { forget } : {}))[0] } /** @@ -620,20 +631,24 @@ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: nu } } const [{ readShownSnapshots, lessonTextHash, snapshotFiles, snapshotFile }, { analyzeRecurrence }] = await Promise.all([import("../learn/delivery"), import("../learn/recurrence")]) - // A reported pair is kept while its session's snapshot exists and was written within `USAGE_PAIR_MS`: a resumed - // session comes back into the newest snapshots, and its pairs must still read as reported. Snapshots are never - // deleted, so age bounds the list; a session resumed after that long may be counted once more. + // A session counts while its snapshot was written within `USAGE_COUNT_MS`; its reported pairs are remembered for + // `USAGE_PAIR_MS`, which is longer, so a pair is never forgotten while its session can still be counted, and a session + // resumed within that longer time is not counted again. One resumed after more than `USAGE_PAIR_MS` may be counted + // once more (accepted: an advisory counter). Snapshots are never deleted, so the window also bounds the list. Without + // the file times nothing is counted this run. const files = await snapshotFiles(root).catch(() => undefined) const sessionOf = (pair: string) => pair.slice(0, pair.lastIndexOf("|")) const now = Date.now() - const live = (pair: string) => { - const written = files!.get(snapshotFile(sessionOf(pair))) - return written !== undefined && now - written < USAGE_PAIR_MS + const within = (session: string, window: number) => { + const written = files?.get(snapshotFile(session)) + return written !== undefined && now - written < window } + const countable = (session: string) => within(session, USAGE_COUNT_MS) + const remembered = (pair: string) => within(sessionOf(pair), USAGE_PAIR_MS) const unpruned = await RulesStore.read(root) - if (files && unpruned.usage_reported.some((pair) => !live(pair))) + if (files && unpruned.usage_reported.some((pair) => !remembered(pair))) await RulesStore.update(root, (s) => { - s.usage_reported = s.usage_reported.filter(live) + s.usage_reported = s.usage_reported.filter(remembered) }) const state = await RulesStore.read(root) if (!state.rules.length) return { items: 0 } @@ -646,6 +661,7 @@ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: nu // even when the session was reported after turn 1. A rule counts at most `MAX_USAGE_DELTA` sessions per report, and // a batch at most `MAX_USAGE_ITEMS` rules: the rest waits for the next report. for (const snapshot of snapshots) { + if (!countable(snapshot.session)) continue // Only against the version the session was shown: the lesson id (from `rule_key`) survives an approved edit, the // text does not. A session shown an earlier version is not counted against the current one. const shown = [...new Set(snapshot.shown @@ -701,15 +717,18 @@ export async function reportUsage(root: string, ctx: Ready): Promise<{ items: nu // (the next run recounts from what is left). let stale = false const after = await RulesStore.update(root, (s) => { + // A batch saved meanwhile is sent instead, only if it is this run's account and workspace's (checked below). + if (!ownedBy(s, ctx)) return void (stale = true) if (s.pending_usage) return const reportedNow = new Set(s.usage_reported) stale = pairs.some((pair) => reportedNow.has(pair)) || Object.keys(corrected).some((key) => (s.corrected_reported[key] ?? 0) !== (state.corrected_reported[key] ?? 0)) - if (!ownedBy(s, ctx)) stale = true if (!stale) s.pending_usage = batch }) if (stale) return { items: 0 } const saved = after.pending_usage ?? batch + // Never sent with this run's credential when it was built for another scope. + if (saved.scope && !RulesStore.sameScope(saved.scope, ctx.scope)) return { items: 0 } try { await sendUsage(root, ctx, saved) } catch (error) { diff --git a/packages/opencode/test/altimate/learn/delivery.test.ts b/packages/opencode/test/altimate/learn/delivery.test.ts index 10009db846..db27bd0134 100644 --- a/packages/opencode/test/altimate/learn/delivery.test.ts +++ b/packages/opencode/test/altimate/learn/delivery.test.ts @@ -505,6 +505,44 @@ test("rules added to a session are reported by tier, as counts only; a cached an expect(JSON.stringify(events)).not.toContain("shipping") }) +test("a resumed session keeps a team rule only while it is still in the mirror; an edit keeps its frozen wording", async () => { + await approve([lesson("L-0301", "Keep the project rules explicit.", { pinned: true })]) + await approve([lesson("L-0302", "Name staging models stg_.", { pinned: true })], Store.WORKSPACE_RULES_STORE) + const env = process.env.ALTIMATE_DISABLE_WORKSPACE + try { + delete process.env.ALTIMATE_DISABLE_WORKSPACE + const first = await new Delivery(root, { ...limits, core_lessons: 2 }).prepare("kept", "first", "staging") + expect(first.section).toContain("Name staging models stg_.") + // An approved edit keeps the rule's id: the session keeps the wording it was shown. + await approve([lesson("L-0302", "Name staging models stg___.", { pinned: true })], Store.WORKSPACE_RULES_STORE) + expect(await new Delivery(root, { ...limits, core_lessons: 2 }).section("kept")).toBe(first.section) + // Signed out, unlinked or another workspace: the mirror no longer holds it, so the cached section is not served. + await approve([], Store.WORKSPACE_RULES_STORE) + const gone = new Delivery(root, { ...limits, core_lessons: 2 }) + expect(await gone.section("kept")).not.toContain("Name staging models") + expect((await gone.prepare("kept", "first", "staging")).section).not.toContain("Name staging models") + } finally { + if (env === undefined) delete process.env.ALTIMATE_DISABLE_WORKSPACE + else process.env.ALTIMATE_DISABLE_WORKSPACE = env + } +}) + +test("a mirror that cannot be read keeps a resumed session's cached team rules", async () => { + await approve([lesson("L-0401", "Keep the project rules explicit.", { pinned: true })]) + await approve([lesson("L-0402", "Name staging models stg_.", { pinned: true })], Store.WORKSPACE_RULES_STORE) + const env = process.env.ALTIMATE_DISABLE_WORKSPACE + try { + delete process.env.ALTIMATE_DISABLE_WORKSPACE + const first = await new Delivery(root, { ...limits, core_lessons: 2 }).prepare("unreadable", "first", "staging") + expect(first.section).toContain("Name staging models") + await fs.writeFile(Store.paths(root, Store.WORKSPACE_RULES_STORE).approved, "{ not json") + expect(await new Delivery(root, { ...limits, core_lessons: 2 }).section("unreadable")).toBe(first.section) + } finally { + if (env === undefined) delete process.env.ALTIMATE_DISABLE_WORKSPACE + else process.env.ALTIMATE_DISABLE_WORKSPACE = env + } +}) + test("a resumed session shown team rules drops them while workspaces are off", async () => { await approve([lesson("L-0201", "Keep the project rules explicit.", { pinned: true })]) await approve([lesson("L-0202", "Name staging models stg_.", { pinned: true })], Store.WORKSPACE_RULES_STORE) diff --git a/packages/opencode/test/altimate/rules/rules.test.ts b/packages/opencode/test/altimate/rules/rules.test.ts index 0156d2cd0b..6f1e8951c6 100644 --- a/packages/opencode/test/altimate/rules/rules.test.ts +++ b/packages/opencode/test/altimate/rules/rules.test.ts @@ -1039,14 +1039,14 @@ describe("the workspace API, by path", () => { expect(state.usage_reported).toEqual([]) }) - test("a reported pair is let go once its session's snapshot is older than a month", async () => { + test("a reported pair is let go once its session's snapshot is older than three months", async () => { const root = await tmp() answer = () => Response.json(syncBody([{ ...rule("aaaaaaaaaaaa", "Name staging models stg_."), public_id: "p-a" }])) await RulesSync.pull(root, ready()) const dir = path.join(root, ".altimate-code", "learn", ".sessions") await fs.mkdir(dir, { recursive: true }) for (const session of ["old", "recent"]) await fs.writeFile(path.join(dir, `${Store.sha256(session)}.json`), "{}") - const longAgo = new Date(Date.now() - 40 * 24 * 60 * 60_000) + const longAgo = new Date(Date.now() - 100 * 24 * 60 * 60_000) await fs.utimes(path.join(dir, `${Store.sha256("old")}.json`), longAgo, longAgo) await RulesStore.update(root, (s) => { s.usage_reported = ["old|p-a", "recent|p-a"] }) await RulesSync.reportUsage(root, ready()) @@ -1067,6 +1067,59 @@ describe("the workspace API, by path", () => { } }) + test("a session older than the window is neither counted nor remembered, so it is not counted again on every sync", async () => { + const root = await tmp() + answer = () => Response.json(syncBody([{ ...rule("aaaaaaaaaaaa", "Name staging models stg_."), public_id: "p-a" }])) + await RulesSync.pull(root, ready()) + const dir = path.join(root, ".altimate-code", "learn", ".sessions") + await fs.mkdir(dir, { recursive: true }) + const lesson = { id: RulesStore.lessonId("R-aaaaaaaaaaaa"), text: "Name staging models stg_.", tags: [], scope: "project", helpful: 0, harmful: 0, applied: 0, created: "2026-10-27T00:00:00Z", updated: "2026-10-27T00:00:00Z" } + const file = path.join(dir, `${Store.sha256("ancient")}.json`) + await fs.writeFile(file, JSON.stringify({ + version: 1, session: "ancient", firstMessage: "m", query: "q", touchedPaths: [], section: "", requests: [], requestParts: [], compactions: [], counted: [], + shown: [{ name: RulesStore.WORKSPACE_RULES_STORE, lesson, tier: "core", at: "2026-09-01T00:00:00Z", queryHash: "h" }], + })) + const longAgo = new Date(Date.now() - 40 * 24 * 60 * 60_000) + await fs.utimes(file, longAgo, longAgo) + await RulesStore.update(root, (s) => { s.usage_reported = ["ancient|p-a"] }) + seen.length = 0 + answer = () => Response.json({ duplicate: false, applied_items: 1, skipped_items: 0 }) + for (let run = 0; run < 3; run++) await RulesSync.reportUsage(root, ready()) + expect(seen.filter((r) => r.path === "/usage")).toEqual([]) + // Remembered longer than it can be counted: resumed within that time, it is not counted again. + expect((await RulesStore.read(root)).usage_reported).toEqual(["ancient|p-a"]) + }) + + test("a usage batch another scope saved while this run counted is not sent with this run's credential", async () => { + const root = await tmp() + answer = () => Response.json(syncBody([{ ...rule("aaaaaaaaaaaa", "Name staging models stg_."), public_id: "p-a" }])) + await RulesSync.pull(root, ready()) + const dir = path.join(root, ".altimate-code", "learn", ".sessions") + await fs.mkdir(dir, { recursive: true }) + const lesson = { id: RulesStore.lessonId("R-aaaaaaaaaaaa"), text: "Name staging models stg_.", tags: [], scope: "project", helpful: 0, harmful: 0, applied: 0, created: "2026-10-27T00:00:00Z", updated: "2026-10-27T00:00:00Z" } + await fs.writeFile(path.join(dir, `${Store.sha256("s-now")}.json`), JSON.stringify({ + version: 1, session: "s-now", firstMessage: "m", query: "q", touchedPaths: [], section: "", requests: [], requestParts: [], compactions: [], counted: [], + shown: [{ name: RulesStore.WORKSPACE_RULES_STORE, lesson, tier: "core", at: "2026-10-28T00:00:00Z", queryHash: "h" }], + })) + const recurrence = await import("../../../src/altimate/learn/recurrence") + const real = recurrence.analyzeRecurrence + // While this run counts, another process saves a batch for someone else's scope. + const spy = spyOn(recurrence, "analyzeRecurrence").mockImplementation((async (...args: Parameters) => { + await RulesStore.update(root, (s) => { + s.pending_usage = { batch_id: "batch-foreign", scope: { ...scope(), account: "someone-else" }, items: [{ public_id: "p-a", applied: 1, helpful: 0, harmful: 0, corrected_again: 0 }], pairs: ["s-other|p-a"], corrected: {} } + }) + return real(...args) + }) as never) + seen.length = 0 + try { + expect(await RulesSync.reportUsage(root, ready())).toEqual({ items: 0 }) + } finally { + spy.mockRestore() + } + expect(seen.filter((r) => r.path === "/usage")).toEqual([]) + expect((await RulesStore.read(root)).pending_usage?.batch_id).toBe("batch-foreign") + }) + test("too many proposals at once say so", async () => { const root = await tmp() const many = Array.from({ length: RulesStore.MAX_OUTBOX + 1 }, (_, i) => ({ item: item(`Rule ${i}.`) })) @@ -1880,13 +1933,14 @@ describe("cards across the feature's edges", () => { s.remember_requests.push({ card: "c-dead", text: "Alias every joined table.", session: "x1", directory: Instance.directory, via: "training", training: { name: "alias-joins" }, pid: dead.pid, boot: "earlier", at: now }) // A restarted server can get the crashed one's PID (PID 1 in a container): its boot id tells them apart. s.remember_requests.push({ card: "c-restart", text: "Quote reserved identifiers.", session: "x3", directory: Instance.directory, via: "training", training: { name: "quote-reserved" }, pid: process.pid, boot: "before-restart", at: now }) - s.remember_requests.push({ card: "c-live", text: "Still open here.", session: "x2", directory: Instance.directory, via: "training", pid: process.pid, boot: RulesStore.PROCESS_BOOT, at: now }) + // Another live process's (our parent), recently written: its own card settles it. + s.remember_requests.push({ card: "c-live", text: "Still open elsewhere.", session: "x2", directory: Instance.directory, via: "training", pid: process.ppid, boot: "another-live-process", at: now }) }) await RulesRemember.recoverRequests(root, Instance.directory) await RulesRemember.recoverRequests(root, Instance.directory) expect((await TrainingStore.get("project", "rule", "alias-joins"))?.content).toContain("Alias every joined table.") expect((await TrainingStore.get("project", "rule", "quote-reserved"))?.content).toContain("Quote reserved identifiers.") - // A card this process still shows is its own to save. + // A live process's fresh request is that process's to save. expect((await RulesStore.read(root)).remember_requests.map((r) => r.card)).toEqual(["c-live"]) } }) } finally { @@ -1926,6 +1980,220 @@ describe("cards across the feature's edges", () => { } }) + test("a claim renews the request, and this process retries its own leftover request once its card is gone, never an open one", async () => { + await using dir = await tmpdir({ git: true }) + const twoDaysAgo = new Date(Date.now() - 2 * RuleProposal.CARD_TTL_MS).toISOString() + try { + await Instance.provide({ directory: dir.path, fn: async () => { + const root = await fs.realpath(Instance.worktree) + await RulesStore.update(root, (s) => { + // Another boot's, its PID alive (our parent), older than a card can live: orphaned, claimed by this process. + s.remember_requests.push({ card: "c-stale", text: "Alias every joined table.", session: "q1", directory: Instance.directory, via: "training", training: { name: "alias-stale" }, pid: process.ppid, boot: "elsewhere", at: twoDaysAgo }) + }) + const save = spyOn(TrainingStore, "save").mockRejectedValueOnce(new Error("EBUSY: resource busy")) + try { + await RulesRemember.recoverRequests(root, Instance.directory) + } finally { + save.mockRestore() + } + // Claimed: owned by this boot with a fresh time, so no other live process takes it while this one runs. + const [kept] = (await RulesStore.read(root)).remember_requests + expect(kept).toMatchObject({ card: "c-stale", boot: RulesStore.PROCESS_BOOT, pid: process.pid }) + expect(Date.now() - Date.parse(kept.at)).toBeLessThan(60_000) + // Its card is not open here: the next refresh in this same process retries it. + await RulesRemember.recoverRequests(root, Instance.directory) + expect((await TrainingStore.get("project", "rule", "alias-stale"))?.content).toContain("Alias every joined table.") + expect((await RulesStore.read(root)).remember_requests).toEqual([]) + // A card this process still shows is never recovered from under it. + RuleProposal.setRendererForTest(Instance.directory) + await RulesStore.update(root, (s) => Object.assign(s, linked())) + const card = (await RuleProposal.ask({ sessionID: "q2", root, directory: Instance.directory, learn: undefined, unanswered: async () => {}, request: { session: "q2", via: "training", training: { name: "still-open" } }, draft: { text: "Still open here.", applies_to: [], change: "add", origin: "explicit", evidence: { sessions: 1 }, topics: [] } }))! + await RulesRemember.recoverRequests(root, Instance.directory) + expect(await TrainingStore.get("project", "rule", "still-open")).toBeUndefined() + expect((await RulesStore.read(root)).remember_requests.map((r) => r.card)).toEqual([card.id]) + await RuleProposal.drop(Instance.directory) + } }) + } finally { + RuleProposal.setRendererForTest(undefined) + await disposeAllInstances() + } + }) + + test("a requested card is shown only once its request is on disk; otherwise there is no card", async () => { + const root = await linkedRoot() + const update = RulesStore.update + const spy = spyOn(RulesStore, "update").mockImplementationOnce((async () => { + throw new Error("Timed out waiting for lock: learn-state") + }) as never) + try { + const card = await RuleProposal.ask({ sessionID: "p1", root, directory: root, learn: undefined, unanswered: async () => {}, request: { session: "p1", via: "training" }, draft: { text: "Quote reserved words.", applies_to: [], change: "add", origin: "explicit", evidence: { sessions: 1 }, topics: [] } }) + expect(card).toBeUndefined() + expect(RuleProposal.list(root, "p1")).toEqual([]) + } finally { + spy.mockRestore() + } + void update + // And through the save tools: no card, so the tool saves as before. + const full = spyOn(RulesStore, "update").mockImplementationOnce((async () => { + throw new Error("Timed out waiting for lock: learn-state") + }) as never) + try { + expect(await RulesRemember.proposeFor({ root, directory: root, learn: undefined, content: "Always use CTEs in staging models.", sessionID: "p2", said: "Remember as a rule: always use CTEs in staging models.", unanswered: async () => {}, request: { session: "p2", via: "training" } })).toBeUndefined() + } finally { + full.mockRestore() + } + }) + + test("dismissing or sending a requested card ends its request in the same write as the decision", async () => { + const root = await linkedRoot() + const draft = (text: string) => ({ text, applies_to: [], change: "add" as const, origin: "explicit" as const, evidence: { sessions: 1 }, topics: [] }) + const dismissed = (await RuleProposal.ask({ sessionID: "w1", root, directory: root, learn: undefined, unanswered: async () => {}, request: { session: "w1", via: "training" }, draft: draft("Prefix seeds with seed_.") }))! + const sent = (await RuleProposal.ask({ sessionID: "w2", root, directory: root, learn: undefined, unanswered: async () => {}, request: { session: "w2", via: "training" }, draft: draft("Quote reserved words.") }))! + // Any write after the decision fails: the request must already be gone with the decision itself. + let seenAtSync: unknown + const run = spyOn(RulesSync, "runFresh").mockImplementation((async () => { + const state = await RulesStore.read(root) + seenAtSync = { requests: state.remember_requests.map((r) => r.card), outbox: state.outbox.map((e) => e.item.text) } + return undefined + }) as never) + try { + await RuleProposal.reply(sent.id, { action: "send" }) + } finally { + run.mockRestore() + } + expect(seenAtSync).toEqual({ requests: [dismissed.id], outbox: ["Quote reserved words."] }) + const update = RulesStore.update + let calls = 0 + const after = spyOn(RulesStore, "update").mockImplementation((async (...args: Parameters) => { + if (calls++ > 0) throw new Error("disk gone") + return update(...args) + }) as never) + try { + await RuleProposal.reply(dismissed.id, { action: "dismiss" }) + } finally { + after.mockRestore() + } + expect((await RulesStore.read(root)).remember_requests).toEqual([]) + }) + + test("a card answered while its request cannot be removed is never saved again by recovery", async () => { + // Not linked: the card offers "Save for this project", an answer that succeeds without a rules-state write. + const root = await tmp() + const card = (await RuleProposal.ask({ sessionID: "a1", root, directory: root, learn: undefined, unanswered: async () => {}, request: { session: "a1", via: "training", training: { name: "answered-once" } }, draft: { text: "Alias joined tables.", applies_to: [], change: "add", origin: "explicit", evidence: { sessions: 1 }, topics: [] } }))! + const saves: string[] = [] + const save = spyOn(TrainingStore, "save").mockImplementation((async (input: { name: string }) => void saves.push(input.name)) as never) + const update = RulesStore.update + const stuck = spyOn(RulesStore, "update").mockImplementation((async () => { + throw new Error("Timed out waiting for lock: learn-state") + }) as never) + try { + expect(await RuleProposal.reply(card.id, { action: "save_local" })).toMatchObject({ ok: true }) + } finally { + stuck.mockRestore() + } + void update + try { + expect((await RulesStore.read(root)).remember_requests.map((r) => r.card)).toEqual([card.id]) + // The next refresh removes it instead of saving it a second time. + await RulesRemember.recoverRequests(root, root) + expect((await RulesStore.read(root)).remember_requests).toEqual([]) + expect(saves).toEqual([RuleProposal.slug("Alias joined tables.")]) + } finally { + save.mockRestore() + } + }) + + test("a send the workspace refuses keeps the card open and its request back on disk", async () => { + const root = await linkedRoot() + const card = (await RuleProposal.ask({ sessionID: "r1", root, directory: root, learn: undefined, unanswered: async () => {}, request: { session: "r1", via: "training" }, draft: { text: "Prefix seeds with seed_.", applies_to: [], change: "add", origin: "explicit", evidence: { sessions: 1 }, topics: [] } }))! + const run = spyOn(RulesSync, "runFresh").mockResolvedValue(undefined as never) + const outcome = spyOn(RulesSync, "outcomeOf").mockReturnValue({ status: "sent", row: { id: "x", text: "", status: "invalid", error: "too vague" } } as never) + try { + expect(await RuleProposal.reply(card.id, { action: "send" })).toMatchObject({ ok: false, code: "refused" }) + } finally { + run.mockRestore() + outcome.mockRestore() + } + expect(RuleProposal.list(root, "r1").map((c) => c.id)).toEqual([card.id]) + expect((await RulesStore.read(root)).remember_requests.map((r) => r.card)).toEqual([card.id]) + await RuleProposal.drop(root) + }) + + test("a send that fails after its proposal was queued does not keep its request again: the queue has it", async () => { + const root = await linkedRoot() + const saved: string[] = [] + const card = (await RuleProposal.ask({ sessionID: "q9", root, directory: root, learn: undefined, unanswered: async (text) => void saved.push(text), request: { session: "q9", via: "training" }, draft: { text: "Name seeds seed_.", applies_to: [], change: "add", origin: "explicit", evidence: { sessions: 1 }, topics: [] } }))! + const run = spyOn(RulesSync, "runFresh").mockResolvedValue(undefined as never) + const outcome = spyOn(RulesSync, "outcomeOf").mockImplementation(() => { throw new Error("state unreadable") }) + try { + expect(await RuleProposal.reply(card.id, { action: "send" })).toMatchObject({ ok: false, code: "failed" }) + } finally { + run.mockRestore() + outcome.mockRestore() + } + const state = await RulesStore.read(root) + expect(state.outbox.map((e) => e.item.text)).toEqual(["Name seeds seed_."]) + expect(state.remember_requests).toEqual([]) + await RuleProposal.drop(root) + expect(saved).toEqual([]) + }) + + test("a send whose queue write landed but whose call failed is treated as queued, not kept again", async () => { + const root = await linkedRoot() + const saved: string[] = [] + const card = (await RuleProposal.ask({ sessionID: "m9", root, directory: root, learn: undefined, unanswered: async (text) => void saved.push(text), request: { session: "m9", via: "training" }, draft: { text: "Name macros with a verb.", applies_to: [], change: "add", origin: "explicit", evidence: { sessions: 1 }, topics: [] } }))! + const update = RulesStore.update + let failAfterWrite = true + // The state (outbox and request) is saved, then the mirror write fails. + const spy = spyOn(RulesStore, "update").mockImplementation((async (...args: Parameters) => { + const result = await update(...args) + if (failAfterWrite) { + failAfterWrite = false + throw new Error("EACCES: approved.json") + } + return result + }) as never) + try { + expect(await RuleProposal.reply(card.id, { action: "send" })).toMatchObject({ ok: false, code: "failed" }) + } finally { + spy.mockRestore() + } + const state = await RulesStore.read(root) + expect(state.outbox.map((e) => e.item.text)).toEqual(["Name macros with a verb."]) + expect(state.remember_requests).toEqual([]) + // Queued: closing the card later saves no second, local copy. + await RuleProposal.drop(root) + expect(saved).toEqual([]) + }) + + test("asked again by a remember whose request cannot be written, the card is not handed back", async () => { + const root = await linkedRoot() + const draft = { text: "Name timestamps _at.", applies_to: [], change: "add" as const, origin: "session" as const, evidence: { sessions: 2 }, topics: [], choose: true } + const shown = (await RuleProposal.ask({ sessionID: "g1", root, directory: root, learn: undefined, draft }))! + const spy = spyOn(RulesStore, "update").mockImplementationOnce((async () => { + throw new Error("Timed out waiting for lock: learn-state") + }) as never) + try { + expect(await RuleProposal.ask({ sessionID: "g1", root, directory: root, learn: undefined, unanswered: async () => {}, request: { session: "g1", via: "memory", memory: { id: "conventions/time", tags: [] } }, draft: { ...draft, origin: "explicit", memory: { id: "conventions/time", tags: [] } } })).toBeUndefined() + } finally { + spy.mockRestore() + } + // Left exactly as it was: the memory block and its scope label are not taken over by a request that was not kept. + expect(RuleProposal.list(root, "g1")).toEqual([shown]) + await RuleProposal.drop(root) + }) + + test("this process renews the record of a card it still shows, so no other process takes it as ownerless", async () => { + const root = await linkedRoot() + const card = (await RuleProposal.ask({ sessionID: "n1", root, directory: root, learn: undefined, unanswered: async () => {}, request: { session: "n1", via: "training" }, draft: { text: "Quote reserved words.", applies_to: [], change: "add", origin: "explicit", evidence: { sessions: 1 }, topics: [] } }))! + await RulesStore.update(root, (s) => { s.remember_requests[0].at = new Date(Date.now() - 2 * RuleProposal.CARD_TTL_MS).toISOString() }) + await RulesRemember.recoverRequests(root, root) + const [record] = (await RulesStore.read(root)).remember_requests + expect(record.card).toBe(card.id) + expect(Date.now() - Date.parse(record.at)).toBeLessThan(60_000) + await RuleProposal.drop(root) + }) + test("a card whose fallback fails for a passing reason keeps its request; a refused save clears it", async () => { const root = await linkedRoot() const draft = (text: string) => ({ text, applies_to: [], change: "add" as const, origin: "explicit" as const, evidence: { sessions: 1 }, topics: [] }) From 2758e4ae3add5c068e66d779e200144619bfef76 Mon Sep 17 00:00:00 2001 From: Sarav Date: Fri, 30 Oct 2026 16:25:39 +0530 Subject: [PATCH 19/20] fix: workspace learning setting ignored when workspaces are off - `learningDecision` uses a saved workspace's "Learn from sessions" only while `workspacesOn(env)`; with `ALTIMATE_DISABLE_WORKSPACE=1` capture no longer starts and the source is no longer reported as "workspace" - Test with a saved workspace and `learning_enabled: true` under the kill switch Co-Authored-By: Claude Opus 5.5 --- packages/opencode/src/altimate/rules/trigger.ts | 4 ++-- packages/opencode/test/altimate/rules/rules.test.ts | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/altimate/rules/trigger.ts b/packages/opencode/src/altimate/rules/trigger.ts index 3b4a6a1e84..4d3c2805df 100644 --- a/packages/opencode/src/altimate/rules/trigger.ts +++ b/packages/opencode/src/altimate/rules/trigger.ts @@ -12,7 +12,7 @@ // an ADD becomes an add card, an EDIT or REMOVE of a team rule becomes an edit or retire card, and a // HELPFUL on a team rule (the rule already says it) makes no card. import { Log } from "@/util/log" -import { booleanFlag, learnEnabled } from "../learn/config" +import { booleanFlag, learnEnabled, workspacesOn } from "../learn/config" import { lint } from "../learn/curator" import { readShownSnapshot } from "../learn/delivery" import { buildDigest, type DigestSource } from "../learn/digest" @@ -54,7 +54,7 @@ export function learningDecision(learn: LearnConfig, state: RulesStore.State, en if (learn?.capture_policy && learn.capture !== undefined) return { on: learn.capture, source: "organization" } if (state.personal_learning !== undefined) return { on: state.personal_learning, source: "you" } if (learn?.capture !== undefined) return { on: learn.capture, source: "you" } - if (state.workspace) return { on: state.settings?.learning_enabled === true, source: "workspace" } + if (state.workspace && workspacesOn(env)) return { on: state.settings?.learning_enabled === true, source: "workspace" } return { on: false, source: "default" } } diff --git a/packages/opencode/test/altimate/rules/rules.test.ts b/packages/opencode/test/altimate/rules/rules.test.ts index 6f1e8951c6..222fefc701 100644 --- a/packages/opencode/test/altimate/rules/rules.test.ts +++ b/packages/opencode/test/altimate/rules/rules.test.ts @@ -1140,6 +1140,13 @@ describe("learning decision", () => { expect(learningDecision(undefined, state(), { ALTIMATE_LEARN_CAPTURE: "yes" })).toEqual({ on: true, source: "workspace" }) expect(learningDecision({ enabled: false }, state(), { ALTIMATE_LEARN: "1" })).toEqual({ on: true, source: "workspace" }) }) + + test("with workspaces off, a saved workspace's Learn from sessions decides nothing", () => { + const saved = state({ settings: { learning_enabled: true } } as Partial) + expect(learningDecision(undefined, saved, {})).toEqual({ on: true, source: "workspace" }) + expect(learningDecision(undefined, saved, { ALTIMATE_DISABLE_WORKSPACE: "1" })).toEqual({ on: false, source: "default" }) + expect(learningDecision({ capture: true }, saved, { ALTIMATE_DISABLE_WORKSPACE: "1" })).toEqual({ on: true, source: "you" }) + }) }) describe("rule or memory, edge wording", () => { From 3f2f127e74b233ffcba70b2e40b77427242978b1 Mon Sep 17 00:00:00 2001 From: Sarav Date: Fri, 30 Oct 2026 18:36:34 +0530 Subject: [PATCH 20/20] fix: bootstrap keeps a candidate that changed while its rules were sent - `stagedRules` records the candidate file it listed the rules from (`Staged.candidate`) - `Store.reject` takes `ifCandidate` and keeps the candidate when it no longer reads exactly that, checked inside the learn lock - `sendStaged` discards only that candidate, and says when a changed one was kept - Test: an untouched candidate is discarded; one a reflection wrote to meanwhile is kept with its new rule Co-Authored-By: Claude Opus 5.5 --- packages/opencode/src/altimate/learn/store.ts | 7 +++- .../src/altimate/rules/from-history.ts | 22 +++++++---- .../test/altimate/rules/rules.test.ts | 37 +++++++++++++++++-- 3 files changed, 53 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/altimate/learn/store.ts b/packages/opencode/src/altimate/learn/store.ts index 575977418c..5eac1634a4 100644 --- a/packages/opencode/src/altimate/learn/store.ts +++ b/packages/opencode/src/altimate/learn/store.ts @@ -742,11 +742,14 @@ export async function rollback(root: string, name: string): Promise<{ restored: }) } -export async function reject(root: string, name: string): Promise { +/** `ifCandidate`: discard only while the candidate file still reads exactly this; a changed candidate is kept (false). */ +export async function reject(root: string, name: string, opts: { ifCandidate?: string } = {}): Promise { assertWritable(name) return transaction(root, async () => { const p = paths(root, name) - const hasCandidate = (await readCandidate(root, name)) !== undefined + const current = await readCandidate(root, name) + if (opts.ifCandidate !== undefined && current !== opts.ifCandidate) return false + const hasCandidate = current !== undefined await assertLearnLock(root) await SafeFS.remove(root, p.candidate) await assertLearnLock(root) diff --git a/packages/opencode/src/altimate/rules/from-history.ts b/packages/opencode/src/altimate/rules/from-history.ts index a4b342a060..3f68e0e5e1 100644 --- a/packages/opencode/src/altimate/rules/from-history.ts +++ b/packages/opencode/src/altimate/rules/from-history.ts @@ -7,6 +7,7 @@ // offered: the caller snapshots the candidate first (`candidateTexts`). A candidate this run created is discarded // once everything in it was sent; otherwise, and when the person declines, it stays for `learn show` / `learn promote`. import { lint } from "../learn/curator" +import * as Lessons from "../learn/lesson" import * as Store from "../learn/store" import type { ProposalItem } from "./api" import { RulesStore } from "./store" @@ -29,6 +30,8 @@ export interface Staged { invalid: { text: string; reason: string }[] /** Local rules the run edited or removed: not team proposals, and a reason to keep the candidate. */ localChanges: number + /** The candidate file these were listed from: it is discarded only while it still reads exactly this. */ + candidate: string } /** What this run staged that is not approved yet, as proposals. Undefined when not linked or nothing is new. */ @@ -44,11 +47,12 @@ export async function stagedRules(input: { if (!RulesSync.rulesEnabled(input.learn)) return undefined const state = await RulesStore.read(input.root) if (!state.workspace || !state.scope) return undefined - const [approved, candidate] = await Promise.all([ + const [approved, raw] = await Promise.all([ Store.loadApproved(input.root, input.name), - Store.loadCandidateLessons(input.root, input.name), + Store.readCandidate(input.root, input.name), ]) - if (!candidate) return undefined + if (raw === undefined) return undefined + const candidate = Lessons.parse(raw) const known = new Set([...approved.map((lesson) => lesson.text), ...(input.before ?? [])]) // The candidate starts as a copy of the approved store: a lesson with an approved id is a local rule (edited if its // text changed), and an approved id missing from it is a removal. Neither is a new team rule. @@ -84,6 +88,7 @@ export async function stagedRules(input: { items, invalid, localChanges: edited + removed, + candidate: raw, } } @@ -130,10 +135,11 @@ export async function sendStaged(input: { // Neither queued nor answered: dropped before sending (the account or workspace changed under it). const lost = outcomes.length - queued - refused - sent // The workspace's review queue replaces a candidate this run created, once it holds every rule of it: only when the - // candidate is nothing but the adds that were sent (no local edits or removals, nothing invalid, refused or queued). + // candidate is nothing but the adds that were sent (no local edits or removals, nothing invalid, refused or queued), + // and only while it is still the candidate they were listed from: a reflection that wrote to it meanwhile keeps it. const discard = !input.before && queued === 0 && refused === 0 && lost === 0 && held === 0 && input.staged.invalid.length === 0 && input.staged.localChanges === 0 - if (discard) await Store.reject(input.root, input.name) + const discarded = discard && await Store.reject(input.root, input.name, { ifCandidate: input.staged.candidate }) // Why some stayed queued, as the run said it: sync not ready (not signed in, link lost …), kept back with an error, or // not reached yet behind older proposals. const why = report.context.status !== "ready" @@ -143,9 +149,11 @@ export async function sendStaged(input: { (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 + "." + - (discard + (discarded ? "" - : input.staged.localChanges + : discard + ? " The local candidate changed while these were sent, so it was kept: `learn show`." + : input.staged.localChanges ? ` The local candidate was kept: it also changes ${input.staged.localChanges} local ${input.staged.localChanges === 1 ? "rule" : "rules"} (\`learn show\`, \`learn promote\`).` : " The local candidate was kept: `learn show`.") return { sent, queued, refused, lost, held, message } diff --git a/packages/opencode/test/altimate/rules/rules.test.ts b/packages/opencode/test/altimate/rules/rules.test.ts index 222fefc701..a4105dffd0 100644 --- a/packages/opencode/test/altimate/rules/rules.test.ts +++ b/packages/opencode/test/altimate/rules/rules.test.ts @@ -626,7 +626,7 @@ describe("the workspace API, by path", () => { // The scope this identity resolves to, as an earlier pull would have stored it. await RulesStore.update(root, (s) => Object.assign(s, linked(), { scope: { apiUrl: url, tenant: "t", account: "user:7", datamateId: 1, repoRemote: "https://github.com/acme/demo" } })) const items = Array.from({ length: 60 }, (_, i) => item(`Staged rule ${i}.`)) - const staged = { workspace: { name: "Team", owner: "Olive" }, scope: (await RulesStore.read(root)).scope!, items, invalid: [], localChanges: 0 } + const staged = { workspace: { name: "Team", owner: "Olive" }, scope: (await RulesStore.read(root)).scope!, items, invalid: [], localChanges: 0, candidate: "[]" } const result = await RulesFromHistory.sendStaged({ root, directory: root, learn: { enabled: true }, name: "team-playbook", staged, before: new Set() }) expect(result).toMatchObject({ sent: 50, queued: 10, refused: 0, lost: 0 }) expect(result.message).toContain("10 kept queued (50 proposals are waiting for review)") @@ -888,12 +888,41 @@ describe("the workspace API, by path", () => { answer = (p, body) => p === "/proposals" ? Response.json({ results: body.items.map((it: { text: string }, index: number) => ({ index, status: "pending", public_id: `p-${it.text}`, version: 1 })) }) : p === "/sync" ? Response.json(syncBody([])) : Response.json({ duplicate: false, applied_items: 0, skipped_items: 0 }) - const staged = { workspace: { name: "Team", owner: "Olive" }, scope: user.scope, items: [item("Mine, last in line.")], invalid: [], localChanges: 0 } + const staged = { workspace: { name: "Team", owner: "Olive" }, scope: user.scope, items: [item("Mine, last in line.")], invalid: [], localChanges: 0, candidate: "[]" } const result = await RulesFromHistory.sendStaged({ root, directory: root, learn: { enabled: true }, name: "team-playbook", staged, before: new Set() }) expect(result).toMatchObject({ sent: 1, queued: 0 }) expect((await RulesStore.read(root)).outbox).toEqual([]) }) + test("a candidate this run created is discarded once sent, unless something wrote to it meanwhile", async () => { + const lesson = (id: string, text: string) => + ({ id, text, tags: [], scope: "project", helpful: 0, harmful: 0, applied: 0, created: "2026-01-01T00:00:00Z", updated: "2026-01-01T00:00:00Z" }) + answer = (p, body) => p === "/proposals" + ? Response.json({ results: body.items.map((it: { text: string }, index: number) => ({ index, status: "pending", public_id: `p-${it.text}`, version: 1 })) }) + : p === "/sync" ? Response.json(syncBody([])) : Response.json({ duplicate: false, applied_items: 0, skipped_items: 0 }) + const run = async (meanwhile?: (file: string) => Promise) => { + const root = await tmp() + using user = asUser(7) + await RulesStore.update(root, (s) => Object.assign(s, linked(), { scope: user.scope })) + const file = Store.paths(root, "team-playbook").candidate + await fs.mkdir(path.dirname(file), { recursive: true }) + await fs.writeFile(file, JSON.stringify([lesson("L-aaaaaaaaaaaa", "Prefix views with v_.")])) + const staged = (await RulesFromHistory.stagedRules({ root, name: "team-playbook", origin: "bootstrap", before: undefined, learn: { enabled: true } }))! + await meanwhile?.(file) + const result = await RulesFromHistory.sendStaged({ root, directory: root, learn: { enabled: true }, name: "team-playbook", staged, before: undefined }) + return { result, candidate: await fs.readFile(file, "utf8").catch(() => undefined) } + } + const untouched = await run() + expect(untouched.result.sent).toBe(1) + expect(untouched.candidate).toBeUndefined() + // A reflection in another session adds a rule while the confirmation prompt waits: the candidate and that rule stay. + const reflected = JSON.stringify([lesson("L-aaaaaaaaaaaa", "Prefix views with v_."), lesson("L-bbbbbbbbbbbb", "Quote reserved words.")]) + const changed = await run((file) => fs.writeFile(file, reflected)) + expect(changed.result.sent).toBe(1) + expect(changed.candidate).toBe(reflected) + expect(changed.result.message).toContain("changed while these were sent, so it was kept") + }) + test("signing out stops delivering the pulled team rules, and keeps the queue", async () => { const root = await tmp() await RulesStore.update(root, (s) => Object.assign(s, linked(), { scope: scope(), rules: [rule("aaaaaaaaaaaa", "Name staging models stg_.")] })) @@ -993,7 +1022,7 @@ describe("the workspace API, by path", () => { await RulesSync.enqueueMany(root, Array.from({ length: RulesStore.MAX_OUTBOX - 5 }, (_, i) => ({ item: item(`Older ${i}.`) }))) // The workspace is unreachable for this run: everything stays queued. answer = () => new Response("unavailable", { status: 503 }) - const staged = { workspace: { name: "Team", owner: "Olive" }, scope: user.scope, items: Array.from({ length: 8 }, (_, i) => item(`Mine ${i}.`)), invalid: [], localChanges: 0 } + const staged = { workspace: { name: "Team", owner: "Olive" }, scope: user.scope, items: Array.from({ length: 8 }, (_, i) => item(`Mine ${i}.`)), invalid: [], localChanges: 0, candidate: "[]" } const result = await RulesFromHistory.sendStaged({ root, directory: root, learn: { enabled: true }, name: "team-playbook", staged, before: new Set() }) expect(result).toMatchObject({ held: 3, queued: 5 }) expect(result.message).toContain("3 not sent: the queue of proposals waiting to be sent is full") @@ -1003,7 +1032,7 @@ describe("the workspace API, by path", () => { test("bootstrap does not queue rules listed for another account or workspace", async () => { const root = await tmp() await RulesStore.update(root, (s) => Object.assign(s, linked(), { scope: { ...scope(), account: "someone-else" } })) - const staged = { workspace: { name: "Team", owner: "Olive" }, scope: scope(), items: [item("Listed for the other one.")], invalid: [], localChanges: 0 } + const staged = { workspace: { name: "Team", owner: "Olive" }, scope: scope(), items: [item("Listed for the other one.")], invalid: [], localChanges: 0, candidate: "[]" } const result = await RulesFromHistory.sendStaged({ root, directory: root, learn: { enabled: true }, name: "team-playbook", staged, before: new Set() }) expect(result).toMatchObject({ sent: 0, held: 1 }) expect((await RulesStore.read(root)).outbox).toEqual([])