← plans · north_os · DESIGN.md12 juin 2026 à 12:10

Proactive Actionable Nudge — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (<input type=checkbox>) tracking.

Context

Goal: Turn the proactive glance into a one-click actionable notification: it proposes a concrete action, the notch expands with the scrolling message + a discreet sound, and clicking runs the exact voice path (spoken ack + dispatch with a fresh screenshot + spoken answer + open) as if the lawyer had said it. Nothing is persisted unless clicked.

Architecture: No new path. The glance agent returns an actionPrompt instead of a deep-link; runProactiveGlance stops creating a thread; the notch tap calls the existing sendTranscriptToClaudeWithScreenshot seeded with actionPrompt. The thread is created by that dispatch, on click.

Tech Stack: TypeScript (agent-runtime, chat-runtime, clicky-gateway), Swift/SwiftUI (Polaris notch). Bun + vitest; swiftc -typecheck for Swift.

Spec: docs/superpowers/specs/2026-06-12-proactive-actionable-nudge-design.md

File Structure

FileChange
packages/agent-runtime/src/tools/finalize-glance.tsschema: +actionPrompt, −deepLink/openInBrowser
packages/agent-runtime/src/agents/proactive-glance/prompt.tsrecalibrated, action-first
packages/chat-runtime/src/glance.tsreturns {surface,message?,actionPrompt?}; no thread creation; fallback
packages/chat-runtime/src/glance.test.tsrewritten for the new shape
apps/clicky-gateway/src/routes/router.tsresponse body new shape (decision log already present)
apps/polaris/leanring-buddy/Notch/NotchViewModel.swiftNudgePayload{message,actionPrompt}
apps/polaris/leanring-buddy/Notch/NotchActivityCoordinator.swiftauto-hide wired into showActivity(duration:)
apps/polaris/leanring-buddy/Notch/NotchView.swiftexpanding scrolling message; tap → voice turn
apps/polaris/leanring-buddy/ProactiveContextMonitor.swiftProactiveGlanceClientResult{message,actionPrompt}
apps/polaris/leanring-buddy/NorthDispatchAPI.swiftGlanceResponse decoder new shape
apps/polaris/leanring-buddy/CompanionManager.swiftplayNudgeSoundIfEnabled(); nudge-tap runs the voice turn; play sound on surface

Task 1: finalize_glance schema — actionPrompt in, deepLink out

Files: packages/agent-runtime/src/tools/finalize-glance.ts + its test finalize-glance.test.ts.

Step 1: Update the test — open finalize-glance.test.ts, replace any deepLink/openInBrowser assertions with actionPrompt:

it("parses surface:true with message + actionPrompt", () => {
  const msgs = [new AIMessage({ content: "", tool_calls: [{ type: "tool_call", name: "finalize_glance", id: "g",
    args: { surface: true, message: "Reply to Riverside?", actionPrompt: "Draft a reply to the Riverside email" } }] })]
  expect(harvestFinalizeGlance(msgs)).toEqual({
    surface: true, message: "Reply to Riverside?", actionPrompt: "Draft a reply to the Riverside email",
  })
})
it("parses surface:false", () => {
  const msgs = [new AIMessage({ content: "", tool_calls: [{ type: "tool_call", name: "finalize_glance", id: "g",
    args: { surface: false } }] })]
  expect(harvestFinalizeGlance(msgs)).toEqual({ surface: false })
})

Step 2: Run → FAILcd packages/agent-runtime && bun test src/tools/finalize-glance.test.ts. Expected: the schema rejects/strips actionPrompt.

Step 3: Update the schema in finalize-glance.ts:

export const finalizeGlanceArgsSchema = z.object({
  surface: z.boolean(),
  // The line SHOWN in the notch (required when surface=true).
  message: z.string().min(1).optional(),
  // The imperative task RE-DISPATCHED when the lawyer taps the nudge — phrased
  // as a first-person instruction they would speak ("Draft a reply to …").
  actionPrompt: z.string().min(1).optional(),
})

Update the tool description: drop the openInBrowser sentence; add: "actionPrompt: the concrete task to run if the lawyer accepts — an imperative sentence they could have spoken (required when surface=true)."

Step 4: Run → PASS.

Step 5: Commit

git add packages/agent-runtime/src/tools/finalize-glance.ts packages/agent-runtime/src/tools/finalize-glance.test.ts
git commit -m "feat(glance): finalize_glance returns actionPrompt (drop deepLink/openInBrowser)"

Task 2: Recalibrate the proactive prompt

Files: packages/agent-runtime/src/agents/proactive-glance/prompt.ts (no test — it is a prompt string; the schema test covers structure).

Step 1: Rewrite the prompt

