Skip to content

Commit 7785643

Browse files
authored
feat(cli): add a Lovable handoff target to plan and impl (#871)
* feat(cli): add a Lovable handoff target to plan and impl Lovable's agent runs in Lovable's cloud and only sees the repo through GitHub sync, so without repo-local guidance it answers CipherStash questions from stale training data (the pre-EQL-v3 "needs a Postgres extension and superuser" story) and talks users out of a supported Supabase setup. The new --target lovable (and picker entry) writes the same AGENTS.md as the editor-agent handoff — doctrine plus the per-integration skills inlined — with Lovable-specific next steps: commit and push the generated files, then add a Knowledge note in the Lovable project settings pointing the agent at AGENTS.md and .cipherstash/setup-prompt.md, since Lovable does not auto-load AGENTS.md the way Cursor or Windsurf do. * fix(cli): only claim AGENTS.md exists when the write succeeded Review follow-up on the Lovable handoff. When writeAgentsMd() fails, the handoff note now shows recovery guidance instead of telling the user to commit a file that was never written — in both the Lovable and editor-agent (agents-md) steps, which shared the flaw. skillsLoadedLines() keyed its 'durable rules are in AGENTS.md' claim on any AGENTS.md-writing handoff, but for agents-md and lovable the skills are inlined into AGENTS.md itself — all skills failing means the file was not written. Only codex writes its doctrine separately, so only codex keeps that pointer on the all-failed path. * test(cli): cover the Lovable handoff step, its dispatch, and the AGENTS.md honesty branch Coverage-bot follow-up on the Lovable handoff. Four gaps, all in new or changed code that CI would have shipped green: - handoff-lovable.test.ts — neither arm of the writeAgentsMd success/ failure split was exercised, so a regression could record skills as inlined after the write failed, or tell the user to commit a file that was never written. - handoff-agents-md.test.ts — the step had no test at all, and this branch gained the same honesty ternary. - how-to-proceed-dispatch.test.ts — buildOptions ordering was covered but not the dispatch arms. Table-driven off HANDOFF_CHOICES so a target that reaches the picker without a dispatch arm fails here. - The --help e2e now loops over impl and plan; each carries its own --target descriptor, so asserting only impl let a dropped target ship silently from plan. Mutation-checked: rerouting the lovable dispatch arm and forcing the note ternary true each fail the new tests.
1 parent f90b0db commit 7785643

15 files changed

Lines changed: 483 additions & 30 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'stash': minor
3+
---
4+
5+
Add a `lovable` handoff target to `stash plan` and `stash impl` (`--target lovable`, plus a new agent-target picker entry). It writes the same AGENTS.md as the editor-agent handoff — doctrine plus the per-integration skills inlined — but the next-steps guidance is Lovable-specific: commit and push the generated files through Lovable's GitHub sync, then add a Knowledge note in the Lovable project settings pointing the agent at `AGENTS.md` and `.cipherstash/setup-prompt.md`. Without repo-local guidance, Lovable's agent answers CipherStash questions from stale training data (the pre-EQL-v3 "needs a Postgres extension and superuser" story) and talks users out of a supported Supabase setup.

packages/cli/src/cli/registry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ export const registry: CommandGroup[] = [
156156
name: '--target',
157157
value: '<name>',
158158
description:
159-
'Skip the agent-target picker and hand off directly to one of claude-code | codex | agents-md | wizard. Safe in non-TTY contexts.',
159+
'Skip the agent-target picker and hand off directly to one of claude-code | codex | agents-md | lovable | wizard. Safe in non-TTY contexts.',
160160
},
161161
],
162162
},
@@ -178,7 +178,7 @@ export const registry: CommandGroup[] = [
178178
name: '--target',
179179
value: '<name>',
180180
description:
181-
'Skip the agent-target picker and hand off directly to one of claude-code | codex | agents-md | wizard. Safe in non-TTY contexts.',
181+
'Skip the agent-target picker and hand off directly to one of claude-code | codex | agents-md | lovable | wizard. Safe in non-TTY contexts.',
182182
},
183183
],
184184
},

