feat(solo): pure-function suggestion engine for upcoming Solo Pipeline mode (#16)

Foundation for Solo mode's project-aware welcome — when the user toggles

Solo on without a concrete task, we want to greet them with ranked guesses

of what they might want to do next instead of an empty prompt. This PR

lands the brain (zero-token, zero I/O, fully deterministic) so the eventual

wiring layer (waiting on the in-flight coordinator-mode optimization) can

drop straight on top.

Engine takes the existing zero-cost project snapshot (RecentActivityResult)

plus an optional Tier-1 enrichment bag (stash count, test-gap detection,

TODO grep against dirtyFiles, version-vs-notes mismatch, mid-flight git

state) and emits up to 5 ranked SoloSuggestions. Each carries an i18n key

and an entryStage hint so the consumer can route the user past stages

that don't apply (e.g. 'review' for already-shipped commits, 'land' for

release prep).

Design constraints baked in:

- Pure function. No git spawning, no LLM, no I/O. Tier-1 signals are

  passed in by the host who decides which to gather.

- Deterministic. (activity, tier1, now) -> stable output. Tests rely on

  this for stable assertions.

- Bounded. Final list capped at 5; per-category dedup keeps the highest-

  scoring entry per category.

- Locale-neutral. Returns translation keys + interpolation params, not

  rendered strings — desktop translates with the active locale.

Score table chosen with deliberate headroom for boosters (recency 0-15,

sample 0-10, file-specific 0-10) so the priority order survives any

boost combination: resolve-conflict 100 > test-gap 55 > ship 35 >

finish-wip 30 > release 28 > cleanup 20 > generic 0.

Foreign-dirty-file detection: when most dirtyFiles look unrelated to the

previous session's edited-files sample (e.g. another agent's parked work

in the same worktree — exactly the situation we're in right now while

the coordinator-mode AI works), the finish-wip suggestion downscores by

15 and switches to a copy that hints at foreign authorship.

Coupling boundary: imports from projectActivityService only. No chat /

WS / coordinator surfaces, so the upcoming Solo wiring can sit on top

without circular deps.

Tested: 23 / 23 unit tests covering each rule, dedup, capping, scoring,

determinism, recency boost, code-source detection, foreign-dirty downscore.

Confidence: high. Scope-risk: narrow (additive, isolated module, zero

code paths touched outside the new files).

Co-authored-by: 你的姓名 <you@example.com>
This commit is contained in:
小橙子 2026-06-11 22:26:54 +08:00 committed by GitHub
parent 4dd2ebd579
commit be7baafc6a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 967 additions and 0 deletions

View File