export const PROACTIVE_GLANCE_PROMPT = `You are a sharp senior legal assistant glancing — UNINVITED — at one screenshot of a lawyer's screen the moment they switched apps.

You were NOT asked anything. Decide whether there is ONE concrete ACTION you could take that the lawyer would plausibly accept with a single tap.

Lean toward proposing an action — but it must be a real, executable next step, named specifically from what is on screen (the matter, the sender, the document). Examples of screen → action:
- an email that clearly awaits a reply → offer to DRAFT the reply
- a title commitment / title report being reviewed → offer to DRAFT the exceptions disposition
- a contract being redlined → offer to DRAFT the next markup or a specific clause
- a matter / file open on screen → offer to pull the key dates, parties, or next actions

Call finalize_glance EXACTLY ONCE:
- surface: true ONLY when there is a concrete, useful action. message: ONE short English sentence the lawyer reads in a glance, naming what you saw and offering the action ("Mail from Riverside looks like it needs a reply — want me to draft it?"). actionPrompt: the imperative task to run if they accept, phrased as something they could have said aloud ("Draft a reply to the Riverside email about the title exceptions").
- surface: false for anything with no actionable next step — settings, a blank document, personal browsing, dashboards, nothing legal. This is a common, correct answer; when in doubt and there is no specific action worth a tap, stay silent.

Always answer in English. Respond ONLY by calling finalize_glance.`

Step 2: Typecheckcd packages/agent-runtime && bun test src/agents/proactive-glance/ (the graph test still passes with the fake model; no prose assertion).

Step 3: Commit

git add packages/agent-runtime/src/agents/proactive-glance/prompt.ts
git commit -m "feat(glance): action-first proactive prompt with worked examples"

Task 3: runProactiveGlance — return actionPrompt, create no thread

Files: packages/chat-runtime/src/glance.ts + rewrite glance.test.ts.

Step 1: Rewrite the tests for the new shape (no deep-link, no thread, fallback):

it("returns surface + message + actionPrompt; creates NO thread", async () => {
  const createThread = vi.fn(async () => "thr_x")
  const result = await runProactiveGlance({
    organizationId: "org_1", userId: "user_1", screenshot: "B64", foregroundApp: "Mail", webBase: "https://web",
    getAgentImpl: (async () => fakeGlanceGraph({
      surface: true, message: "Reply to Riverside?", actionPrompt: "Draft a reply to the Riverside email",
    })) as never,
    createThreadImpl: createThread as never,
  })
  expect(result).toEqual({ surface: true, message: "Reply to Riverside?", actionPrompt: "Draft a reply to the Riverside email" })
  expect(createThread).not.toHaveBeenCalled()
})
it("surface:false → no thread, no message", async () => {
  const result = await runProactiveGlance({
    organizationId: "org_1", userId: "user_1", screenshot: "B64", foregroundApp: "Finder", webBase: "https://web",
    getAgentImpl: (async () => fakeGlanceGraph({ surface: false })) as never,
  })
  expect(result).toEqual({ surface: false })
})
it("falls back actionPrompt←message when the model omitted actionPrompt", async () => {
  const result = await runProactiveGlance({
    organizationId: "org_1", userId: "user_1", screenshot: "B64", foregroundApp: "Mail", webBase: "https://web",
    getAgentImpl: (async () => fakeGlanceGraph({ surface: true, message: "Deadline in 2 days." })) as never,
  })
  expect(result).toEqual({ surface: true, message: "Deadline in 2 days.", actionPrompt: "Deadline in 2 days." })
})

Remove the ./threads mock and the createThreadImpl from the input type usage if no test needs it (keep the mock only if module load requires it — it does NOT once glance.ts stops importing createThread; remove the import).

Step 2: Run → FAILcd packages/chat-runtime && bun test src/glance.test.ts.

Step 3: Rewrite glance.ts

export interface RunProactiveGlanceInput {
  organizationId: string
  userId: string
  screenshot: string
  foregroundApp: string
  webBase: string
  getAgentImpl?: typeof getAgent
}

export interface ProactiveGlanceResult {
  surface: boolean
  message?: string
  actionPrompt?: string
}

export async function runProactiveGlance(
  input: RunProactiveGlanceInput,
): Promise<ProactiveGlanceResult> {
  const getAgentFn = input.getAgentImpl ?? getAgent
  const graph = await getAgentFn("proactive-glance")
  const humanMessage = buildHumanMessageFromTrigger({
    request: `The lawyer just switched into "${input.foregroundApp}". Here is their screen — decide whether there is one concrete action worth proposing.`,
    screenshot: input.screenshot,
  })
  const final = await graph.invoke({ messages: [humanMessage] })
  const glance = harvestFinalizeGlance(final.messages)
  if (!glance || !glance.surface || !glance.message) return { surface: false }
  // A surfaced nudge always carries something to dispatch on tap.
  return {
    surface: true,
    message: glance.message,
    actionPrompt: glance.actionPrompt ?? glance.message,
  }
}