packages/cli/src/commands/impl/__tests__/how-to-proceed.test.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,28 @@ const claudeOnly: InitState = { agents: makeAgents(true, false) }
2626
const codexOnly: InitState = { agents: makeAgents(false, true) }
2727

2828
describe('howToProceed — buildOptions', () => {
29-
it('offers all four targets in implement mode', () => {
29+
it('offers all five targets in implement mode', () => {
3030
const opts = buildOptions(noAgents, 'implement')
3131
const values = opts.map((o) => o.value)
32-
expect(values).toEqual(['claude-code', 'codex', 'agents-md', 'wizard'])
32+
expect(values).toEqual([
33+
'claude-code',
34+
'codex',
35+
'agents-md',
36+
'lovable',
37+
'wizard',
38+
])
3339
})
3440

35-
it('offers all four targets in plan mode', () => {
41+
it('offers all five targets in plan mode', () => {
3642
const opts = buildOptions(noAgents, 'plan')
3743
const values = opts.map((o) => o.value)
38-
expect(values).toEqual(['claude-code', 'codex', 'agents-md', 'wizard'])
44+
expect(values).toEqual([
45+
'claude-code',
46+
'codex',
47+
'agents-md',
48+
'lovable',
49+
'wizard',
50+
])
3951
})
4052

4153
it('reflects detection state in hints regardless of mode', () => {
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import type { InitState } from '../../../init/types.js'
3+
4+
// Same seam as the handoff-codex test. This step launches nothing — it writes
5+
// the artifacts for an editor agent (Cursor / Windsurf / Cline) and prints the
6+
// guidance — so the unit under test is the honesty contract between
7+
// `writeAgentsMd`'s result, the recorded delivery, and the note.
8+
const availableSkills = vi.hoisted(() => vi.fn())
9+
vi.mock('../../../init/lib/install-skills.js', () => ({ availableSkills }))
10+
const writeAgentsMd = vi.hoisted(() => vi.fn())
11+
vi.mock('../../../init/lib/handoff-helpers.js', () => ({
12+
AGENTS_MD_REL_PATH: 'AGENTS.md',
13+
writeAgentsMd,
14+
writeArtifacts: vi.fn(),
15+
}))
16+
const buildAgentsMdBody = vi.hoisted(() => vi.fn())
17+
vi.mock('../../../init/lib/build-agents-md.js', () => ({ buildAgentsMdBody }))
18+
vi.mock('@clack/prompts', () => ({
19+
note: vi.fn(),
20+
log: { success: vi.fn(), info: vi.fn(), warn: vi.fn() },
21+
}))
22+
23+
import * as p from '@clack/prompts'
24+
import { writeArtifacts } from '../../../init/lib/handoff-helpers.js'
25+
import { handoffAgentsMdStep } from '../handoff-agents-md.js'
26+
27+
const state = { integration: 'drizzle' } as unknown as InitState
28+
29+
const noteBody = () => String(vi.mocked(p.note).mock.calls[0][0])
30+
const agentsMdMode = () => vi.mocked(buildAgentsMdBody).mock.calls[0][1]
31+
const delivery = () => vi.mocked(writeArtifacts).mock.calls[0][3]
32+
const handoffRecorded = () => vi.mocked(writeArtifacts).mock.calls[0][2]
33+
34+
beforeEach(() => {
35+
vi.clearAllMocks()
36+
writeAgentsMd.mockReturnValue(true)
37+
availableSkills.mockReturnValue(['stash-encryption', 'stash-drizzle'])
38+
})
39+
40+
describe('when AGENTS.md was written', () => {
41+
it('inlines the skills — these agents do not auto-load skill directories', async () => {
42+
await handoffAgentsMdStep.run(state)
43+
expect(agentsMdMode()).toBe('doctrine-plus-skills')
44+
expect(handoffRecorded()).toBe('agents-md')
45+
expect(delivery()).toEqual({
46+
installed: [],
47+
inlined: ['stash-encryption', 'stash-drizzle'],
48+
failed: [],
49+
})
50+
})
51+
52+
it('tells the user their editor agent picks the file up automatically', async () => {
53+
await handoffAgentsMdStep.run(state)
54+
const body = noteBody()
55+
expect(body).toContain('pick up AGENTS.md automatically')
56+
expect(body).toContain('.cipherstash/setup-prompt.md')
57+
})
58+
})
59+
60+
describe('when AGENTS.md could not be written', () => {
61+
beforeEach(() => {
62+
writeAgentsMd.mockReturnValue(false)
63+
})
64+
65+
it('records the skills as failed, not inlined', async () => {
66+
await handoffAgentsMdStep.run(state)
67+
expect(delivery()).toEqual({
68+
installed: [],
69+
inlined: [],
70+
failed: ['stash-encryption', 'stash-drizzle'],
71+
})
72+
})
73+
74+
it('does not claim an agent will pick up a file that was never written', async () => {
75+
await handoffAgentsMdStep.run(state)
76+
const body = noteBody()
77+
expect(body).toContain('could not be written')
78+
expect(body).not.toContain('pick up AGENTS.md automatically')
79+
})
80+
})
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import type { InitState } from '../../../init/types.js'
3+
4+
// Same seam as the handoff-codex test, minus the launch: Lovable's agent runs
5+
// in Lovable's cloud, so this step only writes files and prints guidance. The
6+
// unit under test is the honesty contract between `writeAgentsMd`'s result,
7+
// the delivery recorded into the artifacts, and what the note tells the user
8+
// to do next.
9+
const availableSkills = vi.hoisted(() => vi.fn())
10+
vi.mock('../../../init/lib/install-skills.js', () => ({ availableSkills }))
11+
const writeAgentsMd = vi.hoisted(() => vi.fn())
12+
vi.mock('../../../init/lib/handoff-helpers.js', () => ({
13+
AGENTS_MD_REL_PATH: 'AGENTS.md',
14+
writeAgentsMd,
15+
writeArtifacts: vi.fn(),
16+
}))
17+
const buildAgentsMdBody = vi.hoisted(() => vi.fn())
18+
vi.mock('../../../init/lib/build-agents-md.js', () => ({ buildAgentsMdBody }))
19+
vi.mock('@clack/prompts', () => ({
20+
note: vi.fn(),
21+
log: { success: vi.fn(), info: vi.fn(), warn: vi.fn() },
22+
}))
23+
24+
import * as p from '@clack/prompts'
25+
import { writeArtifacts } from '../../../init/lib/handoff-helpers.js'
26+
import { handoffLovableStep } from '../handoff-lovable.js'
27+
28+
const state = { integration: 'supabase' } as unknown as InitState
29+
30+
const noteBody = () => String(vi.mocked(p.note).mock.calls[0][0])
31+
const agentsMdMode = () => vi.mocked(buildAgentsMdBody).mock.calls[0][1]
32+
const inlinedList = () => vi.mocked(buildAgentsMdBody).mock.calls[0][2]
33+
const delivery = () => vi.mocked(writeArtifacts).mock.calls[0][3]
34+
const handoffRecorded = () => vi.mocked(writeArtifacts).mock.calls[0][2]
35+
36+
beforeEach(() => {
37+
vi.clearAllMocks()
38+
writeAgentsMd.mockReturnValue(true)
39+
availableSkills.mockReturnValue(['stash-encryption', 'stash-supabase'])
40+
})
41+
42+
describe('when AGENTS.md was written', () => {
43+
it('inlines the per-integration skills — Lovable does not load skill directories', async () => {
44+
await handoffLovableStep.run(state)
45+
expect(agentsMdMode()).toBe('doctrine-plus-skills')
46+
expect(inlinedList()).toEqual(['stash-encryption', 'stash-supabase'])
47+
})
48+
49+
it('records the skills as inlined under the lovable handoff', async () => {
50+
await handoffLovableStep.run(state)
51+
expect(handoffRecorded()).toBe('lovable')
52+
expect(delivery()).toEqual({
53+
installed: [],
54+
inlined: ['stash-encryption', 'stash-supabase'],
55+
failed: [],
56+
})
57+
})
58+
59+
it('walks the user through the GitHub sync and the Knowledge pointer', async () => {
60+
// Lovable only sees the repo through its GitHub sync and does not
61+
// auto-load AGENTS.md, so both halves have to be in the note or the
62+
// guidance never reaches the agent.
63+
await handoffLovableStep.run(state)
64+
const body = noteBody()
65+
expect(body).toContain('Commit and push')
66+
expect(body).toContain('Settings → Knowledge')
67+
expect(body).toContain('.cipherstash/setup-prompt.md')
68+
})
69+
})
70+
71+
// The failure arm is the whole point of the honesty contract: telling the
72+
// user to commit a file that was never written sends them hunting for it.
73+
describe('when AGENTS.md could not be written', () => {
74+
beforeEach(() => {
75+
writeAgentsMd.mockReturnValue(false)
76+
})
77+
78+
it('records the skills as failed, not inlined', async () => {
79+
await handoffLovableStep.run(state)
80+
expect(delivery()).toEqual({
81+
installed: [],
82+
inlined: [],
83+
failed: ['stash-encryption', 'stash-supabase'],
84+
})
85+
})
86+
87+
it('says the write failed instead of telling the user to commit it', async () => {
88+
await handoffLovableStep.run(state)
89+
const body = noteBody()
90+
expect(body).toContain('could not be written')
91+
expect(body).not.toContain('Commit and push')
92+
})
93+
94+
it('still points at the artifacts that did land', async () => {
95+
await handoffLovableStep.run(state)
96+
const body = noteBody()
97+
expect(body).toContain('.cipherstash/setup-prompt.md')
98+
expect(body).toContain('.cipherstash/context.json')
99+
})
100+
})
101+
102+
// A stripped CLI build ships no skills. AGENTS.md still carries the doctrine,
103+
// so the guidance stands — there is just nothing to inline.
104+
it('records an empty delivery when this build ships no skills', async () => {
105+
availableSkills.mockReturnValue([])
106+
await handoffLovableStep.run(state)
107+
expect(delivery()).toEqual({ installed: [], inlined: [], failed: [] })
108+
expect(noteBody()).toContain('Settings → Knowledge')
109+
})
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { beforeEach, expect, it, vi } from 'vitest'
2+
import type { HandoffChoice, InitState } from '../../../init/types.js'
3+
4+
// `buildOptions` / `defaultChoice` / `resolveTarget` are pure and covered in
5+
// `impl/__tests__/how-to-proceed.test.ts`. What is NOT covered there is the
6+
// dispatch arm: a pre-resolved `state.handoff` must skip the picker and run
7+
// the matching step. Misrouting or dropping an arm would otherwise pass CI.
8+
const runs = vi.hoisted(() => ({
9+
'claude-code': vi.fn(async (s: InitState) => s),
10+
codex: vi.fn(async (s: InitState) => s),
11+
'agents-md': vi.fn(async (s: InitState) => s),
12+
lovable: vi.fn(async (s: InitState) => s),
13+
wizard: vi.fn(async (s: InitState) => s),
14+
}))
15+
vi.mock('../handoff-claude.js', () => ({
16+
handoffClaudeStep: { run: runs['claude-code'] },
17+
}))
18+
vi.mock('../handoff-codex.js', () => ({
19+
handoffCodexStep: { run: runs.codex },
20+
}))
21+
vi.mock('../handoff-agents-md.js', () => ({
22+
handoffAgentsMdStep: { run: runs['agents-md'] },
23+
}))
24+
vi.mock('../handoff-lovable.js', () => ({
25+
handoffLovableStep: { run: runs.lovable },
26+
}))
27+
vi.mock('../handoff-wizard.js', () => ({
28+
handoffWizardStep: { run: runs.wizard },
29+
}))
30+
const select = vi.hoisted(() => vi.fn())
31+
vi.mock('@clack/prompts', () => ({
32+
select,
33+
isCancel: vi.fn(() => false),
34+
note: vi.fn(),
35+
log: { success: vi.fn(), info: vi.fn(), warn: vi.fn() },
36+
}))
37+
38+
import { HANDOFF_CHOICES, howToProceedStep } from '../how-to-proceed.js'
39+
40+
beforeEach(() => {
41+
vi.clearAllMocks()
42+
})
43+
44+
// Table-driven off HANDOFF_CHOICES so a new target that reaches the picker
45+
// without a dispatch arm fails here rather than at runtime.
46+
for (const choice of HANDOFF_CHOICES) {
47+
it(`routes a pre-resolved \`${choice}\` state to its own step, without a prompt`, async () => {
48+
await howToProceedStep.run({ handoff: choice } as InitState)
49+
50+
expect(runs[choice]).toHaveBeenCalledTimes(1)
51+
// The dispatched step must see the resolved choice on the state.
52+
expect(runs[choice].mock.calls[0][0].handoff).toBe(choice)
53+
// Every other arm stays untouched.
54+
for (const other of HANDOFF_CHOICES) {
55+
if (other !== choice) expect(runs[other]).not.toHaveBeenCalled()
56+
}
57+
// A pre-resolved target is what makes the command non-TTY safe.
58+
expect(select).not.toHaveBeenCalled()
59+
})
60+
}
61+
62+
it('runs the picked step when the picker is used', async () => {
63+
const picked: HandoffChoice = 'lovable'
64+
select.mockResolvedValueOnce(picked)
65+
66+
await howToProceedStep.run({ agents: undefined } as InitState)
67+
68+
expect(select).toHaveBeenCalledTimes(1)
69+
expect(runs.lovable).toHaveBeenCalledTimes(1)
70+
expect(runs.lovable.mock.calls[0][0].handoff).toBe('lovable')
71+
})

packages/cli/src/commands/impl/steps/handoff-agents-md.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,27 @@ export const handoffAgentsMdStep: HandoffStep = {
4646
failed: written ? [] : inlinable,
4747
})
4848

49+
// Same honesty rule as the Lovable step: only claim AGENTS.md exists
50+
// when the write succeeded (writeAgentsMd already logged the warning).
4951
p.note(
50-
[
51-
`Rules at ${AGENTS_MD_REL_PATH}`,
52-
`Action plan at ${SETUP_PROMPT_REL_PATH}`,
53-
`Context at ${CONTEXT_REL_PATH}`,
54-
'',
55-
'Cursor / Windsurf / Cline pick up AGENTS.md automatically.',
56-
`Open your agent and point it at ${SETUP_PROMPT_REL_PATH} to start.`,
57-
].join('\n'),
52+
written
53+
? [
54+
`Rules at ${AGENTS_MD_REL_PATH}`,
55+
`Action plan at ${SETUP_PROMPT_REL_PATH}`,
56+
`Context at ${CONTEXT_REL_PATH}`,
57+
'',
58+
'Cursor / Windsurf / Cline pick up AGENTS.md automatically.',
59+
`Open your agent and point it at ${SETUP_PROMPT_REL_PATH} to start.`,
60+
].join('\n')
61+
: [
62+
`${AGENTS_MD_REL_PATH} could not be written (see the warning above).`,
63+
`Action plan at ${SETUP_PROMPT_REL_PATH}`,
64+
`Context at ${CONTEXT_REL_PATH}`,
65+
'',
66+
'Fix the file permissions and re-run this command so the rules',
67+
`land in ${AGENTS_MD_REL_PATH}, then open your agent and point it`,
68+
`at ${SETUP_PROMPT_REL_PATH} to start.`,
69+
].join('\n'),
5870
'Drive your editor agent',
5971
)
6072

0 commit comments

Comments
 (0)