@ -0,0 +1,383 @@
import { describe, expect, it } from 'bun:test'
import {
buildSoloSuggestions,
type SoloSignalsTier1,
_SOLO_SUGGESTIONS_INTERNALS,
} from './soloSuggestions'
import type { RecentActivityResult } from './projectActivityService'
const FROZEN_NOW = Date.parse('2026-06-11T12:00:00Z')
/** Minimal activity fixture — clean repo, no session, no signals. */
function emptyActivity(): RecentActivityResult {
return {
hasActivity: false,
workDir: '/repo',
}
}
/** Fixture with a recent session + git context, no dirty work. */
function quietActivity(overrides: Partial<RecentActivityResult> = {}): RecentActivityResult {
return {
hasActivity: true,
workDir: '/repo',
lastSession: {
sessionId: 'sess-1',
title: 'previous work',
modifiedAt: new Date(FROZEN_NOW - 60 * 60 * 1000).toISOString(), // 1h ago
messageCount: 12,
filesEditedCount: 2,
filesEditedSample: ['src/foo.ts', 'src/bar.ts'],
},
git: {
branch: 'feat/x',
defaultBranch: 'main',
aheadCount: 0,
behindCount: 0,
dirtyCount: 0,
dirtyFiles: [],
},
...overrides,
}
}
describe('buildSoloSuggestions — fallback behavior', () => {
it('returns ONLY the generic fallback when no signals fire', () => {
const out = buildSoloSuggestions(emptyActivity(), {}, { now: FROZEN_NOW })
expect(out).toHaveLength(1)
expect(out[0]!.category).toBe('generic')
expect(out[0]!.id).toBe('generic')
expect(out[0]!.entryStage).toBe('plan')
})
it('drops the generic fallback once any specific suggestion fires', () => {
const activity = quietActivity({
git: {
branch: 'feat/x',
defaultBranch: 'main',
aheadCount: 0,
behindCount: 0,
dirtyCount: 2,
dirtyFiles: ['src/foo.ts', 'src/bar.ts'],
},
})
const out = buildSoloSuggestions(activity, {}, { now: FROZEN_NOW })
expect(out.find((s) => s.category === 'generic')).toBeUndefined()
expect(out.find((s) => s.category === 'finish-wip')).toBeDefined()
})
it('is fully deterministic — same inputs produce identical output', () => {
const a = quietActivity({
git: {
branch: 'feat/x',
defaultBranch: 'main',
aheadCount: 3,
behindCount: 0,
dirtyCount: 1,
dirtyFiles: ['src/foo.ts'],
},
})
const t1: SoloSignalsTier1 = {
stashCount: 1,
missingTestFiles: ['src/foo.ts'],
}
const a1 = buildSoloSuggestions(a, t1, { now: FROZEN_NOW })
const a2 = buildSoloSuggestions(a, t1, { now: FROZEN_NOW })
expect(a1.map((s) => s.id)).toEqual(a2.map((s) => s.id))
expect(a1.map((s) => s.score)).toEqual(a2.map((s) => s.score))
})
})
describe('buildSoloSuggestions — rule: finish-wip', () => {
it('fires on dirtyCount > 0, embedding count + sample in params', () => {
const activity = quietActivity({
git: {
branch: 'feat/x',
defaultBranch: 'main',
aheadCount: 0,
behindCount: 0,
dirtyCount: 3,
dirtyFiles: ['src/foo.ts', 'src/bar.ts', 'README.md'],
},
})
const out = buildSoloSuggestions(activity, {}, { now: FROZEN_NOW })
const wip = out.find((s) => s.category === 'finish-wip')
expect(wip).toBeDefined()
expect(wip!.title.params!.count).toBe(3)
expect(wip!.taskPrompt.params!.count).toBe(3)
expect(String(wip!.taskPrompt.params!.files)).toContain('src/foo.ts')
})
it('downscores when dirty files look foreign vs the last session', () => {
// Session edited foo.ts/bar.ts, but the dirty set is dominated
// by unrelated files (likely another agent's parked work).
const activity = quietActivity({
git: {
branch: 'feat/x',
defaultBranch: 'main',
aheadCount: 0,
behindCount: 0,
dirtyCount: 4,
dirtyFiles: [
'src/other-ai-1.ts',
'src/other-ai-2.ts',
'src/other-ai-3.ts',
'src/other-ai-4.ts',
],
},
})
const out = buildSoloSuggestions(activity, {}, { now: FROZEN_NOW })
const wip = out.find((s) => s.category === 'finish-wip')!
// Foreign-dominated → detail copy switches AND score drops by 15.
expect(wip.detail!.key).toBe('solo.suggest.finishWip.detailForeign')
// Base 30 + recency 15 (1h ago) + sample-bonus 10 - foreign 15 = 40
expect(wip.score).toBe(40)
})
it('does NOT fire when dirtyCount is 0', () => {
const out = buildSoloSuggestions(quietActivity(), {}, { now: FROZEN_NOW })
expect(out.find((s) => s.category === 'finish-wip')).toBeUndefined()
})
})
describe('buildSoloSuggestions — rule: ship-ahead', () => {
it('routes "review" entry stage when ahead > 0 on a feature branch', () => {
const activity = quietActivity({
git: {
branch: 'feat/x',
defaultBranch: 'main',
aheadCount: 5,
behindCount: 0,
dirtyCount: 0,
dirtyFiles: [],
},
})
const out = buildSoloSuggestions(activity, {}, { now: FROZEN_NOW })
const ship = out.find((s) => s.category === 'ship')!
expect(ship).toBeDefined()
expect(ship.entryStage).toBe('review')
expect(ship.title.params!.branch).toBe('feat/x')
expect(ship.title.params!.count).toBe(5)
})
it('does NOT fire when on the default branch (ahead on main is suspicious, not ready-to-ship)', () => {
const activity = quietActivity({
git: {
branch: 'main',
defaultBranch: 'main',
aheadCount: 3,
behindCount: 0,
dirtyCount: 0,
dirtyFiles: [],
},
})
const out = buildSoloSuggestions(activity, {}, { now: FROZEN_NOW })
expect(out.find((s) => s.category === 'ship')).toBeUndefined()
})
it('does NOT fire when ahead is 0', () => {
const activity = quietActivity({
git: {
branch: 'feat/x',
defaultBranch: 'main',
aheadCount: 0,
behindCount: 0,
dirtyCount: 0,
dirtyFiles: [],
},
})
const out = buildSoloSuggestions(activity, {}, { now: FROZEN_NOW })
expect(out.find((s) => s.category === 'ship')).toBeUndefined()
})
})
describe('buildSoloSuggestions — rule: test-gap', () => {
it('fires for the first source file in missingTestFiles, plan entry', () => {
const out = buildSoloSuggestions(
quietActivity(),
{ missingTestFiles: ['src/lib/foo.ts'] },
{ now: FROZEN_NOW },
)
const gap = out.find((s) => s.category === 'test-gap')!
expect(gap).toBeDefined()
expect(gap.entryStage).toBe('plan')
expect(gap.taskPrompt.params!.file).toBe('src/lib/foo.ts')
})
it('skips obvious test files in the missing list (defensive — caller may pass them by accident)', () => {
const out = buildSoloSuggestions(
quietActivity(),
{ missingTestFiles: ['src/lib/foo.test.ts', 'src/lib/bar.spec.ts'] },
{ now: FROZEN_NOW },
)
expect(out.find((s) => s.category === 'test-gap')).toBeUndefined()
})
it('outranks ship-ahead when both fire (test gap is more easily ignored)', () => {
const activity = quietActivity({
git: {
branch: 'feat/x',
defaultBranch: 'main',
aheadCount: 5,
behindCount: 0,
dirtyCount: 0,
dirtyFiles: [],
},
})
const out = buildSoloSuggestions(
activity,
{ missingTestFiles: ['src/lib/foo.ts'] },
{ now: FROZEN_NOW },
)
expect(out[0]!.category).toBe('test-gap')
})
})
describe('buildSoloSuggestions — rule: todo-marker', () => {
it('fires on first todoHit, embedding excerpt in detail', () => {
const out = buildSoloSuggestions(
quietActivity(),
{
todoHits: [
{ path: 'src/foo.ts', excerpt: 'TODO: handle null case' },
{ path: 'src/bar.ts', excerpt: 'FIXME: leak' },
],
},
{ now: FROZEN_NOW },
)
const todo = out.find((s) => s.category === 'cleanup')!
expect(todo).toBeDefined()
expect(todo.taskPrompt.params!.file).toBe('src/foo.ts')
expect(String(todo.detail!.params!.excerpt)).toContain('handle null case')
})
})
describe('buildSoloSuggestions — rule: release-mismatch', () => {
it('routes to "land" entry stage with the kind-specific i18n keys', () => {
const out = buildSoloSuggestions(
quietActivity(),
{
releaseMismatch: {
desktopVersion: '0.5.10',
latestNotes: '0.5.9',
kind: 'notes-missing',
},
},
{ now: FROZEN_NOW },
)
const rel = out.find((s) => s.category === 'release')!
expect(rel).toBeDefined()
expect(rel.entryStage).toBe('land')
expect(rel.id).toBe('release-notes-missing')
expect(rel.title.key).toBe('solo.suggest.releaseMismatch.notes-missing.title')
expect(rel.title.params!.desktopVersion).toBe('0.5.10')
})
})
describe('buildSoloSuggestions — rule: resolve-conflict', () => {
it('beats every other suggestion in score', () => {
const out = buildSoloSuggestions(
quietActivity({
git: {
branch: 'feat/x',
defaultBranch: 'main',
aheadCount: 5,
behindCount: 0,
dirtyCount: 3,
dirtyFiles: ['src/foo.ts'],
},
}),
{ gitInProgress: 'merge', missingTestFiles: ['src/foo.ts'] },
{ now: FROZEN_NOW },
)
expect(out[0]!.category).toBe('resolve-conflict')
expect(out[0]!.title.key).toBe('solo.suggest.resolveConflict.merge.title')
})
})
describe('buildSoloSuggestions — capping + per-category dedup', () => {
it('caps at 5 entries even if many rules fire', () => {
const activity = quietActivity({
git: {
branch: 'feat/x',
defaultBranch: 'main',
aheadCount: 5,
behindCount: 3,
dirtyCount: 3,
dirtyFiles: ['src/foo.ts'],
},
})
const tier1: SoloSignalsTier1 = {
stashCount: 1,
missingTestFiles: ['src/foo.ts'],
todoHits: [{ path: 'src/foo.ts', excerpt: 'TODO' }],
releaseMismatch: {
desktopVersion: '0.5.10',
latestNotes: '0.5.9',
kind: 'notes-missing',
},
gitInProgress: 'rebase',
}
const out = buildSoloSuggestions(activity, tier1, { now: FROZEN_NOW })
expect(out.length).toBeLessThanOrEqual(5)
})
it('keeps only the highest-scoring entry per category (e.g. fresh dirty over stash)', () => {
// Both finish-wip rules fire (dirty + stash). Dirty has base 30 +
// sample-bonus + recency, stash is base 30 - 12 → dirty wins.
const activity = quietActivity({
git: {
branch: 'feat/x',
defaultBranch: 'main',
aheadCount: 0,
behindCount: 0,
dirtyCount: 1,
dirtyFiles: ['src/foo.ts'],
},
})
const out = buildSoloSuggestions(
activity,
{ stashCount: 2 },
{ now: FROZEN_NOW },
)
const wips = out.filter((s) => s.category === 'finish-wip')
expect(wips).toHaveLength(1)
expect(wips[0]!.id).toBe('finish-wip')
})
})
describe('recencyBoost', () => {
const { recencyBoost } = _SOLO_SUGGESTIONS_INTERNALS
it('returns +15 within 24h', () => {
expect(recencyBoost(new Date(FROZEN_NOW - 60 * 60 * 1000).toISOString(), FROZEN_NOW)).toBe(15)
})
it('returns +5 within a week', () => {
expect(recencyBoost(new Date(FROZEN_NOW - 3 * 24 * 60 * 60 * 1000).toISOString(), FROZEN_NOW)).toBe(5)
})
it('returns 0 beyond a week, missing, or future', () => {
expect(recencyBoost(new Date(FROZEN_NOW - 30 * 24 * 60 * 60 * 1000).toISOString(), FROZEN_NOW)).toBe(0)
expect(recencyBoost(undefined, FROZEN_NOW)).toBe(0)
expect(recencyBoost(new Date(FROZEN_NOW + 60_000).toISOString(), FROZEN_NOW)).toBe(0)
expect(recencyBoost('not a date', FROZEN_NOW)).toBe(0)
})
})
describe('isCodeSource', () => {
const { isCodeSource } = _SOLO_SUGGESTIONS_INTERNALS
it('classifies typical source files', () => {
expect(isCodeSource('src/foo.ts')).toBe(true)
expect(isCodeSource('packages/x/src/main.go')).toBe(true)
})
it('rejects test files by extension and folder convention', () => {
expect(isCodeSource('src/foo.test.ts')).toBe(false)
expect(isCodeSource('src/foo.spec.tsx')).toBe(false)
expect(isCodeSource('src/foo_test.go')).toBe(false)
expect(isCodeSource('src/__tests__/foo.ts')).toBe(false)
expect(isCodeSource('tests/foo.ts')).toBe(false)
})
it('rejects non-code files', () => {
expect(isCodeSource('README.md')).toBe(false)
expect(isCodeSource('package.json')).toBe(false)
expect(isCodeSource('')).toBe(false)
})
})

