Skip to content

Commit 265d89c

Browse files
fix(rules): a project linked by path can gain its git remote, and learn status says why rules are off (#1670)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 1931f69 commit 265d89c

9 files changed

Lines changed: 133 additions & 6 deletions

File tree

‎docs/docs/configure/learn.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,8 @@ Delivery waits at most about 5 seconds for the learn lock; if another process st
189189

190190
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.
191191

192+
**Rules need a git remote.** Team rules are kept per repository, keyed by its git remote. A project with no remote can be linked by its path, but rules stay off there: `learn status` and `learn sync` say so. Add a remote and run `altimate-code link` again, picking the same workspace: the link keeps its workspace and gains the remote, and rules sync from then on.
193+
192194
**When a rule is proposed.** A card appears above the input when:
193195

194196
- you correct the agent on the same topic in 2 or more sessions (also before the workspace has any rule);

‎packages/opencode/src/altimate/rules/sync.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export async function context(directory: string, learn: LearnConfig): Promise<Co
6969
const outcome = await resolveBindingOutcome(directory).catch(() => ({ status: "unknown" as const }))
7070
if (outcome.status === "unbound") return { status: "skipped", code: "not_linked", reason: "this project is not linked to a workspace", unlinked: true }
7171
if (outcome.status !== "bound") return { status: "skipped", code: "link_unconfirmed", reason: "the workspace link could not be confirmed" }
72-
if (!outcome.binding.repoRemote) return { status: "skipped", code: "no_remote", reason: "this project has no git remote" }
72+
if (!outcome.binding.repoRemote) return { status: "skipped", code: "no_remote", reason: "this project is linked by its path, and workspace rules need a git remote on the link (add a remote if the project has none, then run `altimate-code link`)" }
7373
const digest = credentialDigest(actAs.url, actAs.instance, actAs.apiKey)
7474
// The binding was resolved with the ambient credential: it must still be the captured one.
7575
if ((await accountDigest()) !== digest) return { status: "skipped", code: "account_changed", reason: "the Altimate account changed" }

‎packages/opencode/src/altimate/workspace/api-client.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,18 @@ export interface ProjectBindingLookup extends GetBindingResponse {
7373
matchedBy: MatchedIdentifier
7474
}
7575

76+
/**
77+
* True when the link was found by path, has no remote, and the project has one now (a remote added after linking).
78+
* Binding the same workspace again (`POST /bind`) stores the remote on the existing link; workspace rules are scoped
79+
* by remote and stay off until it has one. A link with a different remote is not changed here: that is a re-link.
80+
*/
81+
export function addsRemoteToLink(identifier: ProjectIdentifier, existing: ProjectBindingLookup): boolean {
82+
return existing.matchedBy === "path" && !!identifier.repoRemote && !existing.binding.repo_remote
83+
}
84+
85+
/** What a link prints or shows when it adds the project's remote to a path link. */
86+
export const ADDING_REMOTE_MESSAGE = "This project is linked by its path. Adding its git remote to the link, so workspace rules can sync."
87+
7688
export interface ConflictDetail {
7789
message: string
7890
existing_datamate_id?: number

‎packages/opencode/src/cli/cmd/learn.ts‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { effectCmd, fail } from "../effect-cmd"
2424
import { Flag } from "@opencode-ai/core/flag/flag"
2525
import { RulesTelemetry } from "../../altimate/rules/telemetry"
2626
import { printable } from "../../altimate/rules/printable"
27+
import type { Context as RulesSyncContext } from "../../altimate/rules/sync"
2728
import * as Playbook from "../../altimate/learn/playbook"
2829
import * as Store from "../../altimate/learn/store"
2930
import { MAX_TEXT, summarize, describeApplied, describeRejected } from "../../altimate/learn/curator"
@@ -426,8 +427,12 @@ const StatusCommand = effectCmd({
426427
out(`Limits: ${Object.entries(status.limits).map(([key, value]) => `${key}=${value}`).join(", ")}`)
427428
// altimate_change start — workspace rules
428429
const rules = status.rules
429-
if (!rules.linked) out("Workspace rules: not linked (run `altimate-code link`)")
430-
else {
430+
if (!rules.linked) {
431+
// Nothing synced yet: the rules gate says why (not linked, linked by path only, signed out, ...).
432+
const [{ RulesSync }, { Instance }] = await Promise.all([import("../../altimate/rules/sync"), import("@/project/instance")])
433+
const gate = await RulesSync.context(Instance.directory, learn).catch(() => undefined)
434+
out(rulesNotSyncedLine(gate))
435+
} else {
431436
const { RulesSummary } = await import("../../altimate/rules/summary")
432437
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)}` : ""}`)
433438
if (rules.last_check) out(`Last proposal check: ${rules.last_check.at} — ${printable(rules.last_check.outcome)}`)
@@ -439,6 +444,14 @@ const StatusCommand = effectCmd({
439444
})
440445

441446
// altimate_change start — workspace rules: send queued proposals, pull approved rules, report usage
447+
/** The `learn status` rules line before any sync, from the rules gate. Undefined: the gate could not be read. */
448+
export function rulesNotSyncedLine(gate: RulesSyncContext | undefined): string {
449+
if (!gate) return "Workspace rules: not synced yet (run `altimate-code learn sync`)"
450+
if (gate.status === "ready") return "Workspace rules: linked, not synced yet (run `altimate-code learn sync`)"
451+
if (gate.status === "skipped" && gate.code === "not_linked") return "Workspace rules: not linked (run `altimate-code link`)"
452+
return `Workspace rules: off — ${printable(gate.reason)}`
453+
}
454+
442455
function noticeLine(notice: { status: string; text: string; reviewer?: string; reason?: string }) {
443456
const status = notice.status === "active" ? "approved" : printable(notice.status)
444457
return ` ${status}${notice.reviewer ? ` by ${printable(notice.reviewer)}` : ""}: "${printable(notice.text)}"${notice.reason ? ` — ${printable(notice.reason)}` : ""}`

‎packages/opencode/src/cli/cmd/link.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import {
2525
NotConfiguredError,
2626
NotFoundError,
2727
PreconditionFailedError,
28+
ADDING_REMOTE_MESSAGE,
29+
addsRemoteToLink,
2830
type ActAs,
2931
type Binding,
3032
type DatamateRef,
@@ -436,6 +438,11 @@ export const LinkCommand = cmd({
436438
}
437439

438440
const targetId = Number(pick)
441+
if (targetId === currentId && existing && addsRemoteToLink(identifier, existing)) {
442+
prompts.log.info(ADDING_REMOTE_MESSAGE)
443+
await bindOrRebind(identifier, targetId, null, true, args.directory, actAs)
444+
return
445+
}
439446
if (targetId === currentId) {
440447
prompts.outro(`Kept "${currentName}" — nothing changed.`)
441448
return
@@ -1114,6 +1121,11 @@ export async function linkHeadless(
11141121
// An explicit `link` for the workspace the service already has: record the user's approval here (a fresh clone
11151122
// only knows the link as discovered, which `workspace sync` refuses), without a redundant server rebind.
11161123
const alreadyLinked = async (lookup: ProjectBindingLookup): Promise<void> => {
1124+
if (addsRemoteToLink(identifier, lookup)) {
1125+
deps.print(ADDING_REMOTE_MESSAGE)
1126+
await deps.bindOrRebind(identifier, lookup.datamate.id, null, actAs)
1127+
return
1128+
}
11171129
let recorded: boolean
11181130
try {
11191131
recorded = await deps.approve(identifier, lookup, actAs)

‎packages/opencode/src/plugin/tui/altimate/workspace.tsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ import {
6363
NotFoundError,
6464
PreconditionFailedError,
6565
WorkspaceApi,
66+
ADDING_REMOTE_MESSAGE,
67+
addsRemoteToLink,
6668
type Binding,
6769
type ActAs,
6870
type DatamateRef,
@@ -1218,6 +1220,8 @@ interface OnDemandPickerProps {
12181220
* correct rebind endpoint when the user swaps workspaces or picks Create
12191221
* on an already-linked project. (M3) */
12201222
matchedBy?: MatchedIdentifier
1223+
/** The link was found by path and the project has a remote it lacks: picking the linked workspace adds it. */
1224+
addsRemote?: boolean
12211225
defaultName: string
12221226
}
12231227

@@ -1319,6 +1323,12 @@ function OnDemandPickerDialog(props: OnDemandPickerProps) {
13191323
return
13201324
}
13211325
// Picked an existing workspace.
1326+
if (option.value === props.currentlyLinkedDatamateId && props.addsRemote) {
1327+
// A plain bind to the linked workspace: the service stores the remote on the existing link.
1328+
props.api.ui.toast({ variant: "info", message: ADDING_REMOTE_MESSAGE })
1329+
void bindOrRebindInline(props.api, props.identifier, option.value, undefined, listedAs)
1330+
return
1331+
}
13221332
if (option.value === props.currentlyLinkedDatamateId) {
13231333
// No-op — user picked the workspace this project is already linked to.
13241334
props.api.ui.toast({
@@ -1454,6 +1464,7 @@ async function runOnDemandPicker(api: TuiPluginApi, directory: string): Promise<
14541464
currentlyLinkedDatamateId={existing?.datamate.id}
14551465
currentlyLinkedDatamateName={existing?.datamate.name}
14561466
matchedBy={existing?.matchedBy}
1467+
addsRemote={existing ? addsRemoteToLink(identifier, existing) : false}
14571468
defaultName={defaultName}
14581469
/>
14591470
))

‎packages/opencode/test/altimate/plugin/workspace.test.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -994,11 +994,11 @@ describe("link dialogs: a workspace that already has this project's name", () =>
994994
expect(dialog.current).toBe(opensOn)
995995
})
996996

997-
async function picker(list: { id: number; name: string; ownerId?: number }[]) {
997+
async function picker(list: { id: number; name: string; ownerId?: number }[], linked: Record<string, unknown> = {}) {
998998
stubWorkspaces(list)
999999
stubCreds("acme", "https://api.acme.example.com")
10001000
const { tui, h } = harness()
1001-
render(() => linkDialogInternals.OnDemandPickerDialog({ api: tui, identifier, defaultName: "analytics" }))
1001+
render(() => linkDialogInternals.OnDemandPickerDialog({ api: tui, identifier, defaultName: "analytics", ...linked }))
10021002
await settle()
10031003
return { h, dialog: h.selects[0]! }
10041004
}
@@ -1054,6 +1054,24 @@ describe("link dialogs: a workspace that already has this project's name", () =>
10541054
expect(calls.bindAs).toEqual(["acme"])
10551055
})
10561056

1057+
test("picker: the linked workspace, linked by path only, is bound again so the link gains the remote", async () => {
1058+
const linked = { currentlyLinkedDatamateId: 2, currentlyLinkedDatamateName: "analytics", matchedBy: "path", addsRemote: true }
1059+
const { h, dialog } = await picker([{ id: 2, name: "analytics", ownerId: ME }], linked)
1060+
dialog.onSelect({ value: 2 })
1061+
await settle()
1062+
expect(calls.bind).toEqual([2])
1063+
expect(h.toasts.some((m) => m.includes("Adding its git remote"))).toBe(true)
1064+
})
1065+
1066+
test("picker: the linked workspace with nothing to add changes nothing", async () => {
1067+
const linked = { currentlyLinkedDatamateId: 2, currentlyLinkedDatamateName: "analytics", matchedBy: "remote" }
1068+
const { h, dialog } = await picker([{ id: 2, name: "analytics", ownerId: ME }], linked)
1069+
dialog.onSelect({ value: 2 })
1070+
await settle()
1071+
expect(calls.bind).toEqual([])
1072+
expect(h.toasts.some((m) => m.includes("nothing changed"))).toBe(true)
1073+
})
1074+
10571075
test("setup dialog: the namesake is bound as the credential it was listed under", async () => {
10581076
const { dialog } = await offer([{ id: 2, name: "analytics", ownerId: ME }])
10591077
dialog.onSelect({ value: "namesake" })

‎packages/opencode/test/altimate/rules/rules.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { RuleProposal } from "../../../src/altimate/rules/proposal"
88
import { RulesStore } from "../../../src/altimate/rules/store"
99
import { RulesSummary } from "../../../src/altimate/rules/summary"
1010
import { RulesSync } from "../../../src/altimate/rules/sync"
11+
import { rulesNotSyncedLine } from "../../../src/cli/cmd/learn"
1112
import { appliesTo, learningDecision, learningEnabled } from "../../../src/altimate/rules/trigger"
1213
import { Config } from "../../../src/config/config"
1314
import { WorkspaceApi } from "../../../src/altimate/workspace/api-client"
@@ -2336,3 +2337,34 @@ describe("cards across the feature's edges", () => {
23362337
expect(parse(await fs.readFile(file, "utf8"))).toEqual([])
23372338
})
23382339
})
2340+
2341+
describe("learn status before the first sync", () => {
2342+
test("a project linked by path says rules need a git remote, not 'not linked'", async () => {
2343+
const root = await tmp()
2344+
const env = process.env.ALTIMATE_DISABLE_WORKSPACE
2345+
delete process.env.ALTIMATE_DISABLE_WORKSPACE
2346+
const spies = [
2347+
spyOn(WorkspaceApi, "captureCredentials").mockResolvedValue({ url: "http://x/", instance: "t", apiKey: "k" }),
2348+
spyOn(WorkspaceState, "resolveBindingOutcome").mockResolvedValue({ status: "bound", binding: { datamateId: 1, repoRemote: null, projectPath: root } } as never),
2349+
]
2350+
try {
2351+
const gate = await RulesSync.context(root, { enabled: true })
2352+
expect(gate).toMatchObject({ status: "skipped", code: "no_remote" })
2353+
const line = rulesNotSyncedLine(gate)
2354+
expect(line).not.toContain("not linked")
2355+
expect(line).toContain("need a git remote")
2356+
expect(line).toContain("altimate-code link")
2357+
} finally {
2358+
for (const spy of spies) spy.mockRestore()
2359+
if (env === undefined) delete process.env.ALTIMATE_DISABLE_WORKSPACE
2360+
else process.env.ALTIMATE_DISABLE_WORKSPACE = env
2361+
}
2362+
})
2363+
2364+
test("the other gate outcomes", () => {
2365+
expect(rulesNotSyncedLine({ status: "skipped", code: "not_linked", reason: "x", unlinked: true })).toBe("Workspace rules: not linked (run `altimate-code link`)")
2366+
expect(rulesNotSyncedLine({ status: "ready" } as never)).toContain("not synced yet (run `altimate-code learn sync`)")
2367+
expect(rulesNotSyncedLine({ status: "skipped", code: "not_signed_in", reason: "not signed in to Altimate" })).toBe("Workspace rules: off — not signed in to Altimate")
2368+
expect(rulesNotSyncedLine(undefined)).toContain("not synced yet")
2369+
})
2370+
})

‎packages/opencode/test/cli/workspace-command.test.ts‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
import { SIGN_IN_COMMAND, linkHeadless, matchWorkspace, type LinkHeadlessDeps } from "../../src/cli/cmd/link"
1818
import { AltimateAuthPlugin } from "../../src/altimate/plugin/altimate"
1919
import { AltimateApi } from "../../src/altimate/api/client"
20-
import { WorkspaceApi } from "../../src/altimate/workspace/api-client"
20+
import { WorkspaceApi, addsRemoteToLink } from "../../src/altimate/workspace/api-client"
2121
import yargs from "yargs"
2222

2323
const binding = {
@@ -380,6 +380,33 @@ describe("link --workspace / --create without prompting", () => {
380380
expect(f.out.join("\n")).toContain("link confirmed on this machine")
381381
})
382382

383+
test("linked by path, and the project has a remote now: binding the same workspace again adds the remote", async () => {
384+
const pathOnly = { datamate: growth, binding: { repo_remote: null, project_path: dir }, matchedBy: "path" } as any
385+
const seen: unknown[] = []
386+
const f = linkDeps({
387+
getBindingForProject: async () => pathOnly,
388+
bindOrRebind: async (i, id, e) => void (seen.push(i.repoRemote, e), f.calls.push(`bind:${id}`)),
389+
})
390+
await linkHeadless({ directory: dir, workspace: "35", yes: false }, f.deps)
391+
expect(process.exitCode ?? 0).toBe(0)
392+
// A plain bind (no "current" link) to the same workspace: the service stores the remote on the existing link.
393+
expect(f.calls).toEqual(["bind:35"])
394+
expect(seen[0]).toBeTruthy()
395+
expect(seen[1]).toBeNull()
396+
expect(f.out.join("\n")).toContain("Adding its git remote")
397+
})
398+
399+
test("the remote is added only to a path-matched link that has none, in a project that has one", () => {
400+
const id = { repoRemote: "https://github.com/acme/demo", projectPath: "/p" }
401+
const lookup = (matchedBy: "path" | "remote", repo_remote: string | null) =>
402+
({ datamate: growth, binding: { repo_remote, project_path: "/p" }, matchedBy }) as any
403+
expect(addsRemoteToLink(id, lookup("path", null))).toBe(true)
404+
expect(addsRemoteToLink(id, lookup("remote", "https://github.com/acme/demo"))).toBe(false)
405+
// The link already has a (different) remote: changing it is a re-link, not an addition.
406+
expect(addsRemoteToLink(id, lookup("path", "https://github.com/acme/old"))).toBe(false)
407+
expect(addsRemoteToLink({ projectPath: "/p" } as any, lookup("path", null))).toBe(false)
408+
})
409+
383410
test("re-linking to a different workspace needs --yes", async () => {
384411
const f = linkDeps({ getBindingForProject: async () => ({ datamate: growth, binding: {} }) as any })
385412
await linkHeadless({ directory: dir, workspace: "9", yes: false }, f.deps)

0 commit comments

Comments
 (0)