diff --git a/docs/docs/configure/learn.md b/docs/docs/configure/learn.md index 67a80b0f0c..e60cb386d3 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`, 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 @@ -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,74 +135,98 @@ 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 + +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 above the input when: + +- 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 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 (an answer after that closes it instead). A card is answered for the account and workspace it was shown for: after signing in as someone else or relinking, it closes without sending anything. + +**Rule or memory.** A personal preference ("I prefer CTEs") or a fact ("our warehouse is Snowflake") is saved to memory without review. When the agent saved it with `training_save`, a preference goes to your own memory (all your projects) and a fact to this project's memory (outside a linked project, `training_save` saves project training as before). When it saved it with the memory tool, it stays in that tool's project memory. When the wording does not say which, the card asks: Memory, Team rule, or Skip. + +**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 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. + +**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 -| | 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 @@ -218,25 +242,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 @@ -249,7 +273,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 @@ -257,7 +281,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" @@ -285,7 +309,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. | @@ -334,20 +358,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 @@ -367,7 +391,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 @@ -390,7 +414,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 @@ -440,21 +464,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. | @@ -465,28 +489,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). @@ -498,12 +522,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 @@ -511,24 +535,24 @@ 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`. 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. @@ -536,117 +560,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. +- **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. - **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/docs/docs/reference/telemetry.md b/docs/docs/reference/telemetry.md index a7949fbf17..2fd4ae6584 100644 --- a/docs/docs/reference/telemetry.md +++ b/docs/docs/reference/telemetry.md @@ -74,6 +74,15 @@ 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`, `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 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`). | +| `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/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/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/capture.ts b/packages/opencode/src/altimate/learn/capture.ts index 700d6fd69a..ae2d2b3857 100644 --- a/packages/opencode/src/altimate/learn/capture.ts +++ b/packages/opencode/src/altimate/learn/capture.ts @@ -247,6 +247,24 @@ 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. +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())) @@ -272,17 +290,36 @@ 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 { +// 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") - 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") const { SessionID } = await import("../../session/schema") const capture = new Capture({ - record: (signal) => appendSignal(root, signal), + record: async (signal) => { + // Fails closed: a gate that cannot decide (unreadable rules state) must not record a correction the person + // 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) { + 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) => { const messages = await Session.messages({ sessionID: SessionID.make(sessionID) }) return correctedTurnStart(turnMessages(messages), messageID) @@ -294,15 +331,25 @@ export async function startCapture(ctx: { directory: string; worktree: string }) }, }) 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 + // 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) + running.delete(ctx.directory) + unregister() + })()) const unregister = registerDisposer(async (directory) => { - if (directory !== ctx.directory) return - for (const stop of subscriptions) stop() - await capture.flush() - await flushWrites() - active.delete(capture) - unregister() + if (directory === ctx.directory) await stopCapture() }) + // altimate_change end subscriptions.push( await subscribeWhenReady(() => Bus.subscribe(MessageV2.Event.Updated, (evt) => capture.onMessage(evt.properties.info))), ) @@ -312,7 +359,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/learn/config.ts b/packages/opencode/src/altimate/learn/config.ts index 901122e974..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 @@ -8,12 +9,17 @@ 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 } +/** 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 { + return !killSwitchSet(env["ALTIMATE_DISABLE_WORKSPACE"]) +} + /** 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 c246f91db9..da465c202b 100644 --- a/packages/opencode/src/altimate/learn/delivery.ts +++ b/packages/opencode/src/altimate/learn/delivery.ts @@ -12,6 +12,10 @@ 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 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" }) const LOCK_OPTIONS = { timeoutMs: 5000 } @@ -85,6 +89,44 @@ export type ShownSnapshot = { shown: { name: string; id: string; textHash: string; tier: Shown["tier"]; at: string }[] } +/** 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) + 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 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, + })) } +} + +/** 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)) + 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 + }) +} + 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 } @@ -102,11 +144,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++ @@ -179,7 +217,17 @@ 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'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 + } + // 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)) @@ -191,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 })) @@ -200,6 +262,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. @@ -288,6 +353,22 @@ 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, + 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, + }) + } + // altimate_change end + private mentionedPaths(query: string) { // Paths mentioned for creation count too; never traverse the project tree. const found = new Set() @@ -364,6 +445,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. @@ -375,6 +457,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) + added = { before: 0, tier: "start" } state.requests.push({ message, note: "" }) } else { state.query = redactedQuery @@ -384,10 +467,13 @@ export class Delivery { paths: [...state.touchedPaths, ...this.mentionedPaths(query)], }) const rendered = renderSection(matches, this.limits.budget_tokens, "Team rules for this request:") + added = { before: state.shown.length, tier: "request" } this.add(state, approved, rendered.lessons, "request", query) 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) @@ -442,8 +528,10 @@ 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) 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/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..506837ba8c 100644 --- a/packages/opencode/src/altimate/learn/lock.ts +++ b/packages/opencode/src/altimate/learn/lock.ts @@ -61,6 +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") + // 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) => { 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..d95a34fdb4 --- /dev/null +++ b/packages/opencode/src/altimate/learn/project-config.ts @@ -0,0 +1,53 @@ +// altimate_change - new file +// +// 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 { 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 + * `.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. + */ +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[] = [] + 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) + let file = path.join(root, ".altimate-code", "altimate-code.json") + let text = "{}\n" + for (const candidate of candidates) { + // Reject linked project config files and their ancestors before reading. + await SafeFS.assertSafePath(root, candidate, root) + 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 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> { + // 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 1efe985750..68ed0b2add 100644 --- a/packages/opencode/src/altimate/learn/recurrence.ts +++ b/packages/opencode/src/altimate/learn/recurrence.ts @@ -28,12 +28,85 @@ 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) +} + +/** 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 + }) + 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 [] + } +} + +/** 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[]) { - 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 +165,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), } } @@ -116,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 } } @@ -154,8 +198,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..5eac1634a4 100644 --- a/packages/opencode/src/altimate/learn/store.ts +++ b/packages/opencode/src/altimate/learn/store.ts @@ -94,6 +94,19 @@ 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 +/** 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)) + 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,14 +214,15 @@ 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) 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) @@ -252,6 +266,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) @@ -350,7 +365,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 +583,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) @@ -630,6 +645,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) @@ -642,14 +658,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 @@ -682,6 +698,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) @@ -698,7 +715,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. */ } } } @@ -725,10 +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/api.ts b/packages/opencode/src/altimate/rules/api.ts new file mode 100644 index 0000000000..147df737c4 --- /dev/null +++ b/packages/opencode/src/altimate/rules/api.ts @@ -0,0 +1,211 @@ +// 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 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 { lint, normalizeText } from "../learn/curator" +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(), + // 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 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(z.unknown()), + cursor: z.string(), +}) +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] + +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" + /** 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 { + constructor( + readonly kind: FailureKind, + 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" + } +} + +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) { + 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 === 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", 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, named.detail) + if (named?.name === "PreconditionFailedError") return new RulesApiError("conflict", messageOf(named.detail, message), codeOf(named.detail), 412, named.detail) + return new RulesApiError("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 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, my_reviewed: reviewed, skipped: raw.rules.length - rules.length } +} + +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..bc4267b6cf --- /dev/null +++ b/packages/opencode/src/altimate/rules/classify.ts @@ -0,0 +1,61 @@ +// 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" + +// 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"). "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. +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"], + [/\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"], + // 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"], +] + +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 && !WEAK_MEMORY.test(text)) return { kind: "rule", cue: rule[1] } + if (memory && !rule) return { kind: "memory", cue: memory[1] } + return { kind: "ambiguous" } +} + +/** 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+(?: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/events.ts b/packages/opencode/src/altimate/rules/events.ts new file mode 100644 index 0000000000..f107ccb36b --- /dev/null +++ b/packages/opencode/src/altimate/rules/events.ts @@ -0,0 +1,59 @@ +// 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(), + /** 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()), +}) +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..3f68e0e5e1 --- /dev/null +++ b/packages/opencode/src/altimate/rules/from-history.ts @@ -0,0 +1,162 @@ +// altimate_change - new file +// +// `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 Lessons from "../learn/lesson" +import * as Store from "../learn/store" +import type { ProposalItem } from "./api" +import { RulesStore } from "./store" +import { RulesSync } from "./sync" +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> { + const candidate = await Store.loadCandidateLessons(root, name) + return candidate ? new Set(candidate.map((lesson) => lesson.text)) : undefined +} + +export interface Staged { + workspace: { name: string; owner: string } + /** The account and workspace these were listed for: they are queued only for them. */ + scope: RulesStore.Scope + /** What would be sent, in order. */ + 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 + /** 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. */ +export async function stagedRules(input: { + root: string + name: string + 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 || !state.scope) return undefined + const [approved, raw] = await Promise.all([ + Store.loadApproved(input.root, input.name), + Store.readCandidate(input.root, input.name), + ]) + 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. + 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) || 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 }) + continue + } + items.push({ + change: "add", + text: lesson.text, + 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: [], + }) + } + 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, + candidate: raw, + } +} + +/** 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; lost: number; held: number; message: string }> { + const { owner, name } = input.staged.workspace + // 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, 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. + 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(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 || !report.push?.sent.length) 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.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), + // 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 + 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" + ? report.context.reason + : 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"})` : "") + heldNote + "." + + (discarded + ? "" + : 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 } +} + +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 new file mode 100644 index 0000000000..7a26318c08 --- /dev/null +++ b/packages/opencode/src/altimate/rules/proposal.ts @@ -0,0 +1,630 @@ +// 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 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" +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" +import { RulesTelemetry, type AnswerOutcome, type CardTrigger } from "./telemetry" + +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 + /** 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 { + card: ProposalCard + root: string + directory: string + 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 + /** 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"] + /** 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 + /** 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 + /** 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 } +} + +/** 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 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((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`). */ +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, directory = entry.directory): Promise { + const record: RulesStore.RememberRequest = { + ...request, + card: entry.card.id, + text: entry.card.text, + directory, + pid: process.pid, + boot: RulesStore.PROCESS_BOOT, + at: new Date().toISOString(), + } + // 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 + }).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. 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++) { + 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)) + } + } + 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 + // 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: 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 + if (saved !== false || refused) await forgetRequest(id, entry) + RulesTelemetry.answered({ + card: entry.card, + trigger: entry.trigger, + action: why, + status: saved === false ? "failed" : "ok", + ...(saved ? { outcome: "saved_unanswered" as const } : {}), + edits: entry.edits, + shownAt: entry.at, + }) +} + +/** 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, 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." } +} + +// 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 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 live +} + +/** `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) +} + +/** For tests: a lease for `directory` that never lapses, or none at all. */ +export function setRendererForTest(directory: string | undefined) { + renderers.clear() + 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). */ +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). 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 +// 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 + // Retiring removes the rule as it stands: there is no wording to edit, and removing a rule weakens nothing. + const flag = draft.change === "retire" ? undefined : verificationWarning(draft.text) + const team: ProposalCard["actions"] = linked + ? [state.workspace!.is_owner ? "approve" : "send"] + : ["save_local"] + const actions: ProposalCard["actions"] = draft.choose + ? ["save_memory", ...team, "dismiss"] + : [...team, ...(draft.change === "retire" ? [] : (["edit"] as const)), "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, 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: { + sessionID: string + root: string + directory: string + learn: Pending["learn"] + 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") + const state = await RulesStore.read(input.root) + 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 + // 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 + // 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 + 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) + 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) + RulesTelemetry.shown(card, trigger, false) + await publish(RuleEvents.Event.Asked, card) + return card +} + +/** Open cards, optionally of one project and one session. */ +export function list(directory?: string, sessionID?: string): ProposalCard[] { + expire(directory) + return [...pending.values()] + .filter((p) => (!directory || p.directory === directory) && (!sessionID || p.card.sessionID === sessionID)) + .map((p) => p.card) +} + +/** 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), 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" | "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 } + +/** 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 +} + +/** 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 || (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 }) + } + if (entry.busy) { + track("busy") + return { ok: false, code: "busy", error: "This proposal is already being answered.", card: entry.card } + } + const { card } = entry + 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() + const problem = editProblem(text) + 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 + // 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 + } + const gone = await settleIfClosed(id, entry) + if (gone) return gone + track("ok") + return { ok: true, message: "Wording updated.", card: next, closed: false } + } + + entry.busy = true + entry.enqueued = false + let message: string + try { + const done = await act(entry, input.action) + 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))! + const message = "The account or workspace changed since this proposal was shown: it was not sent." + 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).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 }) + await publish(RuleEvents.Event.Changed, { directory: entry.directory }) + return { ok: true, message, closed: true } +} + +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 }) + // 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") + // 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() + 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", sessionID: entry.sourceSession }) + message = "Saved as a rule for this project." + outcome = "saved_local" + } else { + 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 + 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, + ...(why ? { 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 && quotes + ? [{ excerpt: card.evidence.correction.excerpt, corrected_at: card.evidence.correction.at, session_ref: hashRef(entry.sourceSession) }] + : [], + }, 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" + const ws = card.workspace?.name ?? "the workspace" + 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.` + 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." + 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 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" + } 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" + } else { + message = `Sent to ${owner}'s review queue in ${ws}.` + outcome = "sent" + } + } + return { message, outcome } +} + +/** `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) +} + +export * as RuleProposal from "./proposal" diff --git a/packages/opencode/src/altimate/rules/remember.ts b/packages/opencode/src/altimate/rules/remember.ts new file mode 100644 index 0000000000..3bb2def0b5 --- /dev/null +++ b/packages/opencode/src/altimate/rules/remember.ts @@ -0,0 +1,307 @@ +// 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. + +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 +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, 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 +} + +/** 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", + 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"), + import("@/config/config"), + import("./service"), + ]) + // 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) + // 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 = () => 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) => 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. 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, 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 }, { TRAINING_MAX_PATTERNS_PER_KIND }] = await Promise.all([ + import("../training"), + import("../training/types"), + ]) + 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 SaveRefusedError(`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, + }) +} + +/** Whether process `pid` is running (a process this one may not signal still counts). */ +function processAlive(pid: number) { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM" + } +} + +/** 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` 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, isOpen, answeredIds, forgotten }] = await Promise.all([import("./store"), import("./proposal")]) + const now = Date.now() + 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) => { + 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 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) + } +} + +/** `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" + /** From the memory tool: the block it meant to write, used if the person picks Memory on the card. */ + memory?: { id: string; tags: string[] } + /** 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 + /** 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" + // 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"), + 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 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") + // 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(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 }) + // 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, + 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 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" && 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", + 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 save it again.`, + } + const owner = state.workspace.is_owner ? "you (the workspace owner)" : (state.workspace.owner_name ?? "the workspace owner") + return { + title: result.choose ? `Team rule or memory: "${content.slice(0, 60)}"` : `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 (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"), + } +} + +export * as RulesRemember from "./remember" diff --git a/packages/opencode/src/altimate/rules/service.ts b/packages/opencode/src/altimate/rules/service.ts new file mode 100644 index 0000000000..190af33f12 --- /dev/null +++ b/packages/opencode/src/altimate/rules/service.ts @@ -0,0 +1,215 @@ +// 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), 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). +// - 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`). +// - Review notices from a pull are published as `rule.proposal.reviewed` by the sync run itself (`RulesSync`). +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 type { RulesTelemetry } from "./telemetry" +import { RuleTrigger } from "./trigger" + +const log = Log.create({ service: "rules.service" }) + +type LearnConfig = { enabled?: boolean; capture?: boolean; model?: string; capture_policy?: boolean } | undefined + +/** + * `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 }, { 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 = 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 +} + +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 triggerDeps(ctx: InstanceContext, model?: string): RuleTrigger.TriggerDeps { + return { + source: async (sessionID) => (await import("../learn/session-reflect")).sourceFromSession(sessionID, ctx), + generate: async (source, onUsage) => { + 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, undefined, onUsage).pipe(Effect.provideService(InstanceRef, ctx)), + ) + }, + } +} + +/** 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") { + return RulesSync.runShared(root, directory, learn, trigger) +} + +/** + * 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, 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. + const hasState = !!(await RulesStore.read(root).catch(() => undefined))?.scope + await RulesSync.sessionStart(root, directory, learn, { startIfDue: hasState }) +} + +export async function start(ctx: InstanceContext): Promise { + try { + const root = await fs.realpath(ctx.worktree !== "/" ? ctx.worktree : ctx.directory) + const { Instance } = await import("@/project/instance") + 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. + // 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) return + // 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() + 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() + } + } + // "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) => { + log.warn("rules sync failed", { error: error instanceof Error ? error.message : String(error) }) + return undefined + }) + if (report) await followLearning(learn) + }) + // 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 + 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 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: 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 () => { + if (!disposed) await followLearning(await learnConfig(ctx)) + }) + const stop = Bus.subscribeAll((event) => { + 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 + disposed = true + clearInterval(timer) + stop() + unregister() + // Cards nobody answered go with the instance: they cannot be answered after it. + const { RuleProposal } = await import("./proposal") + 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?.() + stopCapture = undefined + if (gated) (await import("../learn/capture")).setCaptureGate(ctx.directory, undefined) + }) + 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..deb4d85071 --- /dev/null +++ b/packages/opencode/src/altimate/rules/store.ts @@ -0,0 +1,379 @@ +// altimate_change - new file +// +// 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 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, +// 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 { constants } from "node:fs" +import { randomUUID } from "node:crypto" +import fs from "node:fs/promises" +import path from "node:path" +import z from "zod" +import * as SafeFS from "../learn/safe-fs" +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 (reserved: `learn/store.ts` `RESERVED_STORES`). */ +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 signal id list; `processed_signals` is also pruned to live signals. */ +const MAX_TRACKED = 5000 +/** 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 + +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 + +/** 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, 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. 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), + 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(), + /** 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([]), + /** (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. 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({}), + /** 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([]), + /** 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(), + 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({ + public_id: z.string(), + version: z.number().int(), + 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(), + /** 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()), + }).optional(), +}) +export type State = z.infer + +const EMPTY: State = State.parse({ version: 1 }) + +export function dir(root: string) { + 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. */ +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 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)}` + +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}`, + } +} + +const IGNORE = "*\n" + +/** 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() } }) + .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 { + return withLearnLock(root, async () => { + await SafeFS.mkdir(root, dir(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 rulesBefore = JSON.stringify(current.rules) + const next = change(current) ?? current + next.notices = next.notices.slice(-MAX_NOTICES) + next.sent = capSent(next.sent) + 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 && !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. 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) + } + return next + }, 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)) + const previous = await fs.readFile(file, "utf8").catch(() => undefined) + if (previous === text) return + // 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 `workspace-rules` store was set aside for the workspace team rules", { backup }) + } + await Store.writeAtomic(root, file, text) +} + +/** 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 { + const lessons = parseLessons(text) + return lessons.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) => 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_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 } : {}), + // A "remember" is saved locally whatever the workspace: no reset loses it. + remember_requests: state.remember_requests, + } +} + +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 || !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` +} + +/** Queued proposals at most; a new one is refused (the card stays open) rather than an older one dropped. */ +export const MAX_OUTBOX = 200 + +/** 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/summary.ts b/packages/opencode/src/altimate/rules/summary.ts new file mode 100644 index 0000000000..08f0f5b581 --- /dev/null +++ b/packages/opencode/src/altimate/rules/summary.ts @@ -0,0 +1,89 @@ +// 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`: 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 + 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 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 } : {}), + 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: learning.on, + learning_source: learning.source, + ...(state.personal_learning !== undefined ? { personal_learning: state.personal_learning } : {}), + ...(state.pulled_at ? { pulled_at: state.pulled_at } : {}), + ...(lastError ? { last_error: lastError } : {}), + 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..8e65d9072d --- /dev/null +++ b/packages/opencode/src/altimate/rules/sync.ts @@ -0,0 +1,880 @@ +// altimate_change - new file +// +// Workspace rules sync for a linked project: +// +// - 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, 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, workspacesOn } 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 +export const MAX_PROPOSALS = 50 +const MAX_USAGE_DELTA = 50 +/** 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 + +type LearnConfig = { enabled?: boolean } | undefined + +/** `code`: a stable value for telemetry; `reason` is for people. */ +export type Context = + | { 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" | "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 { + return learnEnabled(learn, env) && workspacesOn(env) +} + +/** 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", 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", 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", 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 digest = 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()) !== digest) return { status: "skipped", code: "account_changed", reason: "the Altimate account changed" } + // Queued state belongs to a person, not to a key: rotating the key or signing in again keeps it, another user on + // the same tenant does not. Unknown for now (the server did not answer): skipped, nothing reset. + let account = principals.get(digest) + if (!account) { + const id = await WorkspaceApi.whoami(actAs).catch(() => 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.replace(/\/+$/, ""), + 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)) + +/** 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) + 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 } +} + +/** 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 { + 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"] = [] + 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 + // 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 = response.skipped + ? `${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) { + 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 }) + } + }) + 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.update(root, (state) => (ownedBy(state, ctx) ? RulesStore.cleared(state, { keepOutbox: true }) : undefined)) + } else { + 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: [] } + } +} + +export interface PushResult { + 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 + /** "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 + /** 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] +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[], split = true): Promise { + try { + const results = await RulesApi.propose(ctx.actAs, ctx.scope.datamateId, { + repo_remote: ctx.scope.repoRemote, + 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) { + 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 (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 + // 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) + } + 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 +/** 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. */ +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 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 || !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 + * 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) { + // 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) + // The state was taken over by another account or workspace since this run started: none of it is this run's to send. + 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 } : {}) } + 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 done = new Set() + const failed = new Map() + for (const { entry, result, error } of outcomes) { + if (error) { + // 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) + continue + } + if (!result) { + 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({ + id: entry.id, + text: entry.item.text, + status: result.status, + // 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 }) + } + // 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 (!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) + if (error === undefined) continue + entry.attempts += 1 + 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 }) + // 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 } + } + /** 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 { + 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 }) + settled.add(approval.publicId) + } catch (error) { + if (row) row.approve_error = errorText(error) + if (retryableApproval(error)) { + if (row) row.approve_retry = true + retry.add(approval.publicId) + } else settled.add(approval.publicId) + } + } + if (approvals.length) + await record(root, (s) => { + 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) { + 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 + * 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) { + // 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.` }) + } + } + 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 +} + +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 } : {}), + } +} + +/** 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`, 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, + all: readonly { item: ProposalItem; approve?: "approve" | "approve_anyway" }[], + scope?: Scope, + /** `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()) + 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 + ? `${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 } : {}) })) + 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, + forget?: string, +): Promise { + return (await enqueueMany(root, [{ item, ...(approve ? { approve } : {}) }], scope, forget ? { forget } : {}))[0] +} + +/** + * 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 recorded = state.push_outcomes[outboxId] + 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 } +} + +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 }) + 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) => { + // 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 } + 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) + // 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) + } catch (error) { + if (!permanentUsageFailure(error)) return { items: 0, error: errorText(error) } + await dropUsage(root, before.pending_usage, error) + } + } + const [{ readShownSnapshots, lessonTextHash, snapshotFiles, snapshotFile }, { analyzeRecurrence }] = await Promise.all([import("../learn/delivery"), import("../learn/recurrence")]) + // 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 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) => !remembered(pair))) + await RulesStore.update(root, (s) => { + s.usage_reported = s.usage_reported.filter(remembered) + }) + 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[] = [] + // 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 (!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 + .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] : [] + }))] + 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 + // 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.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 + // 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.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 + counts.set(rule.public_id, entry) + } + if (!counts.size) { + if (Object.keys(corrected).length) await RulesStore.update(root, (s) => { s.corrected_reported = { ...s.corrected_reported, ...corrected } }) + return { items: 0 } + } + 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, + } + // Another process (a second instance, `learn sync`) may have built a batch from the same unreported sessions in + // 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) => { + // 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 (!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) { + 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 markReported(root, batch) + log.warn("rules usage batch refused; dropped", { error: errorText(error) }) +} + +export interface RunReport { + context: Context + push?: PushResult + pull?: PullResult + 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. `trigger` says which, + * for telemetry. */ +export async function run(root: string, directory: string, learn: LearnConfig, trigger: SyncTrigger = "refresh"): Promise { + const startedAt = Date.now() + 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 { + const key = await canonicalRoot(root) + const ctx = await context(directory, learn) + if (ctx.status !== "ready") { + // 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 } + } + 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()) + 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 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 { 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 + * already) and starts a new one. */ +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, trigger) +} + +/** 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: 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, + 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 + // 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?.())]) +} + +export * as RulesSync from "./sync" diff --git a/packages/opencode/src/altimate/rules/telemetry.ts b/packages/opencode/src/altimate/rules/telemetry.ts new file mode 100644 index 0000000000..b8ee84d868 --- /dev/null +++ b/packages/opencode/src/altimate/rules/telemetry.ts @@ -0,0 +1,150 @@ +// 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 } : {}), + 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 }) +} + +/** 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) +} + +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 }) + // `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 && !push?.error ? "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, + approval_failed: !!push.approval_error, + } + : {}), + ...(usage ? { usage_items: usage.items, usage_failed: !!usage.error } : {}), + }) +} + +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) +} + +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 new file mode 100644 index 0000000000..4d3c2805df --- /dev/null +++ b/packages/opencode/src/altimate/rules/trigger.ts @@ -0,0 +1,340 @@ +// 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 { booleanFlag, learnEnabled, workspacesOn } from "../learn/config" +import { lint } from "../learn/curator" +import { readShownSnapshot } from "../learn/delivery" +import { buildDigest, type DigestSource } from "../learn/digest" +import * as Playbook from "../learn/playbook" +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" +import { RulesStore } from "./store" +import { RulesTelemetry } from "./telemetry" + +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; `onUsage` receives its token and cost usage. */ + generate: (source: DigestSource, onUsage?: (usage: GenerateUsage) => void) => Promise + source: (sessionID: string) => Promise +} + +type LearnConfig = { enabled?: boolean; capture?: boolean; capture_policy?: boolean } | undefined + +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`; `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 && workspacesOn(env)) 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 { + 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(" ")) +/** 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 +/** 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`). */ +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. */ +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].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) + "…") +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 + trigger: "shown_rule" | "recurrence" + 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 } | "covered" | undefined> { + const source = await input.deps.source(input.sessionID) + // 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 (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) { + 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 "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 } + 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" + let checked: string[] = [] + 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) + // 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, + // so a later turn of this session with a card client attached proposes them. + 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(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) + // 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 = (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, trigger, "no_renderer") + return false + } + 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 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)) + .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 && !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, 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("shown_rule")) return + const card = await RuleProposal.ask({ + 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. + 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 = 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. 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 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))) + 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" + return RulesTelemetry.skipped(input.sessionID, "recurrence", candidates.length ? "topic_dismissed" : "no_topic") + } + const backing = all.filter((signal) => topic.signal_ids.includes(signal.id)) + 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("recurrence")) return + const card = await RuleProposal.ask({ + 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)), + why: clip(`Corrected in ${topic.sessions} sessions: ${drafted.reason}`, MAX_WHY), + change: "add", origin: "session", + evidence: { sessions: topic.sessions, correction }, + topics: [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)}` + RulesTelemetry.skipped(input.sessionID, undefined, "failed") + log.warn("rule proposal check failed", { error: outcome }) + } 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. 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 (!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(() => {}) + } + } +} + +/** "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 + /** 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 + /** 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" } + | { 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, kind } + // 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) + .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, request: input.request, + draft: { + text, + 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" } +} + +export * as RuleTrigger from "./trigger" diff --git a/packages/opencode/src/altimate/telemetry/index.ts b/packages/opencode/src/altimate/telemetry/index.ts index 2b7af62c48..965c957c62 100644 --- a/packages/opencode/src/altimate/telemetry/index.ts +++ b/packages/opencode/src/altimate/telemetry/index.ts @@ -761,6 +761,142 @@ export namespace Telemetry { /** Blocks that came from the bound workspace's cloud memory (overlay). */ workspace_blocks?: number } + // 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: ("send" | "approve" | "edit" | "dismiss" | "save_local" | "save_memory")[] + 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" | "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" | "refused" | "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" + | "saved_unanswered" + /** Wording edits accepted on this card so far. */ + 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" + 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" | "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 + * with "remember …"): where it went. */ + type: "rule_remember_routed" + timestamp: number + 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" | "requests_full" + } + | { + /** 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 + /** A retried "Approve now" failed this run (reported apart from the proposals themselves). */ + approval_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: "organization" | "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/altimate/tools/training-save.ts b/packages/opencode/src/altimate/tools/training-save.ts index 8e83b63c9d..58d4ef35b5 100644 --- a/packages/opencode/src/altimate/tools/training-save.ts +++ b/packages/opencode/src/altimate/tools/training-save.ts @@ -8,6 +8,10 @@ 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 +import { lastUserText, proposeTeamRule } from "../rules/remember" +// 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 +20,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 +70,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), "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" // 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 96ca3f0a7d..ba0f4fefa0 100644 --- a/packages/opencode/src/altimate/workspace/api-client.ts +++ b/packages/opencode/src/altimate/workspace/api-client.ts @@ -136,15 +136,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" } @@ -157,6 +165,8 @@ export class WorkspaceApiError extends Error { /** Where it failed: on the wire before any answer, or in an answer the server gave (its * status, when known, is `status`). A statusless error is transport unless said otherwise. */ public readonly failure: "transport" | "response" = status === undefined ? "transport" : "response", + // altimate_change — workspace rules: a coded `detail` object, read by `rules/api.ts` `classify` + public readonly detail?: Record, ) { super(msg) this.name = "WorkspaceApiError" @@ -467,6 +477,7 @@ async function req( } } const detail = (json as { detail?: unknown } | undefined)?.detail + const coded = typeof detail === "object" && detail !== null && !Array.isArray(detail) ? (detail as Record) : undefined if (!res.ok && !opts.expectedStatuses?.includes(res.status)) { reportApiError({ endpoint, @@ -475,8 +486,8 @@ async function req( workspaceId: bodyWorkspaceId, }) } - 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") + 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 @@ -495,6 +506,8 @@ async function req( throw new WorkspaceApiError( typeof detail === "string" ? detail : `Request failed with status ${res.status}`, res.status, + "response", + 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..47f4c9c197 100644 --- a/packages/opencode/src/cli/cmd/learn.ts +++ b/packages/opencode/src/cli/cmd/learn.ts @@ -22,6 +22,8 @@ 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 { 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" @@ -58,7 +60,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. */ @@ -67,6 +69,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, @@ -87,35 +101,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)) @@ -225,6 +216,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 @@ -255,7 +248,8 @@ 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) // altimate_change end // altimate_change start — verify effective settings before reporting learning disabled const { Config } = await import("@/config/config") @@ -275,6 +269,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}`) }) @@ -301,7 +297,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 () => { @@ -392,26 +388,34 @@ 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), 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)) out(`Learning enabled: ${status.enabled ? "yes" : "no"}`) // 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 = { 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"}`) 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`) @@ -420,23 +424,116 @@ 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: ${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 }) }), }) +// altimate_change start — workspace rules: send queued proposals, pull approved rules, report usage +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", + 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 { Instance } = await import("@/project/instance") + 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, 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: ${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)}`) + 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.`) + for (const notice of report.pull.notices) out(noticeLine(notice)) + if (report.usage?.error) out(`Usage not reported: ${printable(report.usage.error)}`) + }) + }), +}) +// 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 { 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 + // 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 = 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(` - ${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 + if (!send) { + out("Not sent; they stay local candidates for this project only (`learn show`, `learn promote`).") + return + } + const result = await RulesFromHistory.sendStaged({ + root, directory: Instance.directory, learn, name: input.name, staged, before: input.before, + }) + out(printable(result.message)) +} +// altimate_change end + 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" }) .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. 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. 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 = 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 () => { const [{ Config }, { Provider }, { Instance }, { InstanceRef }, { AppRuntime }] = await Promise.all([ import("@/config/config"), import("@/provider/provider"), import("@/project/instance"), @@ -472,13 +569,18 @@ const BootstrapCommand = effectCmd({ }, }) }) + // 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.") }), }) 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" }) @@ -491,9 +593,16 @@ 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. 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. 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 = 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 () => { const [{ Config }, { Provider }, { Instance }, { InstanceRef }, { AppRuntime }] = await Promise.all([ import("@/config/config"), import("@/provider/provider"), import("@/project/instance"), @@ -531,6 +640,10 @@ const ImportReviewsCommand = effectCmd({ }, }) }) + // 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.") }), }) @@ -539,7 +652,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 }) @@ -559,7 +672,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", { @@ -806,7 +919,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 @@ -843,7 +956,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.") }) }), @@ -851,14 +964,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}`) }) }), @@ -868,7 +981,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 () => { @@ -881,14 +994,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", { @@ -914,7 +1027,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) @@ -1000,19 +1113,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`).", @@ -1032,24 +1145,35 @@ 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) +const LEARN_SUBCOMMANDS = new Set([ + "enable", "disable", "nudge", "status", "sync", "bootstrap", "import-reviews", "reflect", "signals", "signal", + "show", "search", "pin", "unpin", "promote", "rollback", "reject", +]) + 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) + // 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 @@ -1060,6 +1184,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/config/config.ts b/packages/opencode/src/config/config.ts index 60f4399964..d49756dc52 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.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 + 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 @@ -847,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/memory/tools/memory-write.ts b/packages/opencode/src/memory/tools/memory-write.ts index 439bc9ec0b..f7cff597e7 100644 --- a/packages/opencode/src/memory/tools/memory-write.ts +++ b/packages/opencode/src/memory/tools/memory-write.ts @@ -40,6 +40,19 @@ 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 && !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 ?? [], + }) + // 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 now = new Date().toISOString() const { duplicates } = await MemoryStore.write({ 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: Pickp` (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, TuiPromptRef } from "@opencode-ai/plugin/tui" +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" + +const id = "altimate:rules" + +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 { + 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 +} + +/** 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 } +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) { + 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 ${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 }[] { + 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: memoryScope(card) === "global" ? "Memory — just for you" : "Memory — this project", + 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 ? 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 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." + return card.choose || !card.topics.length ? "Don't save anything." : "Won't propose this topic again for 30 days." +} + +const FOCUS_KEY = "p" +const FOCUS_COMMAND = "altimate.rules.card.focus" + +function InlineCard(props: { + api: TuiPluginApi + card: ProposalCard + 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 +}) { + const theme = () => props.api.theme.current + 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())} + 0}> + {`+${props.more} more`} + + + + {printable(card().replaces!.text)} + + {printable(card().text)} + {meta(card())} + + {`Why: ${printable(card().why)}`} + + + {`Your correction: "${printable(card().evidence.correction!.excerpt)}"`} + + + {`⚠ ${printable(card().weakens_verification)} — it needs "approve anyway"`} + + + + {options().map((option, index) => ( + props.onPick(index)} + > + {option.label} + + ))} + + + {focusHint()} review + + + + + + + {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 + + + + + ) +} + +/** + * 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 [cards, setCards] = createSignal([]) + 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 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 = () => { + 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 + // 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 = () => { + setConfirming(undefined) + releaseKeys() + 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) + .finally(() => setBusy(false)) + if (!result) return api.ui.toast({ variant: "error", message: "Could not reach the session server.", duration: 6000 }) + 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: 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: printable(result.message ?? "Done."), duration: 6000 }) + remove(card.id) + unfocus() + } + + const edit = (card: ProposalCard) => { + 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()} + /> + ), + ) + } + + 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) + // 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) + // 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) => { + const card = current() + const index = card ? buttons(card).findIndex((b) => b.action === action) : -1 + if (index >= 0) pick(index) + } + + const move = (step: number) => { + const card = current() + if (!card) return + setConfirming(undefined) + const count = buttons(card).length + setSelected((index) => (index + step + count) % count) + } + + const focus = () => { + if (!active() || !current() || focused()) return + setSelected(0) + setFocused(true) + prompt?.blur() + bindKeys() + } + + function bindKeys() { + if (layer || api.ui.dialog.open || !active() || !current()) return + 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() }, + ], + }) + } + + // 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. + const disposeEffects = createRoot((dispose) => { + createEffect(() => { + 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. + createEffect(() => { + if (focused() && (!active() || !current())) unfocus() + }) + return dispose + }) + + 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) { + createEffect(() => setActive(props.visible === false ? undefined : props.session_id)) + onCleanup(() => { + if (active() === props.session_id) setActive(undefined) + }) + return ( + + card.sessionID === props.session_id)}> + {(card) => ( + c.sessionID === props.session_id).length - 1} + focused={focused()} + selected={selected()} + confirming={confirming() === card().id} + onPick={(index) => (focused() ? pick(index) : focus())} + onFocus={focus} + /> + )} + + {/* 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={} + /> + + + ) + }, + }, + }) + + 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) + // 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[]) + } + + 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. + const seenKey = () => `altimate.rules.notices.${root(api)}` + 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)) + if (!quiet) reviewToast(api, p) + } + 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) + }) + 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) + 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() + // 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() + }) +} + + +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 }) + 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 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 + ? 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"} (${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 ? "📌 " : ""}${printable(r.text)}`, + value: `rule:${r.public_id}`, + description: r.applies_to.length ? `applies to ${printable(r.applies_to.join(", "))}` : "whole repository", + category: `Team rules (${team.length})`, + })), + ...mine.map((r) => ({ + title: printable(r.text), + value: `mine:${r.public_id}`, + 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: ${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", + 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" || 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; 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: 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 }) + 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: ${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 + } + }} + /> + )) +} + +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: printable(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..21fa68dee0 100644 --- a/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx +++ b/packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx @@ -69,6 +69,20 @@ function View(props: { api: TuiPluginApi }) { // altimate_change start - status lines const [detail, setDetail] = createSignal(null) // 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 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 start - what the last session got, in numbers const [attachLine, setAttachLine] = createSignal(null) const readAttachLine = (bound: CachedBinding | null) => { @@ -132,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 @@ -216,6 +233,7 @@ function View(props: { api: TuiPluginApi }) { return } setDetail(detail) + readRules() // altimate_change end } finally { refreshInFlight = false @@ -242,10 +260,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 +333,14 @@ function View(props: { api: TuiPluginApi }) { {(at) => {`memory loaded ${describeAge(at())}`}} + + {(r) => ( + + {`rules: ${r().line}`} + {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 dddd3299ac..538565c3e3 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1257,6 +1257,146 @@ 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, 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(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) => { + 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", "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) + 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 } : {}) }, + Instance.directory, + ) + 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) + 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) + 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) + 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. + .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)) + 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) + 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 { 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, "manual")) }) + } 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 ecedc18104..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 } from "node:fs" +import { existsSync, readFileSync, statSync } from "node:fs" import os from "os" import fs from "fs/promises" import z from "zod" @@ -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 "." @@ -119,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 @@ -147,6 +150,34 @@ 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() +/** 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. */ +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) + // 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(canonicalDirectory)) } + } + return boundDirs.dirs + } catch { + 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(canonicalDirectory(directory)) +} +// altimate_change end + const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format. IMPORTANT: @@ -796,6 +827,19 @@ 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 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. + // 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, 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!) + 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/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/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/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/delivery.test.ts b/packages/opencode/test/altimate/learn/delivery.test.ts index 47b8e7b3d9..db27bd0134 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,109 @@ 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") +}) + +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) + 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) + 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 { + 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 { + if (env === undefined) delete process.env.ALTIMATE_DISABLE_WORKSPACE + else process.env.ALTIMATE_DISABLE_WORKSPACE = env + } +}) 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() 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..a4105dffd0 --- /dev/null +++ b/packages/opencode/test/altimate/rules/rules.test.ts @@ -0,0 +1,2338 @@ +// altimate_change - new file +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" +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, 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" +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" +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 { disposeAllInstances, tmpdir } from "../../fixture/fixture" + +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"], + ["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) + }) + + 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.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.WORKSPACE_RULES_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", "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 () => { + 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("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 }))! + expect(await RuleProposal.reply(card.id, { action: "dismiss" })).toMatchObject({ ok: true, closed: 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 }) + }) +}) + +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, (s) => Object.assign(s, linked())) + 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([]) + }) +}) + +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 | Promise = () => 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 = () => 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()) + 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. 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: {} } + }) + 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_reported).toEqual(expect.arrayContaining(["s1|p-aaaaaaaaaaaa", "s2|p-aaaaaaaaaaaa"])) + }) + + 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") + }) + + 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 }], pairs: ["s1|p-aaaaaaaaaaaa"], 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: 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 + 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 })]) + }) + + 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("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() + for (const text of ["one", "two", "three"]) await RulesSync.enqueue(root, item(text)) + answer = () => Response.json({ detail: [{ msg: "invalid" }] }, { status: 422 }) + 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" }, 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)") + 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 () => { + 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 }], 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) + expect(state.pending_usage).toBeUndefined() + 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: learn-state") + 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("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" }])) + 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("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, 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_.")] })) + 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, 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") + 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, 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([]) + }) + + 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 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() - 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()) + 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("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}.`) })) + 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", () => { + 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" }) + }) + + 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", () => { + 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", 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) + await RuleProposal.drop(root) + expect(RuleProposal.list(root)).toHaveLength(0) + }) + + 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 () => { + 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.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.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) + 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", () => { + 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, 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 } } : {}) }) + 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, 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())) + 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, 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("outbox, approvals and resets", () => { + let server: ReturnType + let answer: (path: string, body: any) => Response | Promise = () => 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_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_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.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 }) + 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) + }) +}) + +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 + 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 showing this project's cards, or in a subagent's session, both save tools save as before cards", async () => { + const root = await linkedRoot() + 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 () => { + 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." + 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("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 + 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_."]) + } 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 + // 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() + await dropping + expect(saved).toEqual(["Quote reserved words."]) + expect(await replying).toMatchObject({ ok: false, code: "not_open" }) + } 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." + 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", () => { + 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"]]) + }) +}) + +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() + 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 } })) + // 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, capture: false }, root, root)).toEqual({ enabled: false, capture: false, capture_policy: true }) + } finally { + Config.setLearnKeysAboveProjectForTest(root, undefined) + } + expect(await RulesService.effectiveLearn({ enabled: false }, root, root)).toEqual({ enabled: true, capture: true }) + }) + + 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("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 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(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 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(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 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(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 () => { 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 }) + } finally { + spy.mockRestore() + RuleProposal.drop(root) + 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", () => { + 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 })]) + }) +}) + + +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).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) + 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; 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, 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 }) + // 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 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 { + await disposeAllInstances() + } + }) + + 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 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: [] }) + 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 }) + 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([]) + }) +}) 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 new file mode 100644 index 0000000000..7d1d0cd32f --- /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 { Server } from "../../src/server/server" +import { RuleProposal } from "../../src/altimate/rules/proposal" +import { resetDatabase } from "./db" +import { disposeAllInstances, tmpdir } 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(undefined) + 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" }) + + 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" }) + 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, 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(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(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(here, Date.now() + RuleProposal.RENDERER_LEASE_MS + 1)).toBe(false) + }) + + 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) + }) +}) diff --git a/packages/opencode/test/telemetry/telemetry.test.ts b/packages/opencode/test/telemetry/telemetry.test.ts index 8d12f41d45..533e5cec63 100644 --- a/packages/opencode/test/telemetry/telemetry.test.ts +++ b/packages/opencode/test/telemetry/telemetry.test.ts @@ -248,11 +248,20 @@ const ALL_EVENT_TYPES: Telemetry.Event["type"][] = [ "sql_pre_validation", "config_env_interpolation", "plan_no_tool_generation", + "rule_proposal_shown", + "rule_proposal_answered", + "rule_drafted", + "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(54) }) })