View File

@ -0,0 +1,584 @@
/**
* Solo Pipeline mode "what might you want to do next?" suggestion
* engine.
*
* When the user toggles Solo mode and hasn't given a concrete task
* yet, we want to greet them with project-aware guesses instead of an
* empty prompt. This module is the pure-function brain of that
* greeting: it takes the existing zero-token project snapshot
* (`RecentActivityResult` from `projectActivityService`) and an
* optional Tier-1 enrichment bag, and returns a ranked list of
* `SoloSuggestion`s. The desktop renders them; the AI consumes the
* `taskPrompt` as the seed for Stage 1 (or a later stage when
* `entryStage` is set).
*
* Design constraints:
* - No I/O. No process spawning. No LLM calls. The host caller
* decides which signals to gather (Tier 0 is always available
* from `getRecentActivity`; Tier 1 stash count, test-gap
* detection, TODO grep against dirtyFiles, version-vs-notes
* mismatch, in-progress merge state is opt-in and bounded).
* - Deterministic. Same inputs same suggestions in the same
* order, so the greeting is stable across welcome-screen
* re-renders within a session.
* - Locale-neutral. Returns translation KEYS plus interpolation
* params. The desktop translates with the user's active locale
* so the welcome chip and the embedded `taskPrompt` stay in
* sync. (taskPrompt itself is held as a separate i18n key
* tested via a string-marker contract, see test file.)
* - Bounded. Final list capped at 5 entries; each category
* contributes at most one (avoids 3 finish-wip clones).
*
* Coupling boundary: this module imports types from
* `projectActivityService` only. It MUST NOT pull in chat / WS /
* coordinator surfaces, so it can be unit-tested without spinning
* those up and so the eventual Solo-mode wiring layer can sit on top
* without circular dependencies.
*/
import type {
RecentActivityResult,
} from './projectActivityService.js'
/**
* Optional Tier-1 signals the host gathers these alongside
* `RecentActivityResult` when it wants richer suggestions. Each is
* independently optional so a partial gather still produces
* suggestions for the signals that DID resolve.
*
* Cost model (when the host is reading them all):
* stashCount 1 `git stash list --no-decorate`
* missingTestFiles pure in-memory derivation from dirtyFiles
* todoHits grep restricted to dirtyFiles only (NOT full
* repo); naturally bounded by dirty set size
* releaseMismatch 2 file reads (package.json + latest notes)
* gitInProgress 1 fs.access (.git/MERGE_HEAD or REBASE_HEAD)
*/
export type SoloSignalsTier1 = {
/** Number of git stashes. 0 means clean stash list. */
stashCount?: number
/**
* Repo-relative source files in the dirty set whose conventional
* sibling test file is NOT in the dirty set. Picked from
* `dirtyFiles` only never reads the full repo tree.
*/
missingTestFiles?: string[]
/** Repo-relative paths + first-line excerpt for TODOs / FIXMEs in dirtyFiles. */
todoHits?: Array<{ path: string; excerpt: string }>
/**
* Set when `desktop/package.json` version doesn't match the
* latest `release-notes/vX.Y.Z.md`. The mismatch direction tells
* the suggestion which way to nudge (notes-missing vs
* version-not-bumped).
*/
releaseMismatch?: {
desktopVersion: string
latestNotes?: string
kind: 'notes-missing' | 'version-not-bumped' | 'tag-not-pushed'
}
/** True when `.git/MERGE_HEAD` / `REBASE_HEAD` / `CHERRY_PICK_HEAD` exists. */
gitInProgress?: 'merge' | 'rebase' | 'cherry-pick'
}
export type SoloSuggestionCategory =
| 'finish-wip'
| 'ship'
| 'test-gap'
| 'cleanup'
| 'release'
| 'resolve-conflict'
| 'generic'
export type SoloSuggestion = {
/** Stable id (rule + dedup key). Used for analytics and
* category dedup; never user-visible. */
id: string
category: SoloSuggestionCategory
/** Material symbol icon name. */
icon: string
/** i18n key + interpolation params for the short title. */
title: { key: string; params?: Record<string, string | number> }
/**
* Optional one-line "why this is suggested". i18n key + params,
* same shape as `title`.
*/
detail?: { key: string; params?: Record<string, string | number> }
/**
* The task description fed into Solo Stage 1 (or a later stage
* when `entryStage` is set). Held as i18n key+params so the
* locale stays consistent with the surrounding UI. Plugin /
* skill prompts treat this as the user's literal task.
*/
taskPrompt: { key: string; params?: Record<string, string | number> }
/**
* Pipeline stage to enter at. Default 'plan' (start at the top).
* 'review' for "the work is already done, let's look it over".
* 'land' for "everything is ready, just ship it".
*/
entryStage?: 'plan' | 'review' | 'land'
/** Computed score, exposed for tests + analytics. Higher = earlier. */
score: number
}
/** Cap on the public-API suggestion list length. */
const MAX_SUGGESTIONS = 5
/**
* Per-category base scores. Test gaps win over ship/finish-wip
* because they're easiest to ignore and most aligned with this
* repo's quality contract; conflict resolution dominates everything
* because a mid-flight merge/rebase blocks every other useful
* action; generic is the universal fallback so it sits at the
* bottom.
*
* The numbers leave deliberate headroom for boosters (recency 0-15,
* sample-bonus 0-10, file-specific 0-10): the resolve-conflict
* gap is wide enough that ANY combination of boosters on a lower
* rule can't displace it; test-gap's gap over ship is wide enough
* that "I have a test gap AND already-shipped commits" still
* surfaces the test gap first.
*/
const BASE_SCORE: Record<SoloSuggestionCategory, number> = {
'resolve-conflict': 100, // hard override — git mid-state blocks everything
'test-gap': 55,
ship: 35,
'finish-wip': 30,
release: 28,
cleanup: 20,
generic: 0,
}
/** Recency boost based on the last session's modification time. */
function recencyBoost(modifiedAtIso: string | undefined, now: number): number {
if (!modifiedAtIso) return 0
const ts = Date.parse(modifiedAtIso)
if (!Number.isFinite(ts)) return 0
const ageMs = now - ts
if (ageMs < 0) return 0
const ageHours = ageMs / 3_600_000
if (ageHours <= 24) return 15
if (ageHours <= 24 * 7) return 5
return 0
}
/**
* Detect whether a given dirty path looks like "someone else's work"
* specifically, untracked files NOT mentioned in the previous
* session's edited-files sample. Used to mildly downscore the
* finish-wip suggestion when most dirty files are foreign to the
* user's last session (e.g. another agent's parked work in the
* worktree). Returns a boolean per path; caller uses the count.
*/
function isLikelyForeignDirtyFile(
path: string,
lastSessionFiles: ReadonlySet<string>,
): boolean {
if (lastSessionFiles.size === 0) return false
// Match exact + suffix: the session's filesEditedSample stores
// whatever path the tool used, which may be absolute or repo-rel.
// Try both directions so we don't false-positive on path style.
if (lastSessionFiles.has(path)) return false
for (const p of lastSessionFiles) {
if (p === path) return false
if (p.endsWith(path) || path.endsWith(p)) return false
}
return true
}
/**
* Conservative test-file naming heuristic. We classify a path as
* "source" only if it's a code file AND not itself a test. The
* caller (Tier-1 enricher) handles the actual sibling-existence
* check; the engine just trusts what the host hands in.
*/
function isCodeSource(p: string): boolean {
if (!p) return false
if (/\.(test|spec)\.[a-z]+$/i.test(p)) return false
if (/_test\.(py|go|rs)$/i.test(p)) return false
if (/(^|[/\\])(tests?|__tests?__)[/\\]/i.test(p)) return false
return /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|swift|rb|php|cs|cpp|c|h|hpp|sh|ps1)$/i.test(
p,
)
}
/** Truncate a string to N chars with single-char ellipsis. */
function truncate(s: string, max: number): string {
if (s.length <= max) return s
return s.slice(0, Math.max(0, max - 1)).trimEnd() + '…'
}
type RuleContext = {
activity: RecentActivityResult
tier1: SoloSignalsTier1
now: number
lastSessionFiles: ReadonlySet<string>
}
type RuleEmitter = (
ctx: RuleContext,
) => SoloSuggestion[]
/**
* R-CONFLICT Highest priority. A merge / rebase / cherry-pick is
* mid-flight; nothing else productive happens until that's
* resolved. Routes straight to Stage 1 with explicit context so the
* planner specialist asks the right questions.
*/
const ruleResolveConflict: RuleEmitter = ({ tier1 }) => {
if (!tier1.gitInProgress) return []
return [
{
id: `resolve-${tier1.gitInProgress}`,
category: 'resolve-conflict',
icon: 'merge_type',
title: {
key: `solo.suggest.resolveConflict.${tier1.gitInProgress}.title`,
},
detail: {
key: `solo.suggest.resolveConflict.${tier1.gitInProgress}.detail`,
},
taskPrompt: {
key: `solo.suggest.resolveConflict.${tier1.gitInProgress}.taskPrompt`,
},
entryStage: 'plan',
score: BASE_SCORE['resolve-conflict'],
},
]
}
/**
* R1 finish-wip. The repo has uncommitted dirty files; suggest
* walking them through the pipeline so the user closes the loop.
* Downscored when most dirty files look foreign (likely another
* agent's parked work in the same worktree).
*/
const ruleFinishWip: RuleEmitter = ({ activity, lastSessionFiles, now }) => {
const dirtyCount = activity.git?.dirtyCount ?? 0
if (dirtyCount === 0) return []
const dirtyFiles = activity.git?.dirtyFiles ?? []
const foreignCount = dirtyFiles.filter((p) =>
isLikelyForeignDirtyFile(p, lastSessionFiles),
).length
const foreignDominates =
dirtyFiles.length > 0 && foreignCount / dirtyFiles.length > 0.6
const sample = dirtyFiles.slice(0, 3).join(', ')
const score =
BASE_SCORE['finish-wip'] +
recencyBoost(activity.lastSession?.modifiedAt, now) +
(sample ? 10 : 0) +
(foreignDominates ? -15 : 0)
return [
{
id: 'finish-wip',
category: 'finish-wip',
icon: 'edit_note',
title: {
key: 'solo.suggest.finishWip.title',
params: { count: dirtyCount },
},
detail: {
key: foreignDominates
? 'solo.suggest.finishWip.detailForeign'
: 'solo.suggest.finishWip.detail',
params: { sample: truncate(sample, 80) || '—' },
},
taskPrompt: {
key: 'solo.suggest.finishWip.taskPrompt',
params: { count: dirtyCount, files: truncate(sample, 200) || '—' },
},
entryStage: 'plan',
score,
},
]
}
/**
* R2 ship. Local commits ahead of upstream on a non-default
* branch. The work is already done; the pipeline enters at REVIEW
* so the user gets a code-review pass + approval gate + landing
* (PR / push), without re-running plan/implement on already-shipped
* code.
*/
const ruleShipAhead: RuleEmitter = ({ activity, now }) => {
const ahead = activity.git?.aheadCount ?? 0
const branch = activity.git?.branch
const defaultBranch = activity.git?.defaultBranch
if (ahead <= 0 || !branch) return []
const onDefault =
!!defaultBranch && (branch === defaultBranch || branch === 'main' || branch === 'master')
if (onDefault) return []
const score =
BASE_SCORE.ship + recencyBoost(activity.lastSession?.modifiedAt, now) + 10
return [
{
id: 'ship-ahead',
category: 'ship',
icon: 'rocket_launch',
title: {
key: 'solo.suggest.shipAhead.title',
params: { branch, count: ahead },
},
detail: {
key: 'solo.suggest.shipAhead.detail',
params: { count: ahead },
},
taskPrompt: {
key: 'solo.suggest.shipAhead.taskPrompt',
params: { branch, count: ahead },
},
entryStage: 'review',
score,
},
]
}
/**
* R3 test-gap. A source file in the dirty set has no co-located
* test in the dirty set. Highest base-score because this is the
* easiest quality lapse for an agent to ship, and it's exactly
* what AGENTS.md's same-area-test contract is supposed to catch.
*/
const ruleTestGap: RuleEmitter = ({ tier1 }) => {
const gaps = tier1.missingTestFiles ?? []
if (gaps.length === 0) return []
// Pick the first gap that genuinely looks like source. Defensive:
// the host SHOULD already have filtered, but we trust-but-verify.
const sourceGap = gaps.find(isCodeSource)
if (!sourceGap) return []
return [
{
id: `test-gap:${sourceGap}`,
category: 'test-gap',
icon: 'verified',
title: {
key: 'solo.suggest.testGap.title',
params: { file: truncate(sourceGap, 60) },
},
detail: {
key: 'solo.suggest.testGap.detail',
params: { count: gaps.length },
},
taskPrompt: {
key: 'solo.suggest.testGap.taskPrompt',
params: { file: sourceGap, count: gaps.length },
},
entryStage: 'plan',
score: BASE_SCORE['test-gap'] + 10, // file-specific
},
]
}
/** R5 — TODO/FIXME found in dirty files. Cleanup-class. */
const ruleTodoMarker: RuleEmitter = ({ tier1 }) => {
const hits = tier1.todoHits ?? []
if (hits.length === 0) return []
const first = hits[0]!
return [
{
id: `todo:${first.path}`,
category: 'cleanup',
icon: 'task_alt',
title: {
key: 'solo.suggest.todoMarker.title',
params: { file: truncate(first.path, 50) },
},
detail: {
key: 'solo.suggest.todoMarker.detail',
params: { excerpt: truncate(first.excerpt, 80), count: hits.length },
},
taskPrompt: {
key: 'solo.suggest.todoMarker.taskPrompt',
params: {
file: first.path,
excerpt: truncate(first.excerpt, 120),
count: hits.length,
},
},
entryStage: 'plan',
score: BASE_SCORE.cleanup + 5, // mild boost for actionability
},
]
}
/**
* R6 release. The desktop version doesn't line up with the
* release-notes file. Routes to LAND directly: the work is done,
* the release script just needs the missing notes file or the
* version bump.
*/
const ruleReleaseMismatch: RuleEmitter = ({ tier1 }) => {
const mismatch = tier1.releaseMismatch
if (!mismatch) return []
return [
{
id: `release-${mismatch.kind}`,
category: 'release',
icon: 'sell',
title: {
key: `solo.suggest.releaseMismatch.${mismatch.kind}.title`,
params: {
desktopVersion: mismatch.desktopVersion,
latestNotes: mismatch.latestNotes ?? '—',
},
},
detail: {
key: `solo.suggest.releaseMismatch.${mismatch.kind}.detail`,
params: {
desktopVersion: mismatch.desktopVersion,
latestNotes: mismatch.latestNotes ?? '—',
},
},
taskPrompt: {
key: `solo.suggest.releaseMismatch.${mismatch.kind}.taskPrompt`,
params: {
desktopVersion: mismatch.desktopVersion,
latestNotes: mismatch.latestNotes ?? '—',
},
},
entryStage: 'land',
score: BASE_SCORE.release,
},
]
}
/** R7 — recover stashed work. Mild score; user often forgot. */
const ruleStashRecover: RuleEmitter = ({ tier1 }) => {
const stashCount = tier1.stashCount ?? 0
if (stashCount === 0) return []
return [
{
id: 'stash-recover',
category: 'finish-wip',
icon: 'inventory_2',
title: {
key: 'solo.suggest.stashRecover.title',
params: { count: stashCount },
},
detail: { key: 'solo.suggest.stashRecover.detail' },
taskPrompt: {
key: 'solo.suggest.stashRecover.taskPrompt',
params: { count: stashCount },
},
entryStage: 'plan',
score: BASE_SCORE['finish-wip'] - 12, // almost always lower than fresh dirty
},
]
}
/** R8 — sync upstream. Lowest priority; only when truly nothing else. */
const ruleSyncUpstream: RuleEmitter = ({ activity }) => {
const behind = activity.git?.behindCount ?? 0
if (behind <= 0) return []
return [
{
id: 'sync-upstream',
category: 'cleanup',
icon: 'sync',
title: { key: 'solo.suggest.syncUpstream.title', params: { count: behind } },
detail: { key: 'solo.suggest.syncUpstream.detail' },
taskPrompt: {
key: 'solo.suggest.syncUpstream.taskPrompt',
params: { count: behind },
},
score: BASE_SCORE.cleanup - 12,
},
]
}
/**
* R9 generic fallback. Always emitted. Filtered out at the end if
* the user has at least one concrete suggestion, OR retained as the
* sole entry when nothing fired (the "you're on a clean repo, what
* do you want to build?" case).
*/
const ruleGeneric: RuleEmitter = () => [
{
id: 'generic',
category: 'generic',
icon: 'auto_awesome',
title: { key: 'solo.suggest.generic.title' },
detail: { key: 'solo.suggest.generic.detail' },
taskPrompt: { key: 'solo.suggest.generic.taskPrompt' },
entryStage: 'plan',
score: BASE_SCORE.generic,
},
]
const RULES: ReadonlyArray<RuleEmitter> = [
ruleResolveConflict,
ruleFinishWip,
ruleShipAhead,
ruleTestGap,
ruleTodoMarker,
ruleReleaseMismatch,
ruleStashRecover,
ruleSyncUpstream,
ruleGeneric,
]
/**
* Build the ranked Solo-mode suggestion list.
*
* Determinism contract: with identical `activity`, `tier1`, and
* `now`, this function returns identical output (same ids, same
* order, same scores). Tests rely on this for stable assertions.
*
* Scoring + post-processing:
* 1. Each rule emits 0..1 candidates.
* 2. Within a category, only the highest-scoring candidate
* survives avoids the "3 finish-wip clones" failure.
* 3. Candidates sorted by score DESC, then by id ASC for
* tiebreak determinism.
* 4. List capped at MAX_SUGGESTIONS.
* 5. The generic fallback is dropped iff at least one specific
* suggestion survived; otherwise it stays as the sole entry.
*/
export function buildSoloSuggestions(
activity: RecentActivityResult,
tier1?: SoloSignalsTier1,
options?: { now?: number },
): SoloSuggestion[] {
const now = options?.now ?? Date.now()
const ctx: RuleContext = {
activity,
tier1: tier1 ?? {},
now,
lastSessionFiles: new Set(activity.lastSession?.filesEditedSample ?? []),
}
const candidates: SoloSuggestion[] = []
for (const rule of RULES) {
for (const s of rule(ctx)) candidates.push(s)
}
// Per-category dedup: keep the highest-scoring entry per category.
const byCategory = new Map<SoloSuggestionCategory, SoloSuggestion>()
for (const s of candidates) {
const prev = byCategory.get(s.category)
if (!prev || s.score > prev.score) byCategory.set(s.category, s)
}
const ranked = [...byCategory.values()].sort((a, b) => {
if (b.score !== a.score) return b.score - a.score
return a.id.localeCompare(b.id)
})
// Drop the generic fallback if any specific suggestion survived,
// otherwise keep it as the only entry. Generic is special-cased
// here so future rules can ignore the "should we filter generic"
// detail and just emit their candidate.
const hasSpecific = ranked.some((s) => s.category !== 'generic')
const filtered = hasSpecific
? ranked.filter((s) => s.category !== 'generic')
: ranked
return filtered.slice(0, MAX_SUGGESTIONS)
}
/** @internal — exported for unit tests so the score table is locked in. */
export const _SOLO_SUGGESTIONS_INTERNALS = {
BASE_SCORE,
MAX_SUGGESTIONS,
recencyBoost,
isCodeSource,
}