Delete the createThread import, the createThreadImpl seam, buildTasksDeepLink, and the webBase usage in the deep-link (keep webBase in the input type for now — it is harmless and the gateway still passes it; or drop it. Drop it: remove webBase from the input type and the gateway call in Task 4).

Step 4: Run → PASS.

Step 5: Commit

git add packages/chat-runtime/src/glance.ts packages/chat-runtime/src/glance.test.ts
git commit -m "feat(glance): runProactiveGlance returns actionPrompt, persists nothing"

Task 4: Gateway response shape

Files: apps/clicky-gateway/src/routes/router.ts.

Step 1: The route returns whatever runProactiveGlance returns (it already c.json(result)). Remove webBase from the runProactiveGlance({...}) call (it no longer takes it). The decision log stays but now reads result.surface + result.message (no deepLink):

console.log(
  `[clicky-gateway] proactive-glance decision: surface=${result.surface}` +
    (result.surface && result.message ? ` message="${result.message.slice(0, 80)}"` : "")
)

Step 2: cd apps/clicky-gateway && bun test (the route test, if any, still passes). bun run typecheck from root.

Step 3: Commit

git add apps/clicky-gateway/src/routes/router.ts
git commit -m "feat(gateway): proactive-glance returns the actionPrompt shape"

Task 5: Polaris — payload, decoder, monitor result

Files: NorthDispatchAPI.swift, ProactiveContextMonitor.swift, Notch/NotchViewModel.swift.

Step 1: GlanceResponse decoder (NorthDispatchAPI.swift):

private struct GlanceResponse: Decodable {
    let surface: Bool
    let message: String?
    let actionPrompt: String?
}
// ... in proactiveGlance(...) return:
return ProactiveGlanceClientResult(
    surface: decoded.surface,
    message: decoded.message,
    actionPrompt: decoded.actionPrompt
)

Step 2: ProactiveGlanceClientResult (ProactiveContextMonitor.swift):

struct ProactiveGlanceClientResult {
    let surface: Bool
    let message: String?
    let actionPrompt: String?
}

In onSettled, the surface guard becomes: guard let result, result.surface, let message = result.message, let actionPrompt = result.actionPrompt else { return } then surface(message, actionPrompt). Change the surface closure type to (_ message: String, _ actionPrompt: String) -> Void.

Step 3: NudgePayload (Notch/NotchViewModel.swift):

struct NudgePayload: Equatable {
    let message: String
    let actionPrompt: String
}

Step 4: swiftc -typecheck (see Task 8 for the exact command). Expected: errors ONLY where call sites still pass deepLink — fixed in Tasks 6–7.

Step 5: Commit (after Tasks 6–7 typecheck clean, or commit now and fix forward — prefer committing the whole Swift change together at Task 7).

Task 6: Notch — expanding scrolling message + auto-hide

Files: Notch/NotchActivityCoordinator.swift, Notch/NotchView.swift.

Step 1: Wire auto-hide into showActivity (NotchActivityCoordinator.swift): call scheduleActivityHide() at the end of showActivity so a non-zero duration actually hides:

func showActivity(type: NotchActivityType, duration: TimeInterval = 0) {
    activityDuration = duration
    withAnimation(.smooth) {
        expandingActivity = ExpandingActivity(show: true, type: type)
    }
    scheduleActivityHide()   // honor `duration` (was never called before)
}

The CompanionManager surface closure calls showActivity(type: .nudge, duration: 14) (Task 7).

Step 2: Scrolling message subview — in NotchView.swift, the nudge content renders viewModel.pendingNudge?.message in a horizontally-scrolling text when it overflows. Minimal marquee:

// A one-line message that auto-scrolls horizontally if it overflows the notch
// width; short messages stay static. Uses DS tokens for color/typography.
struct ScrollingNudgeText: View {
    let text: String
    @State private var offset: CGFloat = 0
    var body: some View {
        GeometryReader { geo in
            Text(text)
                .font(DS.Fonts.caption)
                .foregroundColor(DS.Colors.primaryText)
                .fixedSize(horizontal: true, vertical: false)
                .offset(x: offset)
                .onAppear {
                    // Scroll only if the text is wider than the box.
                    withAnimation(.linear(duration: 8).repeatForever(autoreverses: true)) {
                        offset = -max(0, /* textWidth - */ geo.size.width * 0.5)
                    }
                }
        }
        .frame(height: 16)
        .clipped()
    }
}

(Match the existing nudge styling in NotchView.swift; use the real DS token names already used in that file — replace DS.Fonts.caption/DS.Colors.primaryText with whatever the file already uses for nudge text.)

