diff --git a/frontend/bindings/github.com/alicoding/mill/internal/domain/clipbridge/index.ts b/frontend/bindings/github.com/alicoding/mill/internal/domain/clipbridge/index.ts new file mode 100644 index 00000000..58270c2a --- /dev/null +++ b/frontend/bindings/github.com/alicoding/mill/internal/domain/clipbridge/index.ts @@ -0,0 +1,6 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +export type { + CardDraft +} from "./models.js"; diff --git a/frontend/bindings/github.com/alicoding/mill/internal/domain/clipbridge/models.ts b/frontend/bindings/github.com/alicoding/mill/internal/domain/clipbridge/models.ts new file mode 100644 index 00000000..a885c671 --- /dev/null +++ b/frontend/bindings/github.com/alicoding/mill/internal/domain/clipbridge/models.ts @@ -0,0 +1,13 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * CardDraft is one to-be-created card parsed from a valid create-cards + * reply -- exactly what the review surface previews. + */ +export interface CardDraft { + "title": string; + "kind"?: string; + "note"?: string; + "summary"?: string; +} diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/atlasservice.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/atlasservice.ts index 10d07a2b..01c91479 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/atlasservice.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/atlasservice.ts @@ -71,6 +71,16 @@ export function CardContextBlock(cardID: string, withAttachments: boolean): $Can return $Call.ByID(4150547692, cardID, withAttachments); } +/** + * CardContextEnvelope renders a card as the OUT envelope (goal 0099): + * its data as items, the reply contract inline. The plain-text + * CardContextBlock stays for human destinations; this is the + * machine-readable twin an external AI answers against. + */ +export function CardContextEnvelope(cardID: string): $CancellablePromise { + return $Call.ByID(1724710751, cardID); +} + /** * Cards returns every LIVE card (goal 0093: a tombstoned card is * excluded, and a live child of a tombstoned container carries its @@ -99,6 +109,15 @@ export function ConvertHTMLToMarkdown(html: string): $CancellablePromise return $Call.ByID(989919804, html); } +/** + * CorrectionEnvelope re-emits the reply contract for the + * re-ask-the-source loop: validation problems and declined titles ride + * the instruction line, the schema stays the instruction. + */ +export function CorrectionEnvelope(problems: string[] | null, declinedTitles: string[] | null): $CancellablePromise { + return $Call.ByID(4009289398, problems, declinedTitles); +} + /** * CreateCard makes a new Card of kindID, optionally inside parentID * ("" for root-level). A non-empty parentID must name an existing @@ -425,6 +444,16 @@ export function PickFolder(startDir: string): $CancellablePromise { return $Call.ByID(3623587391, startDir); } +/** + * PreviewClipbridgeReply validates a raw clipboard string against the + * reply contract (schema-first, then per-action requirements) and + * annotates it with collision state. Malformed input renders inline -- + * this returns a Go error only for internal faults. + */ +export function PreviewClipbridgeReply(raw: string): $CancellablePromise<$models.ClipbridgeReplyPreview> { + return $Call.ByID(3851574839, raw); +} + /** * PromoteNote is the note's one-way lifecycle event (the LOCKED * design's "promotion ritual"): it becomes a typed Card in place -- diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/index.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/index.ts index ebef6a44..57507a2d 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/index.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/index.ts @@ -9,6 +9,8 @@ export { export type { AtlasImportSummary, AtlasSessionState, + ClipbridgeCardOffer, + ClipbridgeReplyPreview, FileDropCreateResult, FileDropRoute, FolderImportSummary, diff --git a/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/models.ts b/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/models.ts index 371ac994..ea61939f 100644 --- a/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/models.ts +++ b/frontend/bindings/github.com/alicoding/mill/internal/services/atlassvc/models.ts @@ -4,6 +4,9 @@ // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore: Unused imports import * as atlas$0 from "../../domain/atlas/models.js"; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as clipbridge$0 from "../../domain/clipbridge/models.js"; /** * AtlasImportSummary counts what ImportAtlas did, per family -- the @@ -47,6 +50,34 @@ export interface AtlasSessionState { "activePerspectiveID": string; } +/** + * ClipbridgeCardOffer is one to-be-created card row on the review + * surface: the parsed draft plus its collision flag (the dedupe + * convention -- colliding rows default unchecked). + */ +export interface ClipbridgeCardOffer { + "Draft": clipbridge$0.CardDraft; + "CollidesWithID": string; + "CollidesWithKind": string; +} + +/** + * ClipbridgeReplyPreview is what the Quick Panel renders when the + * clipboard carries a mill reply: the domain preview plus the + * Atlas-side collision annotations and the route workflow to run on + * accept. + */ +export interface ClipbridgeReplyPreview { + "Recognized": boolean; + "Valid": boolean; + "Action": string; + "Errors": string[] | null; + "Cards": ClipbridgeCardOffer[] | null; + "NoteTexts": string[] | null; + "RouteWorkflowID": string; + "RouteLabel": string; +} + /** * FileDropCreateResult is CreateCardFromFileDrop's own response -- * wraps the newly created card with an additive duplicate-detection diff --git a/frontend/e2e/clipboard-bridge.spec.ts b/frontend/e2e/clipboard-bridge.spec.ts new file mode 100644 index 00000000..4ad4f9e7 --- /dev/null +++ b/frontend/e2e/clipboard-bridge.spec.ts @@ -0,0 +1,107 @@ +import { test, expect } from './fixtures/server' +import { withClipboardLock } from './fixtures/clipboardLock' +import { noteCard, openCard } from './fixtures/atlasBoard' +import { deleteViaPageMenu } from './fixtures/atlasPage' + +// The clipboard bridge (goal 0099): Copy for AI emits the JSON +// envelope; the Quick Panel's clipboard door recognizes a reply, +// renders the review surface (collisions default-unchecked), and the +// accept runs the seeded route workflow. Shared worker pool: every +// entity this file creates it deletes; clipboard sections take the +// cross-process lock per testing.md's real-pasteboard discipline. + +async function openPanelWithClipboard(page: import('@playwright/test').Page, payload: string) { + await page.goto('about:blank') + await page.goto('/#/quickpanel') + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) + await page.evaluate((t) => navigator.clipboard.writeText(t), payload) + const search = page.getByRole('combobox', { name: 'Quick Panel search' }) + await expect(search).toBeFocused() + await search.fill('apply from clipboard') + const option = page.getByRole('option', { name: 'Apply from clipboard…' }) + await expect(option).toBeVisible() + await option.click() +} + +test('Copy for AI puts the reply-contract envelope on the clipboard', async ({ page }) => { + await withClipboardLock(async () => { + await page.goto('/') + await page.getByRole('link', { name: 'Atlas' }).click() + await expect(page.getByTestId('atlas-board')).toBeVisible() + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) + + const card = noteCard(page, 'Getting started') + await openCard(page, card) + await page.getByTestId('atlas-overlay-copy-for-ai').click() + + // The copy handler's binding round-trip + clipboard write are + // async relative to the click -- poll until the envelope lands. + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('"mill"') + const raw = await page.evaluate(() => navigator.clipboard.readText()) + const envelope = JSON.parse(raw) + expect(envelope.mill).toBe(1) + expect(envelope.kind).toBe('context') + expect(envelope.schema.type).toBe('object') + expect(envelope.allowedActions).toContain('create-cards') + expect(envelope.items[0].title).toBe('Getting started') + expect(envelope.instructions).toContain('JSON code block') + await page.keyboard.press('Escape') + }) +}) + +test('a valid reply reviews with collisions unchecked, and accepting creates only the checked card', async ({ page }) => { + const freshTitle = 'ZzE2eBridgeCard' + await withClipboardLock(async () => { + const reply = JSON.stringify({ + mill: 1, kind: 'reply', action: 'create-cards', + items: [{ title: 'Getting started' }, { title: freshTitle, note: 'from the reply' }], + }) + await openPanelWithClipboard(page, reply) + + const review = page.getByTestId('quick-panel-reply-review') + await expect(review).toBeVisible() + const checkboxes = review.getByTestId('quick-panel-reply-card-checkbox') + await expect(checkboxes).toHaveCount(2) + await expect(checkboxes.nth(0)).not.toBeChecked() + await expect(checkboxes.nth(1)).toBeChecked() + await expect(review.getByText(/Already exists as/)).toBeVisible() + + const confirm = review.getByTestId('quick-panel-reply-confirm') + await expect(confirm).toContainText('Create 1 card') + await confirm.click() + await expect(review).toHaveCount(0) + }) + + // The accepted card exists; the declined collision stayed singular. + await page.goto('/') + await page.getByRole('link', { name: 'Atlas' }).click() + await expect(page.getByTestId('atlas-board')).toBeVisible() + const created = noteCard(page, freshTitle) + await expect(created).toBeVisible() + await expect(noteCard(page, 'Getting started')).toHaveCount(1) + + // Cleanup (within-file discipline). + await openCard(page, created) + const overlay = page.locator('[data-component="atlas-card-overlay"]') + await deleteViaPageMenu(page, overlay) + await expect(created).toHaveCount(0) +}) + +test('an invalid reply names its failures and Copy corrected context re-emits the contract', async ({ page }) => { + await withClipboardLock(async () => { + const bad = JSON.stringify({ mill: 1, kind: 'reply', action: 'create-cards', items: [{ note: 'no title here' }] }) + await openPanelWithClipboard(page, bad) + + const invalid = page.getByTestId('quick-panel-reply-invalid') + await expect(invalid).toBeVisible() + await expect(invalid).toContainText('title') + + await invalid.getByTestId('quick-panel-reply-copy-correction').click() + await expect(invalid.getByTestId('quick-panel-reply-copy-correction')).toContainText('Copied') + const raw = await page.evaluate(() => navigator.clipboard.readText()) + const envelope = JSON.parse(raw) + expect(envelope.kind).toBe('context') + expect(envelope.instructions).toContain('did not validate') + expect(envelope.schema.properties.action.enum).toContain('create-cards') + }) +}) diff --git a/frontend/e2e/composition.spec.ts b/frontend/e2e/composition.spec.ts index e4c05182..f24e0ea9 100644 --- a/frontend/e2e/composition.spec.ts +++ b/frontend/e2e/composition.spec.ts @@ -92,8 +92,9 @@ test('Composition page lists built-in workflows; node primitives live in a colla // integration, goal 0066, ADR-0035/0038) + apply-backup-snapshot // (goal 0065's data-stewardship backup step) + apply-list-row (the // Lists write path, goal 0070) + apply-file-move (file verbs, goal - // 0087). - await expect(activePanel(page).getByTestId('palette-item')).toHaveCount(40) + // 0087) + apply-atlas-from-reply (the clipboard bridge's accepted- + // reply materializer, goal 0099). + await expect(activePanel(page).getByTestId('palette-item')).toHaveCount(41) }) test('A new workflow starts with a starter node placed, not a blank canvas', async ({ page }) => { diff --git a/frontend/e2e/node-palette.spec.ts b/frontend/e2e/node-palette.spec.ts index ad58c58f..152b5cda 100644 --- a/frontend/e2e/node-palette.spec.ts +++ b/frontend/e2e/node-palette.spec.ts @@ -97,7 +97,7 @@ test('palette search matches both the shortened display name and the full underl // RegisterNodeType call sites + the seeded "Check httpbin" declared // step type, goal 0054 slice A). await search.fill('') - await expect(panel.getByTestId('palette-item')).toHaveCount(40) + await expect(panel.getByTestId('palette-item')).toHaveCount(41) }) // Progressive-disclosure "Show advanced steps" toggle (goal 0047): the @@ -108,7 +108,7 @@ test('the palette shows every step by default, "Show advanced steps" checked', a await openPaletteOnNewWorkflow(page) const panel = activePanel(page) await expect(panel.getByTestId('palette-show-advanced')).toBeChecked() - await expect(panel.getByTestId('palette-item')).toHaveCount(40) + await expect(panel.getByTestId('palette-item')).toHaveCount(41) }) test('unchecking "Show advanced steps" hides advanced steps, keeps basic ones, and persists across a reload', async ({ page }) => { @@ -130,6 +130,8 @@ test('unchecking "Show advanced steps" hides advanced steps, keeps basic ones, a 'process-atlas-card-find', 'apply-atlas-card-create', 'apply-atlas-card-update', // goal 0070: fieldBindings is the same hand-authored JSON shape. 'apply-list-row', + // goal 0099: consumes a JSON items array from an attribute. + 'apply-atlas-from-reply', ] for (const id of advancedIDs) { await expect(panel.locator(`[data-node-type-id="${id}"]`)).toHaveCount(0) diff --git a/frontend/src/app/QuickPanel.tsx b/frontend/src/app/QuickPanel.tsx index 8bf30209..b9441c09 100644 --- a/frontend/src/app/QuickPanel.tsx +++ b/frontend/src/app/QuickPanel.tsx @@ -4,8 +4,7 @@ import { Events } from '@wailsio/runtime' import { Text } from '@primer/react' import { FilteredActionList } from '@primer/react/experimental' import { NoteIcon, PlayIcon } from '@primer/octicons-react' -import { AtlasService, CompositionService, ExecutionService, RunKind, SettingsService, TriggerService } from '../shared/bindings' -import type { ClipboardApplyPreview } from '../shared/bindings' +import { AtlasService, ExecutionService, RunKind, SettingsService, TriggerService } from '../shared/bindings' import { generateSamplePayload } from '../shared/configSchema' import { useAppStore, refreshWorkflows, refreshRequests, refreshKeybindings } from '../shared/store' import { @@ -19,6 +18,8 @@ import { buildConfigureAndActionEntries } from './quickPanelActionEntries' import type { PanelEntry } from './quickPanelActionEntries' import { cascadeNotePosition, resolveNoteParentID } from './quickPanelCapture' import { QuickPanelClipboardApply } from './QuickPanelClipboardApply' +import { QuickPanelReplyReview } from './QuickPanelReplyReview' +import { useQuickPanelClipboardDoor } from './useQuickPanelClipboardDoor' import { FacetChipRow } from '../shared/FacetChipRow' import { useQuickPanelFacetSearch } from './quickPanelFacets' import styles from './QuickPanel.module.css' @@ -125,14 +126,6 @@ export function QuickPanel() { // those; duplicating them per-window would double-fire OS // notifications for the same pending item). const [reviewPendingCount, setReviewPendingCount] = useState(0) - // docs/goals/0039: non-null swaps the panel body from the search list - // into QuickPanelClipboardApply's preview-confirm view. json is the - // exact clipboard text the preview was computed from -- re-sent to - // ConfirmClipboardApply on confirm rather than re-read from the - // clipboard a second time (the user's gesture already captured it - // once; a second OS-level read has no reason to differ and would - // just be a second permission prompt). - const [clipboardApply, setClipboardApply] = useState<{ json: string; preview: ClipboardApplyPreview } | null>(null) const inputRef = useRef(null) // Declared before the effects that reference them (react-hooks/ @@ -318,32 +311,9 @@ export function QuickPanel() { }) } - // docs/goals/0039: reads the clipboard on the row's own click/Enter - // (the user gesture the Clipboard API requires) and hands the raw - // text to PreviewClipboardApply -- checked what exists first: the - // clipboard adapter (internal/adapters/clipboard) is wired for - // workflow-EXECUTION-side capture/apply nodes, not exposed as a - // general read-text RPC, and this window is an ordinary Wails webview - // where navigator.clipboard.readText() already works. Never throws - // through to the caller -- every failure path (permission denied, - // empty clipboard, malformed/unrecognized payload) becomes a - // Recognized=false preview so QuickPanelClipboardApply's own error - // view renders it, same as a genuinely bad payload would. - const applyFromClipboard = () => { - navigator.clipboard.readText() - .then((text) => { - if (!text.trim()) { - setClipboardApply({ json: text, preview: { recognized: false, error: t('quickPanel.clipboard.emptyError') } }) - return - } - CompositionService.PreviewClipboardApply(text) - .then((preview) => setClipboardApply({ json: text, preview })) - .catch((err) => setClipboardApply({ json: text, preview: { recognized: false, error: String(err) } })) - }) - .catch((err) => { - setClipboardApply({ json: '', preview: { recognized: false, error: t('quickPanel.clipboard.readError', { error: String(err) }) } }) - }) - } + // The clipboard door (goals 0039 + 0099) lives in its own hook -- + // one row recognizes both a workflow export and a mill reply. + const { clipboardApply, setClipboardApply, replyReview, setReplyReview, applyFromClipboard } = useQuickPanelClipboardDoor(t) // The away-capture door (docs/goals/0090): a typed query with no // intent to search becomes a Note instead, filed into the Scratchpad @@ -452,6 +422,22 @@ export function QuickPanel() { // (ADR-0033) has no room for a second, nested surface, so this is a // full replacement, not an overlay. Cancel/Applied both clear the // state, returning to the ordinary search list. + if (replyReview) { + return ( +
+ setReplyReview(null)} + onApplied={(label) => { + setReplyReview(null) + setStatus(t('quickPanel.status.replyApplied', { label })) + window.setTimeout(() => { void SettingsService.DismissPanel().catch(() => {}) }, 600) + }} + /> +
+ ) + } + if (clipboardApply) { return (
diff --git a/frontend/src/app/QuickPanelReplyReview.tsx b/frontend/src/app/QuickPanelReplyReview.tsx new file mode 100644 index 00000000..b21c3fca --- /dev/null +++ b/frontend/src/app/QuickPanelReplyReview.tsx @@ -0,0 +1,168 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Banner, Button, Checkbox, FormControl, Stack, Text } from '@primer/react' +import { ArrowLeftIcon, CopyIcon } from '@primer/octicons-react' +import { AtlasService, ExecutionService, RunKind } from '../shared/bindings' +import type { ClipbridgeReplyPreview } from '../shared/bindings' +import styles from './QuickPanelClipboardApply.module.css' + +// The clipboard bridge's review surface (goal 0099) -- the fourth +// instance of the preview-dialog language (folder import, list-row +// import, and the MCP approval plane are the siblings): every write +// renders as a checkbox row BEFORE anything executes, colliding rows +// default unchecked with their collision named, and the confirm button +// carries the count it will create. Nothing in a reply auto-runs -- +// create-class actions get this review, and replace/delete-class +// actions do not exist in the contract yet (clipbridge.MayAutoRun is +// the enforcement, not this component). +// +// The correction loop is re-ask-the-source: "Copy corrected context" +// re-emits the envelope (via AtlasService.CorrectionEnvelope) carrying +// what failed or what was declined, for a fresh reply from the same AI. + +interface Props { + preview: ClipbridgeReplyPreview + onCancel: () => void + onApplied: (label: string) => void +} + +export function QuickPanelReplyReview({ preview, onCancel, onApplied }: Props) { + const { t } = useTranslation('app') + const [busy, setBusy] = useState(false) + const [confirmError, setConfirmError] = useState(null) + const [copied, setCopied] = useState(false) + const cards = preview.Cards ?? [] + const notes = preview.NoteTexts ?? [] + const [accepted, setAccepted] = useState>( + () => new Set(cards.map((c, i) => (c.CollidesWithID ? -1 : i)).filter((i) => i >= 0)), + ) + + const copyCorrection = (problems: string[], declined: string[]) => { + AtlasService.CorrectionEnvelope(problems, declined) + .then((envelope) => navigator.clipboard.writeText(envelope)) + .then(() => setCopied(true)) + .catch((err) => setConfirmError(String(err))) + } + + if (!preview.Valid) { + return ( + + + + + + ) + } + + const isNoteRoute = notes.length > 0 + const acceptedCount = isNoteRoute ? notes.length : accepted.size + + const confirm = () => { + // Compute the accepted payload into a local value before anything + // async -- never round-tripped through state after a set call. + const items = isNoteRoute + ? notes.map((text) => ({ text })) + : cards.filter((_, i) => accepted.has(i)).map((c) => ({ + title: c.Draft.title, + ...(c.Draft.kind ? { kind: c.Draft.kind } : {}), + ...(c.Draft.note ? { note: c.Draft.note } : {}), + ...(c.Draft.summary ? { summary: c.Draft.summary } : {}), + })) + if (items.length === 0 || !preview.RouteWorkflowID) return + setBusy(true) + setConfirmError(null) + ExecutionService.RunWorkflow(preview.RouteWorkflowID, RunKind.RunKindTest, { items: JSON.stringify(items) }) + .then((summary) => { + if (summary.status !== 'SUCCESS') { + setBusy(false) + setConfirmError(t('quickPanelReplyReview.runFailed', { status: summary.status })) + return + } + onApplied(preview.RouteLabel ?? '') + }) + .catch((err) => { + setBusy(false) + setConfirmError(String(err)) + }) + } + + const declinedTitles = cards.filter((_, i) => !accepted.has(i)).map((c) => c.Draft.title) + + return ( + + {t('quickPanelReplyReview.title')} + {isNoteRoute ? ( + + {notes.map((text, i) => ( + {text} + ))} + + ) : ( + + {cards.map((offer, i) => ( + + { + setAccepted((prev) => { + const next = new Set(prev) + if (next.has(i)) { next.delete(i) } else { next.add(i) } + return next + }) + }} + data-testid="quick-panel-reply-card-checkbox" + /> + + {offer.Draft.title} + {offer.Draft.kind ? ` · ${offer.Draft.kind}` : ''} + + {offer.CollidesWithID ? ( + + {t('quickPanelReplyReview.collision', { kind: offer.CollidesWithKind || t('quickPanelReplyReview.collisionGenericKind') })} + + ) : offer.Draft.note ? ( + {offer.Draft.note} + ) : null} + + ))} + + )} + {confirmError && } + + + + + {declinedTitles.length > 0 && ( + + )} + + ) +} diff --git a/frontend/src/app/useQuickPanelClipboardDoor.ts b/frontend/src/app/useQuickPanelClipboardDoor.ts new file mode 100644 index 00000000..f512c1ff --- /dev/null +++ b/frontend/src/app/useQuickPanelClipboardDoor.ts @@ -0,0 +1,48 @@ +import { useState } from 'react' +import { AtlasService, CompositionService } from '../shared/bindings' +import type { ClipboardApplyPreview, ClipbridgeReplyPreview } from '../shared/bindings' + +// The Quick Panel's ONE clipboard door (goals 0039 + 0099): the same +// row recognizes both payload families -- a workflow export (apply +// preview) first, then a mill reply envelope (the clipboard bridge's +// review surface). Reads the clipboard on the row's own click/Enter +// (the user gesture the Clipboard API requires). Never throws through +// to the caller: every failure path (permission denied, empty +// clipboard, malformed payload) becomes a Recognized=false apply +// preview so the error view renders it inline. +export function useQuickPanelClipboardDoor(t: (key: string, opts?: Record) => string) { + const [clipboardApply, setClipboardApply] = useState<{ json: string; preview: ClipboardApplyPreview } | null>(null) + const [replyReview, setReplyReview] = useState(null) + + const applyFromClipboard = () => { + navigator.clipboard.readText() + .then((text) => { + if (!text.trim()) { + setClipboardApply({ json: text, preview: { recognized: false, error: t('quickPanel.clipboard.emptyError') } }) + return + } + CompositionService.PreviewClipboardApply(text) + .then((preview) => { + if (preview.recognized) { + setClipboardApply({ json: text, preview }) + return + } + AtlasService.PreviewClipbridgeReply(text) + .then((reply) => { + if (reply.Recognized) { + setReplyReview(reply) + } else { + setClipboardApply({ json: text, preview }) + } + }) + .catch(() => setClipboardApply({ json: text, preview })) + }) + .catch((err) => setClipboardApply({ json: text, preview: { recognized: false, error: String(err) } })) + }) + .catch((err) => { + setClipboardApply({ json: '', preview: { recognized: false, error: t('quickPanel.clipboard.readError', { error: String(err) }) } }) + }) + } + + return { clipboardApply, setClipboardApply, replyReview, setReplyReview, applyFromClipboard } +} diff --git a/frontend/src/atlas/AtlasCardOverlay.tsx b/frontend/src/atlas/AtlasCardOverlay.tsx index c21c12bc..a9e13ade 100644 --- a/frontend/src/atlas/AtlasCardOverlay.tsx +++ b/frontend/src/atlas/AtlasCardOverlay.tsx @@ -86,6 +86,7 @@ export function AtlasCardOverlay({ card, kinds, allCards, links, linkKinds, onCl const [receiptStatus, setReceiptStatus] = useState(null) const [includeAttachments, setIncludeAttachments] = useState(false) const [copied, setCopied] = useState(false) + const [copiedAI, setCopiedAI] = useState(false) const [shareError, setShareError] = useState('') const shareActions = atlasCardShareActions(displayedCard, (message) => setShareError(message)) // D5 (goal 0081 slice A3): a file dropped while this page is open @@ -115,6 +116,12 @@ export function AtlasCardOverlay({ card, kinds, allCards, links, linkKinds, onCl setTimeout(() => setCopied(false), 1500) } + const copyForAI = async () => { + await shareActions.copyForAI() + setCopiedAI(true) + setTimeout(() => setCopiedAI(false), 1500) + } + useEffect(() => { if (!displayedCard.ReceiptRunID) { setReceiptStatus(null) @@ -295,6 +302,8 @@ export function AtlasCardOverlay({ card, kinds, allCards, links, linkKinds, onCl onIncludeAttachmentsChange={setIncludeAttachments} copied={copied} onCopyContext={() => void copyContext()} + copiedAI={copiedAI} + onCopyForAI={() => void copyForAI()} onCopyLink={() => void shareActions.copyCloudLink()} />
diff --git a/frontend/src/atlas/AtlasCardPageMetaRail.tsx b/frontend/src/atlas/AtlasCardPageMetaRail.tsx index cd6d9a34..6166e727 100644 --- a/frontend/src/atlas/AtlasCardPageMetaRail.tsx +++ b/frontend/src/atlas/AtlasCardPageMetaRail.tsx @@ -19,7 +19,7 @@ import styles from './AtlasCardPage.module.css' // matching the canvas card menu -- this row used to duplicate it. export function AtlasCardPageMetaRail({ card, updating, onUpdateNow, receiptStatus, onOpenRun, - includeAttachments, onIncludeAttachmentsChange, copied, onCopyContext, onCopyLink, + includeAttachments, onIncludeAttachmentsChange, copied, onCopyContext, copiedAI, onCopyForAI, onCopyLink, }: { card: Card updating: boolean @@ -30,6 +30,8 @@ export function AtlasCardPageMetaRail({ onIncludeAttachmentsChange: (checked: boolean) => void copied: boolean onCopyContext: () => void + copiedAI: boolean + onCopyForAI: () => void onCopyLink: () => void }) { const { t } = useTranslation('atlas') @@ -89,6 +91,9 @@ export function AtlasCardPageMetaRail({ {copied ? : } {copied ? t('overlay.copied') : t('overlay.copyAsContext')} + + {copiedAI ? : } {copiedAI ? t('overlay.copied') : t('overlay.copyForAI')} + {card.Source && ( {t('overlay.copyCloudLink')} diff --git a/frontend/src/atlas/atlasCardShare.ts b/frontend/src/atlas/atlasCardShare.ts index 3dd0a2f5..09e2ff51 100644 --- a/frontend/src/atlas/atlasCardShare.ts +++ b/frontend/src/atlas/atlasCardShare.ts @@ -20,6 +20,15 @@ export function atlasCardShareActions(card: Card, onError: (message: string) => } } + const copyForAI = async (): Promise => { + try { + const envelope = await AtlasService.CardContextEnvelope(card.ID) + await navigator.clipboard.writeText(envelope) + } catch (err) { + onError(String(err)) + } + } + const copyCloudLink = async (): Promise => { try { await navigator.clipboard.writeText(card.Source) @@ -36,5 +45,5 @@ export function atlasCardShareActions(card: Card, onError: (message: string) => } } - return { copyAsContext, copyCloudLink, revealFile } + return { copyAsContext, copyForAI, copyCloudLink, revealFile } } diff --git a/frontend/src/locales/en/app.json b/frontend/src/locales/en/app.json index 93c6c8a4..66856293 100644 --- a/frontend/src/locales/en/app.json +++ b/frontend/src/locales/en/app.json @@ -61,7 +61,8 @@ "failed": "\"{{label}}\" failed: {{error}}", "started": "Started \"{{label}}\"", "appliedUpdated": "Updated \"{{label}}\"", - "appliedCreated": "Created \"{{label}}\"" + "appliedCreated": "Created \"{{label}}\"", + "replyApplied": "Done — {{label}}" }, "clipboard": { "emptyError": "Clipboard is empty -- copy a workflow export first", @@ -183,5 +184,19 @@ "body": "{{count}} tab{{plural}} {{has}} unsaved changes.", "confirm": "Close tabs", "cancel": "Cancel" + }, + "quickPanelReplyReview": { + "title": "Review this reply before anything is created", + "invalidTitle": "This reply doesn't match what Mill asked for", + "invalidFallback": "The reply could not be validated.", + "copyCorrection": "Copy corrected context", + "correctionCopied": "Copied — paste it back to the AI", + "collision": "Already exists as {{kind}} — unchecked so nothing is overwritten", + "collisionGenericKind": "a card", + "confirmCards": "Create {{count}} cards", + "confirmCards_one": "Create 1 card", + "confirmNote": "Save to Scratchpad", + "errorTitle": "That didn't work", + "runFailed": "The workflow run ended with status {{status}}." } } diff --git a/frontend/src/locales/en/atlas.json b/frontend/src/locales/en/atlas.json index fe46bc2e..01240709 100644 --- a/frontend/src/locales/en/atlas.json +++ b/frontend/src/locales/en/atlas.json @@ -181,7 +181,8 @@ "runStarted": "Started", "actionRunError": "Couldn't start the action. Try again.", "removeAction": "Remove action", - "actionsHint": "Each action receives this card's id, kind, and title." + "actionsHint": "Each action receives this card's id, kind, and title.", + "copyForAI": "Copy for AI" }, "page": { "close": "Close ⨯", @@ -246,4 +247,4 @@ "dissolveBody_other": "Its {{count}} cards move up a level.", "dissolveConfirm": "Dissolve" } -} \ No newline at end of file +} diff --git a/frontend/src/shared/bindings.ts b/frontend/src/shared/bindings.ts index e6127cd2..2ba34296 100644 --- a/frontend/src/shared/bindings.ts +++ b/frontend/src/shared/bindings.ts @@ -6,6 +6,10 @@ // (bindings/.../internal/domain/*, internal/adapters/*) are unaffected // by service moves and stay direct. export { AtlasService } from '../../bindings/github.com/alicoding/mill/internal/services/atlassvc' +export type { + ClipbridgeCardOffer, + ClipbridgeReplyPreview, +} from '../../bindings/github.com/alicoding/mill/internal/services/atlassvc' export type { FolderImportSummary, FolderScanEntry, diff --git a/internal/contract/contract.json b/internal/contract/contract.json index 5d0fec43..9865932f 100644 --- a/internal/contract/contract.json +++ b/internal/contract/contract.json @@ -2551,6 +2551,47 @@ "PaletteGroup": "", "Complexity": "advanced" }, + { + "ID": "apply-atlas-from-reply", + "Kind": "apply", + "Label": "Atlas: create from reply items", + "Description": "Creates Atlas records from an accepted clipboard reply's items: an item with a \"title\" becomes a card of its named kind, an item with \"text\" becomes a Scratchpad note. \"Items\" binds the attribute holding the accepted items as a JSON array.", + "ConfigFields": [ + { + "Key": "itemsAttribute", + "Label": "Items attribute", + "Type": "text", + "Required": false, + "Default": "", + "Description": "Which Attributes field carries the accepted reply items (a JSON array).", + "Options": null, + "Suggestions": null, + "Secret": false, + "RefKind": "", + "Multiline": false, + "SystemManaged": false + }, + { + "Key": "outputAttribute", + "Label": "Output attribute (optional)", + "Type": "text", + "Required": false, + "Default": "", + "Description": "Which Attributes field receives a summary of what was created.", + "Options": null, + "Suggestions": null, + "Secret": false, + "RefKind": "", + "Multiline": false, + "SystemManaged": false + } + ], + "Output": "payload unchanged; a created-summary JSON -\u003e the output attribute, if named", + "Effect": "local", + "Declared": false, + "PaletteGroup": "", + "Complexity": "advanced" + }, { "ID": "apply-backup-snapshot", "Kind": "apply", @@ -4196,6 +4237,47 @@ "PaletteGroup": "", "Complexity": "advanced" }, + { + "ID": "apply-atlas-from-reply", + "Kind": "apply", + "Label": "Atlas: create from reply items", + "Description": "Creates Atlas records from an accepted clipboard reply's items: an item with a \"title\" becomes a card of its named kind, an item with \"text\" becomes a Scratchpad note. \"Items\" binds the attribute holding the accepted items as a JSON array.", + "ConfigFields": [ + { + "Key": "itemsAttribute", + "Label": "Items attribute", + "Type": "text", + "Required": false, + "Default": "", + "Description": "Which Attributes field carries the accepted reply items (a JSON array).", + "Options": null, + "Suggestions": null, + "Secret": false, + "RefKind": "", + "Multiline": false, + "SystemManaged": false + }, + { + "Key": "outputAttribute", + "Label": "Output attribute (optional)", + "Type": "text", + "Required": false, + "Default": "", + "Description": "Which Attributes field receives a summary of what was created.", + "Options": null, + "Suggestions": null, + "Secret": false, + "RefKind": "", + "Multiline": false, + "SystemManaged": false + } + ], + "Output": "payload unchanged; a created-summary JSON -\u003e the output attribute, if named", + "Effect": "local", + "Declared": false, + "PaletteGroup": "", + "Complexity": "advanced" + }, { "ID": "apply-backup-snapshot", "Kind": "apply", diff --git a/internal/domain/atlas/builtin.go b/internal/domain/atlas/builtin.go index 2d4a7e5c..f226873c 100644 --- a/internal/domain/atlas/builtin.go +++ b/internal/domain/atlas/builtin.go @@ -66,6 +66,11 @@ const ( cardDataStoreID = "atlas-card-data-store" cardSyncServiceID = "atlas-card-sync-service" + // BuiltInScratchpadCardID is the seeded Scratchpad inbox area -- + // exported because the clipboard bridge's note route (goal 0099) + // lands notes there from the service layer. + BuiltInScratchpadCardID = cardScratchpadID + linkGettingToContactID = "atlas-link-getting-to-contact" linkContactToDocumentID = "atlas-link-contact-to-document" linkWebToStoreID = "atlas-link-web-to-store" diff --git a/internal/domain/clipbridge/class.go b/internal/domain/clipbridge/class.go new file mode 100644 index 00000000..068b5386 --- /dev/null +++ b/internal/domain/clipbridge/class.go @@ -0,0 +1,59 @@ +package clipbridge + +// ActionClass is the guardrail effect class an envelope action maps +// onto -- the taxonomy carrier is the existing gate's vocabulary, never +// a parallel system. +type ActionClass string + +const ( + ClassRead ActionClass = "read" + ClassCreate ActionClass = "create" + ClassReplace ActionClass = "replace" + ClassDelete ActionClass = "delete" + ClassUnknown ActionClass = "unknown" +) + +// The v1 actions, both create-class. Replace/delete-class actions do +// not exist yet; when one lands, the field-level before/after review +// UI is REQUIRED in the same change (recorded in the goal contract). +const ( + ActionCreateCards = "create-cards" + ActionNoteToScratchpad = "note-to-scratchpad" +) + +// V1Actions lists what the OUT envelope advertises today. +func V1Actions() []string { + return []string{ActionCreateCards, ActionNoteToScratchpad} +} + +// ClassOf maps an action to its effect class. Unknown actions classify +// as ClassUnknown and are refused upstream -- never guessed into a +// weaker class. +func ClassOf(action string) ActionClass { + switch action { + case ActionCreateCards, ActionNoteToScratchpad: + return ClassCreate + default: + return ClassUnknown + } +} + +// MayAutoRun reports whether a class may execute without a human +// review-accept. The hard cap: mutation of existing data is never +// agentic regardless of source -- replace and delete always return +// false here, and no guardrail rule may override this (it is enforced +// in code, not configuration, by design). +func MayAutoRun(c ActionClass) bool { + return c == ClassRead +} + +// RequiresReview reports whether a class must pass the review-accept +// surface before executing. Everything that writes does. +func RequiresReview(c ActionClass) bool { + switch c { + case ClassCreate, ClassReplace, ClassDelete: + return true + default: + return c == ClassUnknown + } +} diff --git a/internal/domain/clipbridge/clipbridge_test.go b/internal/domain/clipbridge/clipbridge_test.go new file mode 100644 index 00000000..2fffde8d --- /dev/null +++ b/internal/domain/clipbridge/clipbridge_test.go @@ -0,0 +1,215 @@ +package clipbridge + +import ( + "encoding/json" + "strings" + "testing" +) + +var testKinds = []string{"Topic", "Contact", "Document", "Component"} + +func validReply(t *testing.T, action string, items string) string { + t.Helper() + return `{"mill":1,"kind":"reply","action":"` + action + `","items":` + items + `}` +} + +func TestBuildContextEnvelope(t *testing.T) { + env, err := BuildContextEnvelope([]ContextCard{{Title: "Web app", Kind: "Component"}}, testKinds, V1Actions()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if env.Mill != EnvelopeVersion || env.Kind != KindContext { + t.Errorf("marker fields wrong: %+v", env) + } + if len(env.Items) != 1 || len(env.AllowedActions) != 2 { + t.Errorf("items/actions wrong: %+v", env) + } + if env.Instructions == "" { + t.Error("the one-line reply instruction is missing") + } + // The whole envelope must round-trip as one JSON object. + raw, err := json.Marshal(env) + if err != nil { + t.Fatalf("marshal envelope: %v", err) + } + var back map[string]any + if err := json.Unmarshal(raw, &back); err != nil { + t.Fatalf("envelope is not one valid JSON object: %v", err) + } + for _, key := range []string{"mill", "kind", "instructions", "schema", "allowedActions", "items"} { + if _, ok := back[key]; !ok { + t.Errorf("envelope lost key %q", key) + } + } +} + +// The portable-subset guard: the reply schema's keyword vocabulary must +// stay inside the locked structured-output intersection. A keyword +// outside the allowlist appearing anywhere in the schema fails this +// test -- widening the vocabulary is a deliberate contract change, not +// a drive-by. +func TestReplySchema_KeywordsStayInsidePortableSubset(t *testing.T) { + allowed := map[string]bool{ + "$schema": true, "$defs": true, "$ref": true, + "type": true, "properties": true, "required": true, + "additionalProperties": true, "enum": true, "items": true, + } + raw, err := ReplySchema(testKinds, V1Actions()) + if err != nil { + t.Fatalf("ReplySchema: %v", err) + } + var doc any + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatalf("schema is not valid JSON: %v", err) + } + var walk func(v any, inProperties bool) + walk = func(v any, inProperties bool) { + switch node := v.(type) { + case map[string]any: + for k, child := range node { + if !inProperties && !allowed[k] { + t.Errorf("schema keyword %q is outside the portable subset", k) + } + // Values under "properties"/"$defs" are NAMES, not + // keywords; their children are schemas again. + walk(child, k == "properties" || k == "$defs") + } + case []any: + for _, child := range node { + walk(child, false) + } + } + } + walk(doc, false) +} + +func TestParseReply_CreateCards(t *testing.T) { + p := ParseReply(validReply(t, ActionCreateCards, `[{"title":"CRM","kind":"Component","note":"vendor system"}]`), testKinds, V1Actions()) + if !p.Recognized || !p.Valid { + t.Fatalf("expected a valid reply, got %+v", p) + } + if p.Class != ClassCreate || len(p.Cards) != 1 || p.Cards[0].Title != "CRM" { + t.Errorf("parse result wrong: %+v", p) + } +} + +func TestParseReply_FencedJSONIsTolerated(t *testing.T) { + fenced := "```json\n" + validReply(t, ActionCreateCards, `[{"title":"CRM"}]`) + "\n```" + p := ParseReply(fenced, testKinds, V1Actions()) + if !p.Valid { + t.Fatalf("fenced reply should validate, got %+v", p) + } +} + +func TestParseReply_RefusalsNameThemselves(t *testing.T) { + t.Run("not an envelope at all", func(t *testing.T) { + p := ParseReply("just some prose", testKinds, V1Actions()) + if p.Recognized { + t.Fatalf("prose must not be recognized: %+v", p) + } + }) + t.Run("wrong version", func(t *testing.T) { + p := ParseReply(`{"mill":2,"kind":"reply","action":"create-cards","items":[]}`, testKinds, V1Actions()) + if !p.Recognized || p.Valid || len(p.Errors) == 0 || !strings.Contains(p.Errors[0], "version") { + t.Fatalf("expected a version refusal, got %+v", p) + } + }) + t.Run("unknown action refused by the schema enum", func(t *testing.T) { + p := ParseReply(validReply(t, "delete-everything", `[{"title":"x"}]`), testKinds, V1Actions()) + if p.Valid { + t.Fatalf("unknown action must not validate: %+v", p) + } + if p.Class != ClassUnknown { + t.Errorf("unknown action must classify unknown, got %q", p.Class) + } + }) + t.Run("card without title", func(t *testing.T) { + p := ParseReply(validReply(t, ActionCreateCards, `[{"note":"no title"}]`), testKinds, V1Actions()) + if p.Valid || len(p.Errors) == 0 || !strings.Contains(p.Errors[0], "title") { + t.Fatalf("expected a named title refusal, got %+v", p) + } + }) + t.Run("stray item field refused by additionalProperties", func(t *testing.T) { + p := ParseReply(validReply(t, ActionCreateCards, `[{"title":"x","payload":"smuggled"}]`), testKinds, V1Actions()) + if p.Valid { + t.Fatalf("stray fields must not validate: %+v", p) + } + }) + t.Run("unknown kind refused by the kind enum", func(t *testing.T) { + p := ParseReply(validReply(t, ActionCreateCards, `[{"title":"x","kind":"Missile"}]`), testKinds, V1Actions()) + if p.Valid { + t.Fatalf("unknown kind must not validate: %+v", p) + } + }) + t.Run("empty items", func(t *testing.T) { + p := ParseReply(validReply(t, ActionCreateCards, `[]`), testKinds, V1Actions()) + if p.Valid || len(p.Errors) == 0 { + t.Fatalf("empty items must be refused with a named error, got %+v", p) + } + }) +} + +func TestParseReply_NoteToScratchpad(t *testing.T) { + p := ParseReply(validReply(t, ActionNoteToScratchpad, `[{"text":"remember this"}]`), testKinds, V1Actions()) + if !p.Valid || len(p.NoteTexts) != 1 || p.NoteTexts[0] != "remember this" { + t.Fatalf("expected one note text, got %+v", p) + } +} + +// The hard cap in code: replace/delete classes may never auto-run, and +// everything that writes requires review. +func TestTaxonomyHardCap(t *testing.T) { + if MayAutoRun(ClassReplace) || MayAutoRun(ClassDelete) || MayAutoRun(ClassCreate) || MayAutoRun(ClassUnknown) { + t.Fatal("only read-class may auto-run") + } + if !MayAutoRun(ClassRead) { + t.Fatal("read-class is allowed agentically") + } + for _, c := range []ActionClass{ClassCreate, ClassReplace, ClassDelete, ClassUnknown} { + if !RequiresReview(c) { + t.Fatalf("class %q must require review", c) + } + } +} + +// A round-trip through the emitted schema: a reply an AI would produce +// from the envelope validates; the envelope itself does NOT validate as +// a reply (the two shapes stay distinguishable). +func TestEnvelopeAndReplyStayDistinguishable(t *testing.T) { + env, err := BuildContextEnvelope([]ContextCard{{Title: "A", Kind: "Topic"}}, testKinds, V1Actions()) + if err != nil { + t.Fatalf("build: %v", err) + } + raw, _ := json.Marshal(env) + p := ParseReply(string(raw), testKinds, V1Actions()) + if !p.Recognized { + t.Fatal("an envelope still carries the mill marker") + } + if p.Valid { + t.Fatal("a context envelope must not validate as a reply") + } +} + +// Regression (CodeQL: unsafe quoting class): a kind label carrying +// quotes/JSON metacharacters must land as an inert enum VALUE -- it can +// never alter the schema document's structure, because the schema is +// built structurally, not by string formatting. +func TestReplySchema_HostileLabelsStayInert(t *testing.T) { + hostile := `x"],"$ref":"https://evil.example/schema"}//` + raw, err := ReplySchema([]string{hostile, "Topic"}, V1Actions()) + if err != nil { + t.Fatalf("ReplySchema: %v", err) + } + var doc map[string]any + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatalf("schema no longer parses: %v", err) + } + defs := doc["$defs"].(map[string]any)["item"].(map[string]any) + kindEnum := defs["properties"].(map[string]any)["kind"].(map[string]any)["enum"].([]any) + if len(kindEnum) != 2 || kindEnum[0] != hostile { + t.Fatalf("hostile label mangled or lost: %v", kindEnum) + } + if strings.Contains(string(raw), "evil.example\"}") { + t.Fatal("label content escaped its enum string") + } +} diff --git a/internal/domain/clipbridge/envelope.go b/internal/domain/clipbridge/envelope.go new file mode 100644 index 00000000..e051a468 --- /dev/null +++ b/internal/domain/clipbridge/envelope.go @@ -0,0 +1,113 @@ +// Package clipbridge is the clipboard bridge's protocol core (goal +// 0099): the JSON envelope Mill copies OUT for an external AI, and the +// validated reply it accepts back IN. The clipboard is the transport, +// the inline JSON Schema is the protocol, and the guardrail taxonomy +// (class.go) decides what a reply may do. Pure domain: no clipboard +// access, no persistence -- the service layer owns those. +package clipbridge + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// EnvelopeVersion is the "mill" marker value; a reply carrying a +// different version is refused with an honest error, never guessed at. +const EnvelopeVersion = 1 + +// KindContext is the OUT envelope's kind: card data plus the reply +// contract an external AI answers against. +const KindContext = "context" + +// Envelope is the single JSON object Copy as context places on the +// clipboard. Schema is an INLINE JSON Schema 2020-12 document (the +// transport is an offline blob -- no URI resolution), restricted to the +// portable structured-output subset replySchemaBase documents. +type Envelope struct { + Mill int `json:"mill"` + Kind string `json:"kind"` + Instructions string `json:"instructions"` + Schema json.RawMessage `json:"schema"` + AllowedActions []string `json:"allowedActions"` + Items []json.RawMessage `json:"items"` +} + +// ContextCard is one card's structured data inside an OUT envelope -- +// the same facts renderCardContext prints as prose, as data. +type ContextCard struct { + Title string `json:"title"` + Kind string `json:"kind"` + Note string `json:"note,omitempty"` + Fields map[string]string `json:"fields,omitempty"` + Links []ContextLink `json:"links,omitempty"` +} + +// ContextLink names a relationship from the card's own point of view. +type ContextLink struct { + Kind string `json:"kind"` + Direction string `json:"direction"` // "out" or "in" + Title string `json:"title"` +} + +// replyInstructions is the one line of prose the contract allows; the +// schema is the rest of the instruction. +const replyInstructions = "Reply with a single JSON code block that conforms to the schema in this envelope's \"schema\" field." + +// BuildContextEnvelope assembles the OUT envelope for a set of cards. +// kindLabels and actions become enum values inside the reply schema -- +// the only dynamic parts, injected into fixed literal JSON so the +// portable-subset restriction can never widen at runtime. +func BuildContextEnvelope(cards []ContextCard, kindLabels []string, actions []string) (Envelope, error) { + schema, err := ReplySchema(kindLabels, actions) + if err != nil { + return Envelope{}, err + } + items := make([]json.RawMessage, 0, len(cards)) + for _, c := range cards { + raw, err := json.Marshal(c) + if err != nil { + return Envelope{}, fmt.Errorf("marshal context card %q: %w", c.Title, err) + } + items = append(items, raw) + } + sorted := append([]string(nil), actions...) + sort.Strings(sorted) + return Envelope{ + Mill: EnvelopeVersion, + Kind: KindContext, + Instructions: replyInstructions, + Schema: schema, + AllowedActions: sorted, + Items: items, + }, nil +} + +// BuildCorrectionEnvelope re-emits the reply contract after a failed or +// partially-declined reply -- the correction loop is re-ask-the-source, +// never edit-in-place, so the envelope itself carries what went wrong +// and what the user declined, and the schema remains the instruction. +func BuildCorrectionEnvelope(problems []string, declinedTitles []string, kindLabels []string, actions []string) (Envelope, error) { + schema, err := ReplySchema(kindLabels, actions) + if err != nil { + return Envelope{}, err + } + instructions := replyInstructions + if len(problems) > 0 { + instructions = "Your previous reply did not validate: " + strings.Join(problems, "; ") + ". " + replyInstructions + } + if len(declinedTitles) > 0 { + instructions += " Do not propose these declined items again: " + strings.Join(declinedTitles, ", ") + "." + } + sorted := append([]string(nil), actions...) + sort.Strings(sorted) + return Envelope{ + Mill: EnvelopeVersion, + Kind: KindContext, + Instructions: instructions, + Schema: schema, + AllowedActions: sorted, + Items: []json.RawMessage{}, + }, nil +} diff --git a/internal/domain/clipbridge/schema.go b/internal/domain/clipbridge/schema.go new file mode 100644 index 00000000..9412a7b6 --- /dev/null +++ b/internal/domain/clipbridge/schema.go @@ -0,0 +1,88 @@ +package clipbridge + +import ( + "encoding/json" + "fmt" +) + +// KindReply is the reply envelope's kind -- fixed, so a reply and a +// context envelope are distinguishable on the same clipboard. +const KindReply = "reply" + +// replySchemaTemplate is the committed literal reply contract; the two +// empty enums (action, item kind) are the ONLY parts ReplySchema fills +// in, and it does so structurally -- parse, set the arrays, re-marshal +// -- never by string formatting, so no label content can alter the +// document's shape. The vocabulary deliberately stays inside the +// portable structured-output intersection the goal contract locks: +// type/object/properties/required/additionalProperties:false/enum/ +// array items/local $refs -- no allOf/anyOf/if-then-else/external +// refs/recursion/format extensions +// (TestReplySchema_KeywordsStayInsidePortableSubset pins this). +// Per-action item requirements (a card needs title, a note needs text) +// are deliberately NOT expressed conditionally -- conditionals are +// outside the subset -- and are enforced by ParseReply instead. +const replySchemaTemplate = `{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "required": ["mill", "kind", "action", "items"], + "properties": { + "mill": {"type": "integer", "enum": [1]}, + "kind": {"type": "string", "enum": ["reply"]}, + "action": {"type": "string", "enum": []}, + "items": {"type": "array", "items": {"$ref": "#/$defs/item"}} + }, + "$defs": { + "item": { + "type": "object", + "additionalProperties": false, + "properties": { + "title": {"type": "string"}, + "kind": {"type": "string", "enum": []}, + "note": {"type": "string"}, + "summary": {"type": "string"}, + "text": {"type": "string"} + } + } + } +}` + +// ReplySchema renders the reply contract with the current kind labels +// and allowed actions as enums. +func ReplySchema(kindLabels []string, actions []string) (json.RawMessage, error) { + if len(kindLabels) == 0 || len(actions) == 0 { + return nil, fmt.Errorf("reply schema needs at least one kind label and one action") + } + var doc map[string]any + if err := json.Unmarshal([]byte(replySchemaTemplate), &doc); err != nil { + return nil, fmt.Errorf("committed reply schema template is not valid JSON: %w", err) + } + setEnum := func(path []string, values []string) error { + node := doc + for _, key := range path[:len(path)-1] { + next, ok := node[key].(map[string]any) + if !ok { + return fmt.Errorf("reply schema template lost its %q node", key) + } + node = next + } + enum := make([]any, len(values)) + for i, v := range values { + enum[i] = v + } + node[path[len(path)-1]] = map[string]any{"type": "string", "enum": enum} + return nil + } + if err := setEnum([]string{"properties", "action"}, actions); err != nil { + return nil, err + } + if err := setEnum([]string{"$defs", "item", "properties", "kind"}, kindLabels); err != nil { + return nil, err + } + raw, err := json.Marshal(doc) + if err != nil { + return nil, fmt.Errorf("marshal reply schema: %w", err) + } + return raw, nil +} diff --git a/internal/domain/clipbridge/validate.go b/internal/domain/clipbridge/validate.go new file mode 100644 index 00000000..431864cb --- /dev/null +++ b/internal/domain/clipbridge/validate.go @@ -0,0 +1,182 @@ +package clipbridge + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/santhosh-tekuri/jsonschema/v6" + "golang.org/x/text/language" + "golang.org/x/text/message" +) + +// CardDraft is one to-be-created card parsed from a valid create-cards +// reply -- exactly what the review surface previews. +type CardDraft struct { + Title string `json:"title"` + Kind string `json:"kind,omitempty"` + Note string `json:"note,omitempty"` + Summary string `json:"summary,omitempty"` +} + +// ReplyPreview is ParseReply's typed result. Never a bare Go error for +// user-visible failure modes -- the malformed cases render inline (the +// PreviewClipboardApply precedent), so every refusal names itself. +type ReplyPreview struct { + Recognized bool `json:"Recognized"` // a {"mill": ...} object at all + Valid bool `json:"Valid"` + Action string `json:"Action"` + Class ActionClass `json:"Class"` + Errors []string `json:"Errors"` + Cards []CardDraft `json:"Cards"` + NoteTexts []string `json:"NoteTexts"` +} + +type replyWire struct { + Mill *int `json:"mill"` + Kind string `json:"kind"` + Action string `json:"action"` + Items []json.RawMessage `json:"items"` +} + +// ParseReply validates a raw clipboard string against the v1 reply +// contract rendered with the CURRENT kind labels and actions. The +// schema validates shape; the per-action item requirements the schema +// deliberately cannot express (conditionals are outside the portable +// subset) are enforced here with named errors. +func ParseReply(raw string, kindLabels []string, actions []string) ReplyPreview { + trimmed := stripCodeFence(strings.TrimSpace(raw)) + var wire replyWire + if err := json.Unmarshal([]byte(trimmed), &wire); err != nil || wire.Mill == nil { + return ReplyPreview{Recognized: false} + } + p := ReplyPreview{Recognized: true, Action: wire.Action, Class: ClassOf(wire.Action)} + if *wire.Mill != EnvelopeVersion { + p.Errors = append(p.Errors, fmt.Sprintf("unsupported envelope version %d (this Mill speaks version %d)", *wire.Mill, EnvelopeVersion)) + return p + } + + schemaDocRaw, err := ReplySchema(kindLabels, actions) + if err != nil { + p.Errors = append(p.Errors, fmt.Sprintf("internal: reply schema unavailable: %v", err)) + return p + } + schemaDoc, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaDocRaw)) + if err != nil { + p.Errors = append(p.Errors, fmt.Sprintf("internal: reply schema unreadable: %v", err)) + return p + } + compiler := jsonschema.NewCompiler() + if err := compiler.AddResource("mill://schema/clipbridge-reply/v1", schemaDoc); err != nil { + p.Errors = append(p.Errors, fmt.Sprintf("internal: reply schema rejected: %v", err)) + return p + } + schema, err := compiler.Compile("mill://schema/clipbridge-reply/v1") + if err != nil { + p.Errors = append(p.Errors, fmt.Sprintf("internal: reply schema does not compile: %v", err)) + return p + } + instance, err := jsonschema.UnmarshalJSON(strings.NewReader(trimmed)) + if err != nil { + p.Errors = append(p.Errors, fmt.Sprintf("reply is not valid JSON: %v", err)) + return p + } + if err := schema.Validate(instance); err != nil { + p.Errors = append(p.Errors, schemaErrorLines(err)...) + return p + } + + // Shape is valid; now the per-action requirements. + switch wire.Action { + case ActionCreateCards: + for i, item := range wire.Items { + var d CardDraft + if err := json.Unmarshal(item, &d); err != nil { + p.Errors = append(p.Errors, fmt.Sprintf("item %d: unreadable: %v", i+1, err)) + continue + } + if strings.TrimSpace(d.Title) == "" { + p.Errors = append(p.Errors, fmt.Sprintf("item %d: a card needs a non-empty \"title\"", i+1)) + continue + } + p.Cards = append(p.Cards, d) + } + if len(wire.Items) == 0 { + p.Errors = append(p.Errors, "the reply carries no items to create") + } + case ActionNoteToScratchpad: + for i, item := range wire.Items { + var d struct { + Text string `json:"text"` + } + if err := json.Unmarshal(item, &d); err != nil { + p.Errors = append(p.Errors, fmt.Sprintf("item %d: unreadable: %v", i+1, err)) + continue + } + if strings.TrimSpace(d.Text) == "" { + p.Errors = append(p.Errors, fmt.Sprintf("item %d: a note needs a non-empty \"text\"", i+1)) + continue + } + p.NoteTexts = append(p.NoteTexts, d.Text) + } + if len(wire.Items) == 0 { + p.Errors = append(p.Errors, "the reply carries no items to save") + } + default: + // The schema's action enum already refused unknown actions; this + // branch only exists for defense in depth. + p.Errors = append(p.Errors, fmt.Sprintf("action %q is not offered by this Mill", wire.Action)) + } + + p.Valid = len(p.Errors) == 0 + return p +} + +// stripCodeFence tolerates a reply pasted from a chat surface that +// wraps JSON in a markdown code fence -- the contract asks for a bare +// JSON block, but a fenced one is unambiguous and refusing it would be +// pedantry the user pays for. +func stripCodeFence(s string) string { + if !strings.HasPrefix(s, "```") { + return s + } + body, ok := strings.CutPrefix(s, "```") + if !ok { + return s + } + if idx := strings.Index(body, "\n"); idx >= 0 { + body = body[idx+1:] + } + body = strings.TrimSpace(body) + body = strings.TrimSuffix(body, "```") + return strings.TrimSpace(body) +} + +// schemaErrorLines flattens a jsonschema validation error into honest, +// per-failure lines naming what failed where. +func schemaErrorLines(err error) []string { + var ve *jsonschema.ValidationError + if !errors.As(err, &ve) { + return []string{err.Error()} + } + printer := message.NewPrinter(language.English) + var lines []string + var walk func(e *jsonschema.ValidationError) + walk = func(e *jsonschema.ValidationError) { + if len(e.Causes) == 0 { + loc := "/" + strings.Join(e.InstanceLocation, "/") + lines = append(lines, fmt.Sprintf("%s: %s", loc, e.ErrorKind.LocalizedString(printer))) + return + } + for _, c := range e.Causes { + walk(c) + } + } + walk(ve) + if len(lines) == 0 { + lines = []string{err.Error()} + } + return lines +} diff --git a/internal/domain/composition/atlasfromreply.go b/internal/domain/composition/atlasfromreply.go new file mode 100644 index 00000000..3c4d0193 --- /dev/null +++ b/internal/domain/composition/atlasfromreply.go @@ -0,0 +1,86 @@ +package composition + +import ( + "encoding/json" + "fmt" + + "github.com/alicoding/mill/internal/domain/guardrail" +) + +// atlasReplyMaterializerFn turns a VALIDATED, user-accepted clipboard +// bridge reply's items (goal 0099 -- JSON array of drafts, each either +// a card draft with "title" or a note draft with "text") into real +// Atlas records -- injected so this domain package never depends on +// atlassvc's storage (.claude/rules/backend.md), which owns kind-label +// resolution, field mapping, and the Scratchpad landing for notes. +// Returns a short JSON summary ({"cards":N,"notes":N,"ids":[...]}). +// Defaults to erroring so a node run before SetAtlasReplyMaterializer +// is wired fails loudly. +var atlasReplyMaterializerFn = func(itemsJSON string, sourceRunID string) (string, error) { + return "", fmt.Errorf("no atlas reply materializer registered (yet)") +} + +// SetAtlasReplyMaterializer wires the function apply-atlas-from-reply +// nodes use. Called once from main.go once AtlasService exists. +func SetAtlasReplyMaterializer(fn func(itemsJSON string, sourceRunID string) (string, error)) { + atlasReplyMaterializerFn = fn +} + +func init() { + RegisterNodeType(NodeType{ + ID: "apply-atlas-from-reply", Kind: KindApply, + Label: "Atlas: create from reply items", + // ClassLocal: writes to Atlas's own persisted store, same + // classification as apply-atlas-card-create. The clipboard + // bridge's own taxonomy (clipbridge.MayAutoRun) already forces a + // human review-accept BEFORE any run reaches this node -- the + // items arriving here are the accepted subset, never the raw + // reply. + Effect: guardrail.ClassLocal, + Complexity: ComplexityAdvanced, + Output: "payload unchanged; a created-summary JSON -> the output attribute, if named", + Description: "Creates Atlas records from an accepted clipboard reply's items: an item with a " + + "\"title\" becomes a card of its named kind, an item with \"text\" becomes a Scratchpad note. " + + "\"Items\" binds the attribute holding the accepted items as a JSON array.", + ConfigFields: []ConfigField{ + { + Key: "itemsAttribute", Label: "Items attribute", Type: FieldText, + Description: "Which Attributes field carries the accepted reply items (a JSON array).", + }, + { + Key: "outputAttribute", Label: "Output attribute (optional)", Type: FieldText, + Description: "Which Attributes field receives a summary of what was created.", + }, + }, + }, execAtlasFromReply) +} + +func execAtlasFromReply(node Node, ctx ExecContext) (ExecContext, error) { + attr := node.Config["itemsAttribute"] + if attr == "" { + return ctx, fmt.Errorf("apply-atlas-from-reply: itemsAttribute is required") + } + raw, _ := ctx.Attributes[attr].(string) + if raw == "" { + return ctx, fmt.Errorf("apply-atlas-from-reply: attribute %q carries no items", attr) + } + var probe []json.RawMessage + if err := json.Unmarshal([]byte(raw), &probe); err != nil { + return ctx, fmt.Errorf("apply-atlas-from-reply: attribute %q is not a JSON array: %w", attr, err) + } + if len(probe) == 0 { + return ctx, fmt.Errorf("apply-atlas-from-reply: attribute %q carries an empty item list", attr) + } + + summary, err := atlasReplyMaterializerFn(raw, currentRunID(ctx.RunContext)) + if err != nil { + return ctx, fmt.Errorf("apply-atlas-from-reply: %w", err) + } + if outAttr := node.Config["outputAttribute"]; outAttr != "" { + if ctx.Attributes == nil { + ctx.Attributes = map[string]any{} + } + ctx.Attributes[outAttr] = summary + } + return ctx, nil +} diff --git a/internal/domain/composition/atlasfromreply_test.go b/internal/domain/composition/atlasfromreply_test.go new file mode 100644 index 00000000..253ff59f --- /dev/null +++ b/internal/domain/composition/atlasfromreply_test.go @@ -0,0 +1,60 @@ +package composition + +import ( + "strings" + "testing" +) + +func TestExecAtlasFromReply(t *testing.T) { + node := Node{Config: map[string]string{"itemsAttribute": "items", "outputAttribute": "created"}} + + t.Run("materializes via the injected function and stores the summary", func(t *testing.T) { + restore := atlasReplyMaterializerFn + defer func() { atlasReplyMaterializerFn = restore }() + var got string + atlasReplyMaterializerFn = func(itemsJSON, sourceRunID string) (string, error) { + got = itemsJSON + return `{"cards":1}`, nil + } + ctx := ExecContext{Attributes: map[string]any{"items": `[{"title":"CRM"}]`}} + out, err := execAtlasFromReply(node, ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != `[{"title":"CRM"}]` { + t.Errorf("materializer received %q", got) + } + if out.Attributes["created"] != `{"cards":1}` { + t.Errorf("summary not stored: %+v", out.Attributes) + } + }) + + t.Run("missing attribute is a named error", func(t *testing.T) { + _, err := execAtlasFromReply(node, ExecContext{Attributes: map[string]any{}}) + if err == nil || !strings.Contains(err.Error(), "no items") { + t.Fatalf("expected a named no-items error, got %v", err) + } + }) + + t.Run("non-array payload refused", func(t *testing.T) { + _, err := execAtlasFromReply(node, ExecContext{Attributes: map[string]any{"items": `{"title":"x"}`}}) + if err == nil || !strings.Contains(err.Error(), "JSON array") { + t.Fatalf("expected a named array error, got %v", err) + } + }) + + t.Run("empty array refused", func(t *testing.T) { + _, err := execAtlasFromReply(node, ExecContext{Attributes: map[string]any{"items": `[]`}}) + if err == nil || !strings.Contains(err.Error(), "empty") { + t.Fatalf("expected a named empty error, got %v", err) + } + }) + + t.Run("unwired materializer fails loudly", func(t *testing.T) { + ctx := ExecContext{Attributes: map[string]any{"items": `[{"title":"x"}]`}} + _, err := execAtlasFromReply(node, ctx) + if err == nil || !strings.Contains(err.Error(), "no atlas reply materializer") { + t.Fatalf("expected the unwired default to error, got %v", err) + } + }) +} diff --git a/internal/domain/composition/builtinworkflows.go b/internal/domain/composition/builtinworkflows.go index 9f3ff7d8..94ce0362 100644 --- a/internal/domain/composition/builtinworkflows.go +++ b/internal/domain/composition/builtinworkflows.go @@ -486,7 +486,10 @@ func BuiltInWorkflows() []Workflow { workflows = append(workflows, builtInListWriteWorkflows()...) // goal 0087: apply-file-move's own seeded proof, same split-file // reasoning. - return append(workflows, builtInFileMoveWorkflows()...) + workflows = append(workflows, builtInFileMoveWorkflows()...) + // goal 0099: the clipboard bridge's two seeded routes, same + // split-file reasoning. + return append(workflows, builtInClipbridgeWorkflows()...) } // ExampleChildWorkflowID is exported so the parent seed above and any diff --git a/internal/domain/composition/builtinworkflows_clipbridge.go b/internal/domain/composition/builtinworkflows_clipbridge.go new file mode 100644 index 00000000..4e1e7184 --- /dev/null +++ b/internal/domain/composition/builtinworkflows_clipbridge.go @@ -0,0 +1,75 @@ +package composition + +import "github.com/alicoding/mill/internal/domain/seedorigin" + +// The clipboard bridge's two seeded ROUTES (goal 0099): what the Quick +// Panel runs when the user accepts a validated reply. Routes are +// composition by contract -- seeded, user-editable workflows, never a +// bespoke service path -- so "same but different destination" is an +// edit here, not a kernel change. +const ( + // ReplyCardsWorkflowID is the create-cards route. + ReplyCardsWorkflowID = "clipbridge-reply-cards-workflow" + // ReplyNoteWorkflowID is the note-to-scratchpad route. + ReplyNoteWorkflowID = "clipbridge-reply-note-workflow" +) + +func builtInClipbridgeWorkflows() []Workflow { + const ( + cardsTriggerID = "clipbridge-reply-cards-trigger" + cardsApplyID = "clipbridge-reply-cards-apply" + noteTriggerID = "clipbridge-reply-note-trigger" + noteApplyID = "clipbridge-reply-note-apply" + ) + cardsNodes, err := ResolveNodeDefaults([]Node{ + {ID: cardsTriggerID, NodeTypeID: "trigger-manual", Position: Position{X: 0, Y: 0}}, + {ID: cardsApplyID, NodeTypeID: "apply-atlas-from-reply", Position: Position{X: 0, Y: 100}, + Config: map[string]string{"itemsAttribute": "items", "outputAttribute": "created"}}, + }) + if err != nil { + panic("built-in workflow references an unknown node type: " + err.Error()) + } + noteNodes, err := ResolveNodeDefaults([]Node{ + {ID: noteTriggerID, NodeTypeID: "trigger-manual", Position: Position{X: 0, Y: 0}}, + {ID: noteApplyID, NodeTypeID: "apply-atlas-from-reply", Position: Position{X: 0, Y: 100}, + Config: map[string]string{"itemsAttribute": "items", "outputAttribute": "created"}}, + }) + if err != nil { + panic("built-in workflow references an unknown node type: " + err.Error()) + } + + return []Workflow{ + { + ID: ReplyCardsWorkflowID, + Label: "Clipboard reply: create cards", + Description: "Creates the Atlas cards a reviewed clipboard reply proposes. Runs from the Quick " + + "Panel after you accept the preview -- the accepted items arrive in the \"items\" input. " + + "Edit this workflow to change where accepted cards go or what happens after they land.", + Nodes: cardsNodes, + Attributes: []AttributeDef{ + {Key: "items", Label: "Accepted items (JSON)", Type: FieldText}, + }, + Edges: []Edge{ + {ID: "clipbridge-reply-cards-e0", Source: cardsTriggerID, Target: cardsApplyID}, + }, + BuiltIn: true, + Seed: seedorigin.Stamp(1), + }, + { + ID: ReplyNoteWorkflowID, + Label: "Clipboard reply: save to Scratchpad", + Description: "Saves a reviewed clipboard reply's text into the Scratchpad as a note. Runs from " + + "the Quick Panel after you accept the preview. Edit this workflow to route the note " + + "somewhere else or add follow-up steps.", + Nodes: noteNodes, + Attributes: []AttributeDef{ + {Key: "items", Label: "Accepted items (JSON)", Type: FieldText}, + }, + Edges: []Edge{ + {ID: "clipbridge-reply-note-e0", Source: noteTriggerID, Target: noteApplyID}, + }, + BuiltIn: true, + Seed: seedorigin.Stamp(1), + }, + } +} diff --git a/internal/domain/composition/seedproof_test.go b/internal/domain/composition/seedproof_test.go index c9e4de7a..35ca4fef 100644 --- a/internal/domain/composition/seedproof_test.go +++ b/internal/domain/composition/seedproof_test.go @@ -152,6 +152,13 @@ var workflowProofRegistry = map[string]seedProof{ "executionsvc.TestSeededCardCreateLinkExample_CreatesFindsAndLinksCards", "e2e: seed-completeness.spec.ts > Example: Create and link Atlas cards runs end to end through the real live app", ), + "clipbridge-reply-cards-workflow": proven( + "executionsvc.TestSeededClipbridgeCardsRoute_CreatesAcceptedCards", + "executionsvc.TestClipbridgePreviewToRouteLoop (the preview-to-route loop over the same seed)", + ), + "clipbridge-reply-note-workflow": proven( + "executionsvc.TestSeededClipbridgeNoteRoute_LandsInScratchpad", + ), "example-list-write-workflow": proven( "executionsvc.TestGuardrail_ApplyListRowParks_ApproveWritesRow", "executionsvc.TestSeededTaskTrackerExample_PinnedSearch_ResolvesFrozenV1AfterLiveWrite (via the write-path half of the same test)", diff --git a/internal/services/atlassvc/atlasservice_clipbridge.go b/internal/services/atlassvc/atlasservice_clipbridge.go new file mode 100644 index 00000000..24ad9ebb --- /dev/null +++ b/internal/services/atlassvc/atlasservice_clipbridge.go @@ -0,0 +1,249 @@ +package atlassvc + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/alicoding/mill/internal/domain/atlas" + "github.com/alicoding/mill/internal/domain/clipbridge" + "github.com/alicoding/mill/internal/domain/composition" +) + +// The clipboard bridge (goal 0099) lives beside AtlasService because +// every dynamic part of the protocol is Atlas's own data: the kind +// labels the reply schema enumerates, the card titles collision +// detection compares against, and the records an accepted reply +// materializes into. The protocol itself stays in domain/clipbridge. + +// ClipbridgeCardOffer is one to-be-created card row on the review +// surface: the parsed draft plus its collision flag (the dedupe +// convention -- colliding rows default unchecked). +type ClipbridgeCardOffer struct { + Draft clipbridge.CardDraft `json:"Draft"` + CollidesWithID string `json:"CollidesWithID"` + CollidesWithKind string `json:"CollidesWithKind"` +} + +// ClipbridgeReplyPreview is what the Quick Panel renders when the +// clipboard carries a mill reply: the domain preview plus the +// Atlas-side collision annotations and the route workflow to run on +// accept. +type ClipbridgeReplyPreview struct { + Recognized bool `json:"Recognized"` + Valid bool `json:"Valid"` + Action string `json:"Action"` + Errors []string `json:"Errors"` + Cards []ClipbridgeCardOffer `json:"Cards"` + NoteTexts []string `json:"NoteTexts"` + RouteWorkflowID string `json:"RouteWorkflowID"` + RouteLabel string `json:"RouteLabel"` +} + +// PreviewClipbridgeReply validates a raw clipboard string against the +// reply contract (schema-first, then per-action requirements) and +// annotates it with collision state. Malformed input renders inline -- +// this returns a Go error only for internal faults. +func (a *AtlasService) PreviewClipbridgeReply(raw string) (ClipbridgeReplyPreview, error) { + kinds := a.Kinds() + labels := make([]string, 0, len(kinds)) + for _, k := range kinds { + labels = append(labels, k.Label) + } + sort.Strings(labels) + + p := clipbridge.ParseReply(raw, labels, clipbridge.V1Actions()) + out := ClipbridgeReplyPreview{ + Recognized: p.Recognized, + Valid: p.Valid, + Action: p.Action, + Errors: p.Errors, + NoteTexts: p.NoteTexts, + } + if !p.Recognized { + return out, nil + } + switch p.Action { + case clipbridge.ActionCreateCards: + out.RouteWorkflowID = composition.ReplyCardsWorkflowID + out.RouteLabel = fmt.Sprintf("Create %d cards", len(p.Cards)) + if len(p.Cards) == 1 { + out.RouteLabel = "Create 1 card" + } + case clipbridge.ActionNoteToScratchpad: + out.RouteWorkflowID = composition.ReplyNoteWorkflowID + out.RouteLabel = "Save to Scratchpad" + } + + byTitle := map[string]atlas.Card{} + for _, c := range a.Cards() { + byTitle[strings.ToLower(strings.TrimSpace(c.Title))] = c + } + kindLabelByID := map[string]string{} + for _, k := range kinds { + kindLabelByID[k.ID] = k.Label + } + for _, d := range p.Cards { + offer := ClipbridgeCardOffer{Draft: d} + if existing, ok := byTitle[strings.ToLower(strings.TrimSpace(d.Title))]; ok { + offer.CollidesWithID = existing.ID + offer.CollidesWithKind = kindLabelByID[existing.KindID] + } + out.Cards = append(out.Cards, offer) + } + return out, nil +} + +// CardContextEnvelope renders a card as the OUT envelope (goal 0099): +// its data as items, the reply contract inline. The plain-text +// CardContextBlock stays for human destinations; this is the +// machine-readable twin an external AI answers against. +func (a *AtlasService) CardContextEnvelope(cardID string) (string, error) { + a.mu.RLock() + in, err := a.cardContextInputLocked(cardID) + if err != nil { + a.mu.RUnlock() + return "", err + } + labels := make([]string, 0, len(a.kinds)) + for _, k := range a.kinds { + labels = append(labels, k.Label) + } + a.mu.RUnlock() + sort.Strings(labels) + + ctxCard := clipbridge.ContextCard{Title: in.title, Kind: in.kindLabel, Note: in.note} + if len(in.fields) > 0 { + ctxCard.Fields = make(map[string]string, len(in.fields)) + for _, f := range in.fields { + ctxCard.Fields[f.label] = f.value + } + } + for _, l := range in.outgoing { + ctxCard.Links = append(ctxCard.Links, clipbridge.ContextLink{Kind: l.linkKindLabel, Direction: "out", Title: l.otherTitle}) + } + for _, l := range in.incoming { + ctxCard.Links = append(ctxCard.Links, clipbridge.ContextLink{Kind: l.linkKindLabel, Direction: "in", Title: l.otherTitle}) + } + + env, err := clipbridge.BuildContextEnvelope([]clipbridge.ContextCard{ctxCard}, labels, clipbridge.V1Actions()) + if err != nil { + return "", err + } + raw, err := json.MarshalIndent(env, "", " ") + if err != nil { + return "", err + } + return string(raw), nil +} + +// materializeReplyItems is the apply-atlas-from-reply seam: accepted +// reply items become real records. An item with a title becomes a card +// (kind label resolved against the CURRENT kinds; summary lands in the +// kind's own "summary" field when it declares one, otherwise appended +// to the note so nothing is silently dropped); an item with text +// becomes a Scratchpad note. +func (a *AtlasService) materializeReplyItems(itemsJSON string, sourceRunID string) (string, error) { + var items []struct { + Title string `json:"title"` + Kind string `json:"kind"` + Note string `json:"note"` + Summary string `json:"summary"` + Text string `json:"text"` + } + if err := json.Unmarshal([]byte(itemsJSON), &items); err != nil { + return "", fmt.Errorf("reply items are not a JSON array: %w", err) + } + + kinds := a.Kinds() + if len(kinds) == 0 { + return "", fmt.Errorf("no card kinds exist to create into") + } + kindByLabel := map[string]atlas.Kind{} + for _, k := range kinds { + kindByLabel[strings.ToLower(k.Label)] = k + } + // A draft without a kind takes the first declared kind -- the + // schema's enum already restricts named kinds, so this only covers + // the omitted case. + defaultKind := kinds[0] + + var ids []string + cards, notes := 0, 0 + for i, item := range items { + switch { + case strings.TrimSpace(item.Title) != "": + kind := defaultKind + if item.Kind != "" { + k, ok := kindByLabel[strings.ToLower(item.Kind)] + if !ok { + return "", fmt.Errorf("item %d: no kind labeled %q", i+1, item.Kind) + } + kind = k + } + note := item.Note + fields := map[string]string{} + switch { + case item.Summary == "": + case kindDeclaresField(kind, "summary"): + fields["summary"] = item.Summary + case note == "": + note = item.Summary + default: + note = note + "\n\n" + item.Summary + } + card, err := a.CreateCardForWorkflow(kind.ID, item.Title, note, fields, sourceRunID) + if err != nil { + return "", fmt.Errorf("item %d (%q): %w", i+1, item.Title, err) + } + ids = append(ids, card.ID) + cards++ + case strings.TrimSpace(item.Text) != "": + n, err := a.CreateNote(item.Text, atlas.Position{}, atlas.BuiltInScratchpadCardID) + if err != nil { + return "", fmt.Errorf("item %d (note): %w", i+1, err) + } + ids = append(ids, n.ID) + notes++ + default: + return "", fmt.Errorf("item %d carries neither a title nor text", i+1) + } + } + + summary, err := json.Marshal(map[string]any{"cards": cards, "notes": notes, "ids": ids}) + if err != nil { + return "", err + } + return string(summary), nil +} + +func kindDeclaresField(k atlas.Kind, key string) bool { + for _, f := range k.Fields { + if strings.EqualFold(f.Key, key) { + return true + } + } + return false +} + +// CorrectionEnvelope re-emits the reply contract for the +// re-ask-the-source loop: validation problems and declined titles ride +// the instruction line, the schema stays the instruction. +func (a *AtlasService) CorrectionEnvelope(problems []string, declinedTitles []string) (string, error) { + kinds := a.Kinds() + labels := make([]string, 0, len(kinds)) + for _, k := range kinds { + labels = append(labels, k.Label) + } + sort.Strings(labels) + env, err := clipbridge.BuildCorrectionEnvelope(problems, declinedTitles, labels, clipbridge.V1Actions()) + if err != nil { + return "", err + } + raw, err := json.MarshalIndent(env, "", " ") + if err != nil { + return "", err + } + return string(raw), nil +} diff --git a/internal/services/atlassvc/atlasservice_clipbridge_test.go b/internal/services/atlassvc/atlasservice_clipbridge_test.go new file mode 100644 index 00000000..702e7e83 --- /dev/null +++ b/internal/services/atlassvc/atlasservice_clipbridge_test.go @@ -0,0 +1,174 @@ +package atlassvc + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/alicoding/mill/internal/domain/atlas" + "github.com/alicoding/mill/internal/services/servicetest" +) + +func TestPreviewClipbridgeReply(t *testing.T) { + a := NewAtlasService(servicetest.NewFakeStore()) + + t.Run("prose is not recognized", func(t *testing.T) { + p, err := a.PreviewClipbridgeReply("meeting notes, nothing structured") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.Recognized { + t.Fatalf("prose recognized: %+v", p) + } + }) + + t.Run("valid create-cards reply routes and flags collisions", func(t *testing.T) { + reply := `{"mill":1,"kind":"reply","action":"create-cards","items":[{"title":"Getting started"},{"title":"Brand new"}]}` + p, err := a.PreviewClipbridgeReply(reply) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !p.Valid || p.RouteWorkflowID == "" || !strings.Contains(p.RouteLabel, "2 cards") { + t.Fatalf("preview = %+v", p) + } + if p.Cards[0].CollidesWithID == "" || p.Cards[1].CollidesWithID != "" { + t.Fatalf("collision flags wrong: %+v", p.Cards) + } + }) + + t.Run("invalid reply names its failures", func(t *testing.T) { + reply := `{"mill":1,"kind":"reply","action":"create-cards","items":[{"note":"missing title"}]}` + p, err := a.PreviewClipbridgeReply(reply) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.Valid || len(p.Errors) == 0 { + t.Fatalf("expected named errors: %+v", p) + } + }) + + t.Run("note reply routes to the scratchpad workflow", func(t *testing.T) { + reply := `{"mill":1,"kind":"reply","action":"note-to-scratchpad","items":[{"text":"remember"}]}` + p, err := a.PreviewClipbridgeReply(reply) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !p.Valid || p.RouteLabel != "Save to Scratchpad" || len(p.NoteTexts) != 1 { + t.Fatalf("preview = %+v", p) + } + }) +} + +func TestCardContextEnvelope(t *testing.T) { + a := NewAtlasService(servicetest.NewFakeStore()) + var seedCard atlas.Card + for _, c := range a.Cards() { + if c.Title == "Getting started" { + seedCard = c + } + } + if seedCard.ID == "" { + t.Fatal("seeded card missing") + } + + raw, err := a.CardContextEnvelope(seedCard.ID) + if err != nil { + t.Fatalf("CardContextEnvelope: %v", err) + } + var env map[string]any + if err := json.Unmarshal([]byte(raw), &env); err != nil { + t.Fatalf("envelope is not JSON: %v", err) + } + if env["mill"] != float64(1) || env["kind"] != "context" { + t.Fatalf("marker wrong: %v %v", env["mill"], env["kind"]) + } + items, _ := env["items"].([]any) + if len(items) != 1 { + t.Fatalf("items = %v", env["items"]) + } + first, _ := items[0].(map[string]any) + if first["title"] != "Getting started" { + t.Fatalf("item = %v", first) + } + if _, ok := env["schema"].(map[string]any); !ok { + t.Fatal("inline schema missing") + } + + if _, err := a.CardContextEnvelope("no-such-card"); err == nil { + t.Fatal("unknown card must error") + } +} + +func TestMaterializeReplyItems(t *testing.T) { + a := NewAtlasService(servicetest.NewFakeStore()) + + t.Run("cards and notes in one batch", func(t *testing.T) { + summary, err := a.materializeReplyItems(`[{"title":"Batch card","note":"n"},{"text":"batch note"}]`, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var s struct { + Cards int `json:"cards"` + Notes int `json:"notes"` + IDs []string `json:"ids"` + } + if err := json.Unmarshal([]byte(summary), &s); err != nil { + t.Fatalf("summary not JSON: %v", err) + } + if s.Cards != 1 || s.Notes != 1 || len(s.IDs) != 2 { + t.Fatalf("summary = %+v", s) + } + }) + + t.Run("summary maps to a declared field or appends to the note", func(t *testing.T) { + summary, err := a.materializeReplyItems(`[{"title":"With summary","summary":"the gist"}]`, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + _ = summary + var made atlas.Card + for _, c := range a.Cards() { + if c.Title == "With summary" { + made = c + } + } + if made.ID == "" { + t.Fatal("card missing") + } + if made.Fields["summary"] == "" && !strings.Contains(made.Note, "the gist") { + t.Fatalf("summary lost: %+v", made) + } + }) + + t.Run("unknown kind label errors with its name", func(t *testing.T) { + _, err := a.materializeReplyItems(`[{"title":"x","kind":"Nonesuch"}]`, "") + if err == nil || !strings.Contains(err.Error(), "Nonesuch") { + t.Fatalf("expected a named kind error, got %v", err) + } + }) + + t.Run("item with neither title nor text errors", func(t *testing.T) { + _, err := a.materializeReplyItems(`[{"note":"only a note"}]`, "") + if err == nil { + t.Fatal("expected an error") + } + }) +} + +func TestCorrectionEnvelope(t *testing.T) { + a := NewAtlasService(servicetest.NewFakeStore()) + raw, err := a.CorrectionEnvelope([]string{"item 1: a card needs a non-empty \"title\""}, []string{"Getting started"}) + if err != nil { + t.Fatalf("CorrectionEnvelope: %v", err) + } + if !strings.Contains(raw, "did not validate") || !strings.Contains(raw, "Getting started") { + t.Fatalf("instructions incomplete: %s", raw[:200]) + } + var env map[string]any + if err := json.Unmarshal([]byte(raw), &env); err != nil { + t.Fatalf("not JSON: %v", err) + } + if _, ok := env["schema"].(map[string]any); !ok { + t.Fatal("schema missing") + } +} diff --git a/internal/services/atlassvc/atlasservice_composition.go b/internal/services/atlassvc/atlasservice_composition.go index f31150c2..bdf50b85 100644 --- a/internal/services/atlassvc/atlasservice_composition.go +++ b/internal/services/atlassvc/atlasservice_composition.go @@ -24,6 +24,7 @@ func (a *AtlasService) WireCompositionSeams(cardChangeSink func(cardID, kindID, composition.SetAtlasCardCreator(a.createCardForComposition) composition.SetAtlasCardUpdater(a.updateCardForComposition) composition.SetAtlasCardLinker(a.linkCardsForComposition) + composition.SetAtlasReplyMaterializer(a.materializeReplyItems) SetCardChangeSink(cardChangeSink) } diff --git a/internal/services/executionsvc/clipbridge_seed_test.go b/internal/services/executionsvc/clipbridge_seed_test.go new file mode 100644 index 00000000..c7150819 --- /dev/null +++ b/internal/services/executionsvc/clipbridge_seed_test.go @@ -0,0 +1,142 @@ +package executionsvc + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/alicoding/mill/internal/domain/atlas" + "github.com/alicoding/mill/internal/domain/composition" + "github.com/alicoding/mill/internal/services/atlassvc" + "github.com/alicoding/mill/internal/services/compositionsvc" + "github.com/alicoding/mill/internal/services/guardrailsvc" + "github.com/alicoding/mill/internal/services/servicetest" +) + +func newClipbridgeSeedFixture(t *testing.T) (*ExecutionService, *atlassvc.AtlasService) { + t.Helper() + store := servicetest.NewFakeStore() + comp := compositionsvc.NewCompositionService(store) + guard := guardrailsvc.NewGuardrailService(store, comp) + dbPath := filepath.Join(t.TempDir(), "exec.db") + exec, err := NewExecutionService("sqlite:"+dbPath, comp, guard) + if err != nil { + t.Fatalf("NewExecutionService: %v", err) + } + t.Cleanup(func() { _ = exec.Shutdown(2 * time.Second) }) + atlasSvc := atlassvc.NewAtlasService(store) + atlasSvc.WireCompositionSeams(func(string, string, string, string, string) {}) + return exec, atlasSvc +} + +// TestSeededClipbridgeCardsRoute_CreatesAcceptedCards runs the real +// seeded "Clipboard reply: create cards" route (goal 0099) end to end: +// accepted items in, real cards out, including the summary-field +// mapping and the kind-label resolution. +func TestSeededClipbridgeCardsRoute_CreatesAcceptedCards(t *testing.T) { + exec, atlasSvc := newClipbridgeSeedFixture(t) + + baseline := len(atlasSvc.Cards()) + items := `[{"title":"Payments gateway","kind":"Topic","note":"from the AI reply"},{"title":"Ada follow-up"}]` + summary, err := exec.RunWorkflow(composition.ReplyCardsWorkflowID, RunKindTest, map[string]string{"items": items}) + if err != nil { + t.Fatalf("RunWorkflow: %v", err) + } + if summary.Status != "SUCCESS" { + t.Fatalf("summary = %+v, want SUCCESS", summary) + } + cards := atlasSvc.Cards() + if len(cards) != baseline+2 { + t.Fatalf("got %d cards, want %d", len(cards), baseline+2) + } + var made atlas.Card + for _, c := range cards { + if c.Title == "Payments gateway" { + made = c + } + } + if made.ID == "" || made.Note != "from the AI reply" { + t.Fatalf("created card wrong: %+v", made) + } +} + +// TestSeededClipbridgeNoteRoute_LandsInScratchpad runs the seeded +// "Clipboard reply: save to Scratchpad" route: the note lands under the +// seeded Scratchpad area, never floating at root. +func TestSeededClipbridgeNoteRoute_LandsInScratchpad(t *testing.T) { + exec, atlasSvc := newClipbridgeSeedFixture(t) + + items := `[{"text":"remember: renew the cert"}]` + summary, err := exec.RunWorkflow(composition.ReplyNoteWorkflowID, RunKindTest, map[string]string{"items": items}) + if err != nil { + t.Fatalf("RunWorkflow: %v", err) + } + if summary.Status != "SUCCESS" { + t.Fatalf("summary = %+v, want SUCCESS", summary) + } + var found bool + for _, n := range atlasSvc.Notes() { + if strings.Contains(n.Text, "renew the cert") { + found = true + if n.ParentID != atlas.BuiltInScratchpadCardID { + t.Fatalf("note landed under %q, want the Scratchpad", n.ParentID) + } + } + } + if !found { + t.Fatal("the reply's note never landed") + } +} + +// TestClipbridgePreviewToRouteLoop closes the whole IN loop at the +// service level: a raw reply string previews (with collision +// annotation), and the preview's own route workflow materializes the +// accepted items. +func TestClipbridgePreviewToRouteLoop(t *testing.T) { + exec, atlasSvc := newClipbridgeSeedFixture(t) + + // "Getting started" exists in the seed -- a reply proposing it + // again must flag the collision. + reply := `{"mill":1,"kind":"reply","action":"create-cards","items":[{"title":"Getting started"},{"title":"Fresh card"}]}` + preview, err := atlasSvc.PreviewClipbridgeReply(reply) + if err != nil { + t.Fatalf("PreviewClipbridgeReply: %v", err) + } + if !preview.Valid || preview.RouteWorkflowID != composition.ReplyCardsWorkflowID { + t.Fatalf("preview = %+v", preview) + } + if len(preview.Cards) != 2 { + t.Fatalf("got %d offers, want 2", len(preview.Cards)) + } + if preview.Cards[0].CollidesWithID == "" { + t.Errorf("existing title must flag its collision: %+v", preview.Cards[0]) + } + if preview.Cards[1].CollidesWithID != "" { + t.Errorf("fresh title must not flag: %+v", preview.Cards[1]) + } + + // Accept only the non-colliding item (the review surface's default) + // and run the preview's own route. + accepted, _ := json.Marshal([]map[string]string{{"title": preview.Cards[1].Draft.Title}}) + summary, err := exec.RunWorkflow(preview.RouteWorkflowID, RunKindTest, map[string]string{"items": string(accepted)}) + if err != nil { + t.Fatalf("RunWorkflow: %v", err) + } + if summary.Status != "SUCCESS" { + t.Fatalf("summary = %+v, want SUCCESS", summary) + } + var count int + for _, c := range atlasSvc.Cards() { + if c.Title == "Fresh card" { + count++ + } + if c.Title == "Getting started" { + count += 0 + } + } + if count != 1 { + t.Fatalf("accepted card count = %d, want exactly 1", count) + } +} diff --git a/internal/services/seeding/seed_fingerprints.json b/internal/services/seeding/seed_fingerprints.json index 77505f55..1a2a90f7 100644 --- a/internal/services/seeding/seed_fingerprints.json +++ b/internal/services/seeding/seed_fingerprints.json @@ -1,275 +1,282 @@ - { - "aiprovider:example-local-ollama": { - "seedRevision": 1, - "fingerprint": "75218e8e0727e6cbf04afd8ff25f8a461ccfea9c77fdccfb06ac824333fe4100" - }, - "atlascard:atlas-card-data-store": { - "seedRevision": 1, - "fingerprint": "35bf876e76601acf2f0b9216a43b0270dbe764170e040b2d6f7c8ed57aa4db7d" - }, - "atlascard:atlas-card-example-area": { - "seedRevision": 5, - "fingerprint": "a6eb22e7ae842f08fcd0b6004d10a7dd33cd298f63790dd35cfd2b7608ced2e4" - }, - "atlascard:atlas-card-example-contact": { - "seedRevision": 4, - "fingerprint": "315a9a9df7d4670eaab81dffb8273ab6e31842d81963fc5cb9a67786f76a598f" - }, - "atlascard:atlas-card-example-document": { - "seedRevision": 5, - "fingerprint": "4dfe77f81c4c3d72f669047c444fb9683cc03982e08dde9f4fbaa6ed9c4e10c5" - }, - "atlascard:atlas-card-getting-started": { - "seedRevision": 5, - "fingerprint": "013b4878544094f8f8630cb1ff84a52acc2833a42a9b5bf14e0a142bb478c5cd" - }, - "atlascard:atlas-card-my-space": { - "seedRevision": 5, - "fingerprint": "b29d4770ec62fe881f0205380f5e684c181bdffd4b93dd220850184ed94117e7" - }, - "atlascard:atlas-card-scratchpad": { - "seedRevision": 5, - "fingerprint": "b0330a8234b5f5ccd7768520bfbe70e72e0561795dc024ea29a89feea1e093a1" - }, - "atlascard:atlas-card-sync-service": { - "seedRevision": 1, - "fingerprint": "0c5d5c30133975d03290d7522dc3d71146968e3f6fdf3e158493c81c2921a9a7" - }, - "atlascard:atlas-card-system-landscape": { - "seedRevision": 1, - "fingerprint": "b0779404a5d92a2b5591e367dad464f4d0020cae88f8de3924bb0b15185f0f68" - }, - "atlascard:atlas-card-web-app": { - "seedRevision": 1, - "fingerprint": "7c70a8d7935ba5a15f199c9d10a8f11faf2998746b26e488ca47cc5a2c29ddb7" - }, - "atlaskind:atlas-kind-component": { - "seedRevision": 1, - "fingerprint": "c0717ac09c75dcef29c2a06c08d0d4a6e60832bb9385db4eb327f0fb069205c1" - }, - "atlaskind:atlas-kind-contact": { - "seedRevision": 2, - "fingerprint": "b4a22502670484312692a4118167d8a4e7395a0d59b39f590683376288a3e732" - }, - "atlaskind:atlas-kind-document": { - "seedRevision": 2, - "fingerprint": "61d42bc082711822ae67493bc63b25f4045a5881ee703c0ea342b78991c9b76a" - }, - "atlaskind:atlas-kind-intake": { - "seedRevision": 2, - "fingerprint": "ec06f5c0e094665ffd4c3160a23288c8c2ff0a455f736c8c4f774b7e2cf524e5" - }, - "atlaskind:atlas-kind-reference": { - "seedRevision": 1, - "fingerprint": "8ddd7806e05137eda5b4ed3bb52af907a2679e1184e069ad21eeacf52104cc89" - }, - "atlaskind:atlas-kind-topic": { - "seedRevision": 2, - "fingerprint": "325f0697cec657797baefab1f253e8e6203a71badbce734cf2f8b2580432a17e" - }, - "atlaslink:atlas-link-contact-to-document": { - "seedRevision": 1, - "fingerprint": "93fbc8aef570739859e999310dc4f6862d0141fc3899d8bafaa14ff219970dae" - }, - "atlaslink:atlas-link-getting-to-contact": { - "seedRevision": 1, - "fingerprint": "1bb44b5b7395f6d37dab829e4a7657dc61b012b7c7169efc52c922193eb2889a" - }, - "atlaslink:atlas-link-sync-to-store": { - "seedRevision": 1, - "fingerprint": "8b1293286837f7b9c6528bf0f25279b3a4acc021f34281478cac10703c66f231" - }, - "atlaslink:atlas-link-web-to-store": { - "seedRevision": 1, - "fingerprint": "5fb2388428d5a7436480d3811fe8cec1dd1126a5fac3e8ea95f1057933088b12" - }, - "atlaslink:atlas-link-web-to-sync": { - "seedRevision": 1, - "fingerprint": "c7e6f53b5fcf4d61cdb4594ae30f443b972641cb92e63bb5c979a9ebd5da897a" - }, - "atlaslinkkind:atlas-linkkind-relates-to": { - "seedRevision": 1, - "fingerprint": "5efd2d4f46ceb01ae229e997102fe11191367b9ee46ac03939ffe278d4dec379" - }, - "atlasperspective:atlas-perspective-current": { - "seedRevision": 1, - "fingerprint": "0da2062c14d7435ce318021dbd16fa829114d0d668df03d7f02bf6a235861b80" - }, - "atlasperspective:atlas-perspective-interim": { - "seedRevision": 1, - "fingerprint": "40781bca500aa24304fb503d00c619de84b9dcda9413c55e30c922d029521674" - }, - "atlasperspective:atlas-perspective-target": { - "seedRevision": 1, - "fingerprint": "0accb341af068cb4b92d7ae8246235015aa86a09392a0e2a804638500fbc7dc6" - }, - "decision:example-approve-decision": { - "seedRevision": 3, - "fingerprint": "b29aebfd8e13eeb833999fac0c970922830f2c61b514441c1edfa410b92b1e34" - }, - "decision:example-deny-decision": { - "seedRevision": 3, - "fingerprint": "aa54d1a9131fef88fa3276c604e24a845c9e3765e06dca979ecf4e5b3f24b604" - }, - "decision:example-manual-review-decision": { - "seedRevision": 3, - "fingerprint": "3ecb2ea4b17b61db87e7ab8f636991f8ff47df30c41597837233f070f8d5ea37" - }, - "execenv:example-safe-sandbox-execenv": { - "seedRevision": 1, - "fingerprint": "aa9a964c09faafbd8e7ebbeff3e75aa438878bdb9e1207c4dcd5c0bbba8de0bc" - }, - "list:example-country-codes-list": { - "seedRevision": 4, - "fingerprint": "e5f0f240a11b3186184fc845f0a5b43e0a866f354c90c6cb22286a3ca4a4b755" - }, - "list:example-task-tracker-list": { - "seedRevision": 1, - "fingerprint": "7dce628bee6d21080ce535a42affe766655281db28fa08eb7f24845bd25f8cb3" - }, - "mcpserver:example-reference-mcpserver": { - "seedRevision": 1, - "fingerprint": "039af69260ea3c243cabc1f58ef0fb40524dbd4743b5c9f19e8224d9b1796549" - }, - "request:example-apikey-httpbin": { - "seedRevision": 1, - "fingerprint": "78f0254ea9bf750793fcf49eb23979defa44ae9e5387d246d18d819dd78f986e" - }, - "request:example-bearer-httpbin": { - "seedRevision": 1, - "fingerprint": "ecfac51fff4f10c02f71beb592f143b8b34c400388728daa1593aa919f991bc2" - }, - "request:example-hmac-httpbin": { - "seedRevision": 2, - "fingerprint": "bdc7a35da096a1debf62358ec5c030423ffa71b7503857104ce8ca3d513e2ca1" - }, - "request:example-none-httpbin": { - "seedRevision": 1, - "fingerprint": "afbadde9cf95232f3a2521b7e40fd9fe6740e1dbae3df8a598c74beb26a584cf" - }, - "request:example-oauth1-postman-echo": { - "seedRevision": 1, - "fingerprint": "15ab86dd91d184008729bc181a80d7c3efe89572141b7c3d3455891bd4d8cb6a" - }, - "request:example-oauth2-spotify": { - "seedRevision": 1, - "fingerprint": "0c86b49dcaae77b60f050a7bb839aafc118f6af7b10897bc12657631c690359d" - }, - "request:example-queryparam-httpbin": { - "seedRevision": 1, - "fingerprint": "d9e35d3d3d3bcf5bbb3f11775b82255ca335462c3854b5f1dffa5012707a4f61" - }, - "workflow:backup-mill-data-workflow": { - "seedRevision": 1, - "fingerprint": "33182d6efa709f8e0e2ca012b2f1d90827afb85b1974a5e6b8af01b8827cab55" - }, - "workflow:clipboard-html-to-markdown-workflow": { - "seedRevision": 1, - "fingerprint": "acb12fc22f02f89de6038b85ace772147cb5f6e74b523b4f9f9ff7ffff350397" - }, - "workflow:example-ai-classify-branch-workflow": { - "seedRevision": 2, - "fingerprint": "251cb4a6272e3e7303a61a2cad8c988dca8c44edb324bef908c063eca2cc926c" - }, - "workflow:example-ai-summarize-workflow": { - "seedRevision": 2, - "fingerprint": "18645ae0dd50fb623432f102af0ac2e17d44007a9b19fbc71d34d52e0c7efe6e" - }, - "workflow:example-branch-to-decision-workflow": { - "seedRevision": 3, - "fingerprint": "a029c7c1d3ee9f7da229f81689b341d455a51a6dfe8efe488158c5ac137b2bc0" - }, - "workflow:example-card-create-link-workflow": { - "seedRevision": 1, - "fingerprint": "7b2ad643f36f99b161876ddf497a868473cf13b709575b59f4e24ac1e005ef5a" - }, - "workflow:example-card-intake-workflow": { - "seedRevision": 1, - "fingerprint": "73a0918515c0e87d051ca9d4d6f74c6f84547baa8aed005e326ff45d261fe004" - }, - "workflow:example-child-echo-workflow": { - "seedRevision": 2, - "fingerprint": "a8e0f1f1296edc30b6568515f6852adfb9c5fdc27ebc43c64acb0aa08efccef8" - }, - "workflow:example-clipboard-inspector-workflow": { - "seedRevision": 2, - "fingerprint": "c1dd4b14828d9a8957b6a667633d8597742adc69f0d2e4e735671da7ad4ed287" - }, - "workflow:example-codeexec-workflow": { - "seedRevision": 2, - "fingerprint": "885b37e75639c22e576436b7272754c86854bb0d896fffd677c43c61c1e13586" - }, - "workflow:example-decision-with-review-workflow": { - "seedRevision": 3, - "fingerprint": "ae0b9f6b15aa9d44fe854cdf78393de1e4c179292d3b757eebfb23117fc4d07c" - }, - "workflow:example-disabled-filesystem-watch-workflow": { - "seedRevision": 2, - "fingerprint": "03ac020114b1559111e84abde9ec0d33718b2e24dbaab7f1b2072e41fd3e6017" - }, - "workflow:example-disabled-schedule-workflow": { - "seedRevision": 2, - "fingerprint": "f1d5e2d5143c8942eeb222b82c9a6978269f0749b2613c19fddeb441898c52da" - }, - "workflow:example-filemove-workflow": { - "seedRevision": 1, - "fingerprint": "d835c0706e34b5eb10e1e35363856a861bb3c2652066cac30e88ae25af3dff06" - }, - "workflow:example-forward-approvals-workflow": { - "seedRevision": 2, - "fingerprint": "228b33ff965d175c2156ecce6c91a56ed0c3f3526e47fa818c310100777b981d" - }, - "workflow:example-guarded-http-workflow": { - "seedRevision": 2, - "fingerprint": "06ff189f9ff1362321c0187466a2336f34cb6a16217ee3c129900ab2da6e2b9f" - }, - "workflow:example-list-lookup-workflow": { - "seedRevision": 2, - "fingerprint": "bf671926b7026069f6dd72158696f2d3f6ed8328984859878581beb56fede134" - }, - "workflow:example-list-pinned-workflow": { - "seedRevision": 1, - "fingerprint": "3a54d24a83030bc221bf639791434b9cf7ec0c1386ad2fb877bcc0c9858fc708" - }, - "workflow:example-list-search-workflow": { - "seedRevision": 2, - "fingerprint": "d37857488ba5f230401d5fa5033b3c7837dcc59473b5ef1ac70c94a97536bdc1" - }, - "workflow:example-list-write-workflow": { - "seedRevision": 1, - "fingerprint": "4ed20ba60b7602a75b0bb7490f3a2780faab1be1c7906618f1c578408be174d3" - }, - "workflow:example-mcp-echo-workflow": { - "seedRevision": 2, - "fingerprint": "a24f6a0a19fa008c61e7f8baa641b1de67b9b80ad2a0cab642d9853af0de7666" - }, - "workflow:example-parent-workflow": { - "seedRevision": 2, - "fingerprint": "0b531dd2a145e37acc71cdb1da26365800aef467a905aacd440d70b4720a07b2" - }, - "workflow:example-review-workflow": { - "seedRevision": 2, - "fingerprint": "57aa0cc6f06c52b8160163bd7ae6c94fb05ba6eb3bd5a94ff2f4982fb3375fe2" - }, - "workflow:example-run-receipt-workflow": { - "seedRevision": 1, - "fingerprint": "44312916e5911567eb2d1811a77418ae9bf9195b38cb0102bdbd3d36fe11afc6" - }, - "workflow:example-saved-page-to-markdown-workflow": { - "seedRevision": 2, - "fingerprint": "3cd5e1bab8414c4d6ae353830b2a27cc9fa5f49747d8d540a752d955f1b162a8" - }, - "workflow:example-scratch-capture-workflow": { - "seedRevision": 2, - "fingerprint": "58474919963eb4da78947e7f46802b2c75d2c0e51f4e685b5c7e94774d00550c" - }, - "workflow:example-step-failure-workflow": { - "seedRevision": 1, - "fingerprint": "c4c6feedce67ba803cd59737c0ca5836cbca536dd79d591e94601c05a19aafdf" - }, - "workflow:load-sample-html-workflow": { - "seedRevision": 1, - "fingerprint": "add39815b73d840c233f266f01043b9cbec60ed37d04cba12a8306e85ce23214" - } - } - +{ + "aiprovider:example-local-ollama": { + "seedRevision": 1, + "fingerprint": "75218e8e0727e6cbf04afd8ff25f8a461ccfea9c77fdccfb06ac824333fe4100" + }, + "atlascard:atlas-card-data-store": { + "seedRevision": 1, + "fingerprint": "35bf876e76601acf2f0b9216a43b0270dbe764170e040b2d6f7c8ed57aa4db7d" + }, + "atlascard:atlas-card-example-area": { + "seedRevision": 5, + "fingerprint": "a6eb22e7ae842f08fcd0b6004d10a7dd33cd298f63790dd35cfd2b7608ced2e4" + }, + "atlascard:atlas-card-example-contact": { + "seedRevision": 4, + "fingerprint": "315a9a9df7d4670eaab81dffb8273ab6e31842d81963fc5cb9a67786f76a598f" + }, + "atlascard:atlas-card-example-document": { + "seedRevision": 5, + "fingerprint": "4dfe77f81c4c3d72f669047c444fb9683cc03982e08dde9f4fbaa6ed9c4e10c5" + }, + "atlascard:atlas-card-getting-started": { + "seedRevision": 5, + "fingerprint": "013b4878544094f8f8630cb1ff84a52acc2833a42a9b5bf14e0a142bb478c5cd" + }, + "atlascard:atlas-card-my-space": { + "seedRevision": 5, + "fingerprint": "b29d4770ec62fe881f0205380f5e684c181bdffd4b93dd220850184ed94117e7" + }, + "atlascard:atlas-card-scratchpad": { + "seedRevision": 5, + "fingerprint": "b0330a8234b5f5ccd7768520bfbe70e72e0561795dc024ea29a89feea1e093a1" + }, + "atlascard:atlas-card-sync-service": { + "seedRevision": 1, + "fingerprint": "0c5d5c30133975d03290d7522dc3d71146968e3f6fdf3e158493c81c2921a9a7" + }, + "atlascard:atlas-card-system-landscape": { + "seedRevision": 1, + "fingerprint": "b0779404a5d92a2b5591e367dad464f4d0020cae88f8de3924bb0b15185f0f68" + }, + "atlascard:atlas-card-web-app": { + "seedRevision": 1, + "fingerprint": "7c70a8d7935ba5a15f199c9d10a8f11faf2998746b26e488ca47cc5a2c29ddb7" + }, + "atlaskind:atlas-kind-component": { + "seedRevision": 1, + "fingerprint": "c0717ac09c75dcef29c2a06c08d0d4a6e60832bb9385db4eb327f0fb069205c1" + }, + "atlaskind:atlas-kind-contact": { + "seedRevision": 2, + "fingerprint": "b4a22502670484312692a4118167d8a4e7395a0d59b39f590683376288a3e732" + }, + "atlaskind:atlas-kind-document": { + "seedRevision": 2, + "fingerprint": "61d42bc082711822ae67493bc63b25f4045a5881ee703c0ea342b78991c9b76a" + }, + "atlaskind:atlas-kind-intake": { + "seedRevision": 2, + "fingerprint": "ec06f5c0e094665ffd4c3160a23288c8c2ff0a455f736c8c4f774b7e2cf524e5" + }, + "atlaskind:atlas-kind-reference": { + "seedRevision": 1, + "fingerprint": "8ddd7806e05137eda5b4ed3bb52af907a2679e1184e069ad21eeacf52104cc89" + }, + "atlaskind:atlas-kind-topic": { + "seedRevision": 2, + "fingerprint": "325f0697cec657797baefab1f253e8e6203a71badbce734cf2f8b2580432a17e" + }, + "atlaslink:atlas-link-contact-to-document": { + "seedRevision": 1, + "fingerprint": "93fbc8aef570739859e999310dc4f6862d0141fc3899d8bafaa14ff219970dae" + }, + "atlaslink:atlas-link-getting-to-contact": { + "seedRevision": 1, + "fingerprint": "1bb44b5b7395f6d37dab829e4a7657dc61b012b7c7169efc52c922193eb2889a" + }, + "atlaslink:atlas-link-sync-to-store": { + "seedRevision": 1, + "fingerprint": "8b1293286837f7b9c6528bf0f25279b3a4acc021f34281478cac10703c66f231" + }, + "atlaslink:atlas-link-web-to-store": { + "seedRevision": 1, + "fingerprint": "5fb2388428d5a7436480d3811fe8cec1dd1126a5fac3e8ea95f1057933088b12" + }, + "atlaslink:atlas-link-web-to-sync": { + "seedRevision": 1, + "fingerprint": "c7e6f53b5fcf4d61cdb4594ae30f443b972641cb92e63bb5c979a9ebd5da897a" + }, + "atlaslinkkind:atlas-linkkind-relates-to": { + "seedRevision": 1, + "fingerprint": "5efd2d4f46ceb01ae229e997102fe11191367b9ee46ac03939ffe278d4dec379" + }, + "atlasperspective:atlas-perspective-current": { + "seedRevision": 1, + "fingerprint": "0da2062c14d7435ce318021dbd16fa829114d0d668df03d7f02bf6a235861b80" + }, + "atlasperspective:atlas-perspective-interim": { + "seedRevision": 1, + "fingerprint": "40781bca500aa24304fb503d00c619de84b9dcda9413c55e30c922d029521674" + }, + "atlasperspective:atlas-perspective-target": { + "seedRevision": 1, + "fingerprint": "0accb341af068cb4b92d7ae8246235015aa86a09392a0e2a804638500fbc7dc6" + }, + "decision:example-approve-decision": { + "seedRevision": 3, + "fingerprint": "b29aebfd8e13eeb833999fac0c970922830f2c61b514441c1edfa410b92b1e34" + }, + "decision:example-deny-decision": { + "seedRevision": 3, + "fingerprint": "aa54d1a9131fef88fa3276c604e24a845c9e3765e06dca979ecf4e5b3f24b604" + }, + "decision:example-manual-review-decision": { + "seedRevision": 3, + "fingerprint": "3ecb2ea4b17b61db87e7ab8f636991f8ff47df30c41597837233f070f8d5ea37" + }, + "execenv:example-safe-sandbox-execenv": { + "seedRevision": 1, + "fingerprint": "aa9a964c09faafbd8e7ebbeff3e75aa438878bdb9e1207c4dcd5c0bbba8de0bc" + }, + "list:example-country-codes-list": { + "seedRevision": 4, + "fingerprint": "e5f0f240a11b3186184fc845f0a5b43e0a866f354c90c6cb22286a3ca4a4b755" + }, + "list:example-task-tracker-list": { + "seedRevision": 1, + "fingerprint": "7dce628bee6d21080ce535a42affe766655281db28fa08eb7f24845bd25f8cb3" + }, + "mcpserver:example-reference-mcpserver": { + "seedRevision": 1, + "fingerprint": "039af69260ea3c243cabc1f58ef0fb40524dbd4743b5c9f19e8224d9b1796549" + }, + "request:example-apikey-httpbin": { + "seedRevision": 1, + "fingerprint": "78f0254ea9bf750793fcf49eb23979defa44ae9e5387d246d18d819dd78f986e" + }, + "request:example-bearer-httpbin": { + "seedRevision": 1, + "fingerprint": "ecfac51fff4f10c02f71beb592f143b8b34c400388728daa1593aa919f991bc2" + }, + "request:example-hmac-httpbin": { + "seedRevision": 2, + "fingerprint": "bdc7a35da096a1debf62358ec5c030423ffa71b7503857104ce8ca3d513e2ca1" + }, + "request:example-none-httpbin": { + "seedRevision": 1, + "fingerprint": "afbadde9cf95232f3a2521b7e40fd9fe6740e1dbae3df8a598c74beb26a584cf" + }, + "request:example-oauth1-postman-echo": { + "seedRevision": 1, + "fingerprint": "15ab86dd91d184008729bc181a80d7c3efe89572141b7c3d3455891bd4d8cb6a" + }, + "request:example-oauth2-spotify": { + "seedRevision": 1, + "fingerprint": "0c86b49dcaae77b60f050a7bb839aafc118f6af7b10897bc12657631c690359d" + }, + "request:example-queryparam-httpbin": { + "seedRevision": 1, + "fingerprint": "d9e35d3d3d3bcf5bbb3f11775b82255ca335462c3854b5f1dffa5012707a4f61" + }, + "workflow:backup-mill-data-workflow": { + "seedRevision": 1, + "fingerprint": "33182d6efa709f8e0e2ca012b2f1d90827afb85b1974a5e6b8af01b8827cab55" + }, + "workflow:clipboard-html-to-markdown-workflow": { + "seedRevision": 1, + "fingerprint": "acb12fc22f02f89de6038b85ace772147cb5f6e74b523b4f9f9ff7ffff350397" + }, + "workflow:clipbridge-reply-cards-workflow": { + "seedRevision": 1, + "fingerprint": "290efbf6399960481af8b587544808ac9ba4d032f00889ccdeec886cd5f082c5" + }, + "workflow:clipbridge-reply-note-workflow": { + "seedRevision": 1, + "fingerprint": "0db57006c8193c338bcccfb23befd063b487367cc859a44069118d43e590433d" + }, + "workflow:example-ai-classify-branch-workflow": { + "seedRevision": 2, + "fingerprint": "251cb4a6272e3e7303a61a2cad8c988dca8c44edb324bef908c063eca2cc926c" + }, + "workflow:example-ai-summarize-workflow": { + "seedRevision": 2, + "fingerprint": "18645ae0dd50fb623432f102af0ac2e17d44007a9b19fbc71d34d52e0c7efe6e" + }, + "workflow:example-branch-to-decision-workflow": { + "seedRevision": 3, + "fingerprint": "a029c7c1d3ee9f7da229f81689b341d455a51a6dfe8efe488158c5ac137b2bc0" + }, + "workflow:example-card-create-link-workflow": { + "seedRevision": 1, + "fingerprint": "7b2ad643f36f99b161876ddf497a868473cf13b709575b59f4e24ac1e005ef5a" + }, + "workflow:example-card-intake-workflow": { + "seedRevision": 1, + "fingerprint": "73a0918515c0e87d051ca9d4d6f74c6f84547baa8aed005e326ff45d261fe004" + }, + "workflow:example-child-echo-workflow": { + "seedRevision": 2, + "fingerprint": "a8e0f1f1296edc30b6568515f6852adfb9c5fdc27ebc43c64acb0aa08efccef8" + }, + "workflow:example-clipboard-inspector-workflow": { + "seedRevision": 2, + "fingerprint": "c1dd4b14828d9a8957b6a667633d8597742adc69f0d2e4e735671da7ad4ed287" + }, + "workflow:example-codeexec-workflow": { + "seedRevision": 2, + "fingerprint": "885b37e75639c22e576436b7272754c86854bb0d896fffd677c43c61c1e13586" + }, + "workflow:example-decision-with-review-workflow": { + "seedRevision": 3, + "fingerprint": "ae0b9f6b15aa9d44fe854cdf78393de1e4c179292d3b757eebfb23117fc4d07c" + }, + "workflow:example-disabled-filesystem-watch-workflow": { + "seedRevision": 2, + "fingerprint": "03ac020114b1559111e84abde9ec0d33718b2e24dbaab7f1b2072e41fd3e6017" + }, + "workflow:example-disabled-schedule-workflow": { + "seedRevision": 2, + "fingerprint": "f1d5e2d5143c8942eeb222b82c9a6978269f0749b2613c19fddeb441898c52da" + }, + "workflow:example-filemove-workflow": { + "seedRevision": 1, + "fingerprint": "d835c0706e34b5eb10e1e35363856a861bb3c2652066cac30e88ae25af3dff06" + }, + "workflow:example-forward-approvals-workflow": { + "seedRevision": 2, + "fingerprint": "228b33ff965d175c2156ecce6c91a56ed0c3f3526e47fa818c310100777b981d" + }, + "workflow:example-guarded-http-workflow": { + "seedRevision": 2, + "fingerprint": "06ff189f9ff1362321c0187466a2336f34cb6a16217ee3c129900ab2da6e2b9f" + }, + "workflow:example-list-lookup-workflow": { + "seedRevision": 2, + "fingerprint": "bf671926b7026069f6dd72158696f2d3f6ed8328984859878581beb56fede134" + }, + "workflow:example-list-pinned-workflow": { + "seedRevision": 1, + "fingerprint": "3a54d24a83030bc221bf639791434b9cf7ec0c1386ad2fb877bcc0c9858fc708" + }, + "workflow:example-list-search-workflow": { + "seedRevision": 2, + "fingerprint": "d37857488ba5f230401d5fa5033b3c7837dcc59473b5ef1ac70c94a97536bdc1" + }, + "workflow:example-list-write-workflow": { + "seedRevision": 1, + "fingerprint": "4ed20ba60b7602a75b0bb7490f3a2780faab1be1c7906618f1c578408be174d3" + }, + "workflow:example-mcp-echo-workflow": { + "seedRevision": 2, + "fingerprint": "a24f6a0a19fa008c61e7f8baa641b1de67b9b80ad2a0cab642d9853af0de7666" + }, + "workflow:example-parent-workflow": { + "seedRevision": 2, + "fingerprint": "0b531dd2a145e37acc71cdb1da26365800aef467a905aacd440d70b4720a07b2" + }, + "workflow:example-review-workflow": { + "seedRevision": 2, + "fingerprint": "57aa0cc6f06c52b8160163bd7ae6c94fb05ba6eb3bd5a94ff2f4982fb3375fe2" + }, + "workflow:example-run-receipt-workflow": { + "seedRevision": 1, + "fingerprint": "44312916e5911567eb2d1811a77418ae9bf9195b38cb0102bdbd3d36fe11afc6" + }, + "workflow:example-saved-page-to-markdown-workflow": { + "seedRevision": 2, + "fingerprint": "3cd5e1bab8414c4d6ae353830b2a27cc9fa5f49747d8d540a752d955f1b162a8" + }, + "workflow:example-scratch-capture-workflow": { + "seedRevision": 2, + "fingerprint": "58474919963eb4da78947e7f46802b2c75d2c0e51f4e685b5c7e94774d00550c" + }, + "workflow:example-step-failure-workflow": { + "seedRevision": 1, + "fingerprint": "c4c6feedce67ba803cd59737c0ca5836cbca536dd79d591e94601c05a19aafdf" + }, + "workflow:load-sample-html-workflow": { + "seedRevision": 1, + "fingerprint": "add39815b73d840c233f266f01043b9cbec60ed37d04cba12a8306e85ce23214" + } +}