Step 3: swiftc -typecheck (Task 8 command).

Task 7: Notch tap = voice turn + sound + CompanionManager wiring

Files: Notch/NotchView.swift, CompanionManager.swift, Notch/NotchViewBridges.swift (the seam).

Step 1: Replace the deep-link tap (NotchView.swift ~line 101):

.onTapGesture {
    if viewModel.status != .opened {
        if hasNudge, let nudge = viewModel.pendingNudge {
            // A nudge tap runs the proposed action as a full voice turn.
            viewModel.clearNudge()
            coordinator.hideActivity()
            NotchViewBridges.shared.runProactiveAction?(nudge.actionPrompt)
        } else {
            viewModel.notchOpen(reason: .click)
        }
    }
}

Step 2: Add the seam (NotchViewBridges.swift): a closure the notch calls, set by CompanionManager.

/// Called when the lawyer taps a proactive nudge — runs `actionPrompt` as a
/// full voice turn (spoken ack + dispatch + spoken answer + open).
var runProactiveAction: ((String) -> Void)?

Step 3: Wire CompanionManager — in startProactiveMonitor()'s setup (or app start), set the seam to run the existing voice entry with the prompt:

NotchViewBridges.shared.runProactiveAction = { [weak self] actionPrompt in
    guard let self else { return }
    print("🛰️ proactive: nudge tapped → running action as a voice turn")
    ClickyAnalytics.trackUserMessageSent(transcript: actionPrompt)
    self.lastTranscript = actionPrompt
    // Same entry the push-to-talk shortcut calls on a finalized transcript —
    // it recaptures a fresh screenshot, speaks the ack, dispatches, and opens.
    self.sendTranscriptToClaudeWithScreenshot(transcript: actionPrompt)
}

(Confirm sendTranscriptToClaudeWithScreenshot recaptures the screenshot internally — it does, it is the same routine the shortcut's submitDraftText calls. If it takes a screenshot param, pass a fresh captureCursorScreenBase64().)

Step 4: Discreet sound on surface — add to CompanionManager.swift:

/// A soft, distinct "coucou" when a proactive nudge appears — lighter than the
/// voice-turn "Tink", same preference + not-frontmost guards.
private func playNudgeSoundIfEnabled() {
    guard UserDefaults.standard.object(forKey: "notchSoundEnabled") as? Bool ?? true else { return }
    guard !NSApplication.shared.isActive else { return }
    NSSound(named: "Pop")?.play()
}

Call it in the surface closure (the one that sets pendingNudge + showActivity(.nudge)): set the new NudgePayload(message:actionPrompt:), showActivity(type: .nudge, duration: 14), triggerBounce(), and playNudgeSoundIfEnabled().

Step 5: swiftc -typecheck → clean (Task 8 command; 0 errors outside the pre-existing PostHog/Mixpanel noise).

Step 6: Commit the whole Swift change

git add apps/polaris/leanring-buddy/Notch/NotchViewModel.swift \
  apps/polaris/leanring-buddy/Notch/NotchActivityCoordinator.swift \
  apps/polaris/leanring-buddy/Notch/NotchView.swift \
  apps/polaris/leanring-buddy/Notch/NotchViewBridges.swift \
  apps/polaris/leanring-buddy/ProactiveContextMonitor.swift \
  apps/polaris/leanring-buddy/NorthDispatchAPI.swift \
  apps/polaris/leanring-buddy/CompanionManager.swift
git commit -m "feat(polaris): actionable nudge — scrolling message, discreet sound, tap runs a voice turn"

Task 8: Full verification

Step 1: TSbun run typecheck && bun run lint from root → 0 errors.

Step 2: Touched-package tests (export surfaces changed):

cd packages/agent-runtime && bunx dotenv -e ../../.env.local -e ../../.env -- vitest run src/tools src/agents/proactive-glance
cd ../chat-runtime && bunx dotenv -e ../../.env.local -e ../../.env -- vitest run
cd ../../apps/clicky-gateway && bunx dotenv -e ../../.env.local -e ../../.env -- vitest run

Step 3: Swift typecheck (TCC-safe, no xcodebuild):

cd apps/polaris/leanring-buddy
SDK=$(xcrun --show-sdk-path)
swiftc -typecheck -sdk "$SDK" -target arm64-apple-macos14.0 -module-name lb -I /tmp/phmod \
  $(find . -name "*.swift" ! -name "leanring_buddyApp.swift") 2>&1 | grep "error:" | grep -ivE "PostHogSDK|Mixpanel"
# expect: no output (the only errors are the pre-existing PostHog/Mixpanel SDK noise)

Step 4: Pushgit push. (Server side is live on cosmic's --watch; Polaris needs a Cmd+R rebuild for the Swift.)

Notes for the executor