Commit Graph
359 Commits
Author SHA1 Message Date
Cursor AgentandTom Huang 729ef2ebb5 feat(plugins): od plugin validate <folder> author-side lint (Phase 4)
Plan W1 / spec §11.5 / §16 Phase 4.

apps/daemon/src/plugins/validate.ts ships the pre-install lint
helper:

  validatePluginFolder({ folder, registry?, connectorProbe? })
    \u2192 { ok, resolveErrors, resolveWarnings, doctor?, folder }

Reads the folder via the same resolvePluginFolder() the installer
uses (so manifest parsing is byte-equal), then runs doctorPlugin()
against the supplied registry view. Without a registry, ref-bound
checks skip cleanly (offline mode); with an empty/full registry,
unresolved skill / DS / atom refs surface as warnings.

  flattenValidationDiagnostics(result) \u2192 Diagnostic[]

Convenience flattener for renderers that don't want to special-case
resolve vs. doctor diagnostics.

apps/daemon/src/cli.ts `od plugin validate <folder>`:

  Usage: od plugin validate <folder> [--json] [--no-daemon] [--daemon-url <url>]

  Optional --no-daemon skips the registry fetch (works fully
  offline). When the daemon is reachable, the registry view is
  fetched from /api/skills + /api/design-systems + /api/atoms so
  ref checks happen against the operator's actual library.

Exit codes:
  0  doctor.ok = true
  4  doctor.ok = false (errors present)
  2  CLI usage error / folder unreadable

Help block in printPluginHelp() updated.

Daemon tests: 1666 \u2192 1674 (+8 cases on plugins-validate: clean
manifest passes, empty folder errors at resolve, malformed JSON
parse error, unknown atom id error, unparseable until expression
error, missing skill ref no-op without registry, missing skill ref
flagged with empty registry, flatten ordering).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 16:21:57 +00:00
Cursor AgentandTom Huang b7f0fc0d96 feat(daemon): flip OD_BUNDLED_ATOM_PROMPTS default to ON (Phase 4)
Plan V1 / spec §23.4.

The bundled SKILL.md fragment library now covers every Phase 6/7/8
atom impl shipped this changeset (build-test, code-import,
design-extract, figma-extract, token-map, rewrite-plan, patch-edit,
diff-review, handoff). The §3.M3-M5 audit-block on flipping the
default-active flag is therefore lifted.

The new policy:

  default:                      ON   (activeStageBlocks render)
  OD_BUNDLED_ATOM_PROMPTS unset ON
  OD_BUNDLED_ATOM_PROMPTS=''    ON
  OD_BUNDLED_ATOM_PROMPTS=1     ON
  OD_BUNDLED_ATOM_PROMPTS=0     OFF  (explicit opt-out for
                                      snapshot replay against
                                      pre-§3.V1 daemons + regression
                                      bisects that need byte-equal
                                      pre-flip prompts)
  OD_BUNDLED_ATOM_PROMPTS=*     ON   (forward-compat)

Effect: a run with a plugin snapshot whose pipeline carries any
of the bundled atoms now ALWAYS gets the matching SKILL.md fragments
spliced in as 'Active stage' blocks ahead of the rest of the prompt,
without operators having to set the env var. Runs without a
snapshot keep byte-equal prompts (the snapshotId guard short-
circuits the build).

Daemon tests: 1661 \u2192 1666 (+5 cases on
plugins-bundled-atom-prompts-default: ON when unset / empty / '1'
/ unknown values; OFF only when explicit '0').

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 16:15:31 +00:00
Cursor AgentandTom Huang 9b2ce2bbc4 feat(daemon): S3ProjectStorage live impl via AWS SigV4 (Phase 5)
Plan U1 / spec §15.6.

apps/daemon/src/storage/aws-sigv4.ts ships a minimal AWS Signature
V4 signer using only node:crypto. No `@aws-sdk/*` dep is pulled
in; the OD daemon ships without an extra ~60 MB of SDK code on
disk.

  signSigV4({ method, path, query, headers, body, region, service,
              credentials, now? }) \u2192 { authorization, amzDate,
                                          contentSha256 }

The signer mutates the supplied headers map by adding the four
required amz fields ('x-amz-date', 'x-amz-content-sha256',
optional 'x-amz-security-token', and the final 'authorization').
The 'now' override pins signatures for tests.

apps/daemon/src/storage/project-storage.ts S3ProjectStorage now
implements the five operations the ProjectStorage interface
declares — all five round-trip through pluggable fetch:

  readFile    \u2192 GET    /<key>
  writeFile   \u2192 PUT    /<key>          (with x-amz-content-sha256)
  deleteFile  \u2192 DELETE /<key>          (idempotent on 404)
  statFile    \u2192 HEAD   /<key>          (returns null on 404)
  listFiles   \u2192 GET    /?list-type=2&prefix=<projectId>/
                walks NextContinuationToken for paginated buckets.

Style:
  - Default to virtual-host-style endpoints (`<bucket>.s3.<region>.amazonaws.com`).
  - When OD_S3_ENDPOINT is set, switch to path-style (`<endpoint>/<bucket>/<key>`) for
    Aliyun OSS / Tencent COS / Huawei OBS / MinIO.

Credentials are read in this order:
  OD_S3_ACCESS_KEY_ID / OD_S3_SECRET_ACCESS_KEY / OD_S3_SESSION_TOKEN
  AWS_ACCESS_KEY_ID  / AWS_SECRET_ACCESS_KEY  / AWS_SESSION_TOKEN
\u2026so existing AWS toolchain setups (`aws configure` exporters,
IAM-role pods) drop in without renaming.

Daemon tests: 1650 \u2192 1661 (+11).

  +4 plugins-aws-sigv4 cases:
    - AWS-published reference vector (GetObject) hashes byte-equal.
    - List-bucket query stays canonical-sorted.
    - x-amz-security-token forwards through SignedHeaders.
    - encodeS3PathSegment handles unreserved / reserved chars.

  +7 storage S3 cases (replacing the prior throw-only stub):
    - PUT signs + reports ProjectFileMeta back.
    - GET returns body bytes; 404 \u2192 NOT_FOUND.
    - HEAD returns null on 404 + parses Content-Length / Last-Modified.
    - DELETE swallows 404, rejects 500.
    - LIST parses ListBucketV2 XML + walks NextContinuationToken.
    - endpoint override switches to path-style.
    - resolveProjectStorage falls back to AWS_* env vars when OD_*
      knobs are unset.

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 16:10:05 +00:00
Cursor AgentandTom Huang 023a9eb56f test(plugins): figma-migration pipeline e2e smoke (Phase 6 entry slice)
Plan T2 / spec §1 / §10 / §21.3.1.

apps/daemon/tests/plugins-figma-migration-e2e.test.ts walks every
Phase 6 atom impl in sequence on a stubbed Figma REST response:

  figma-extract \u2192 figma/{tree.json, tokens.json, meta.json}
                    via injected fetch stub (no network).
  token-map     \u2192 token-map/colors.json crosswalking the lifted
                    SOLID fills onto the active design system.
  diff-review   \u2192 review/decision.json with explicit decision.
  handoff       \u2192 handoff/manifest.json via runAndPersistHandoff()
                    with the right handoffKind from the promotion
                    ladder.

Three cases:

  1. accept happy path \u2192 implementation-plan rung; figma export
     target lands on manifest.exportTargets[].
  2. reject path \u2192 design-only rung even after figma-extract +
     token-map ran successfully.
  3. Bundled scenario folder roster lock — confirms
     plugins/_official/scenarios/od-figma-migration/open-design.json
     ships the canonical stage list (extract \u2192 tokens \u2192 generate
     \u2192 critique) so the §3.O1 fallback resolver always has the
     same target shape to copy.

Daemon tests: 1647 \u2192 1650 (+3 cases on plugins-figma-migration-e2e:
accept, reject, scenario roster).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 16:02:40 +00:00
Cursor AgentandTom Huang 97c3b77f51 feat(plugins): runAndPersistHandoff() + auto-handoff from diff-review bridge
Plan T1 / spec §11.5.1 / §21.5.

apps/daemon/src/plugins/atoms/handoff.ts gains the on-disk shell
runAndPersistHandoff({ cwd, manifestSeed?, exportTarget?, ... })
that round-trips `<cwd>/handoff/manifest.json`:

  1. Read existing manifest (if any) so promotions stay monotonic.
     Fall back to manifestSeed; final fallback is a minimal default.
  2. Call runHandoffAtom() with the chosen seed + caller's targets.
  3. Write the updated manifest back ONLY when something changed.

persistMode in the result distinguishes 'created' / 'updated' /
'skipped' so callers can decide whether to broadcast an event.

The diff-review GenUI bridge (R1) now auto-invokes this helper
after recording the user's decision. The /api/runs/:id/genui/...
respond endpoint therefore writes BOTH:

  <cwd>/review/decision.json     (R1)
  <cwd>/handoff/manifest.json    (T1 — auto-promotes from the new
                                  decision + any prior build-test
                                  signals)

The handoff bridge is best-effort: a failure surfaces on the
ApplyDiffReviewDecisionResult as `handoffError` without failing
the diff-review write.

Phase 8 promotion ladder now closes end-to-end without an agent
turn:

  user clicks 'Accept all' in the web composer's diff-review
   surface
   \u2192 POST /api/runs/:id/genui/__auto_diff_review_<stage>/respond
   \u2192 daemon writes review/decision.json
   \u2192 daemon writes handoff/manifest.json with handoffKind set
     according to the reject/accept + build-test ladder
   \u2192 follow-up od plugin export reads handoff/manifest.json and
     ships the artefact with the right provenance fields.

Daemon tests: 1641 \u2192 1647 (+6 cases on plugins-handoff-persist:
created from seed, round-trips existing manifest with monotonic
promotion, no-op skip on second run, deployable-app rung with
docker export, diff-review bridge auto-creates handoff/manifest.json
on accept, reject auto-stamps design-only, build-test signals
forward through the bridge into 'patch' tier).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 16:00:50 +00:00
Cursor AgentandTom Huang 999c6cd68a test(plugins): full code-migration pipeline e2e smoke test (Phase 7-8)
Plan S2 / spec §1 / §10 / §20.3 / §21.3.2.

apps/daemon/tests/plugins-code-migration-e2e.test.ts exercises
every Phase 6/7/8 atom impl in sequence on a Next.js fixture repo,
proving the contract chain works end-to-end without any agent /
LLM in the loop:

  code-import     \u2192 code/index.json
  design-extract  \u2192 code/tokens.json (hex colour lifted)
  token-map       \u2192 token-map/colors.json (mapped to design system)
  rewrite-plan    \u2192 plan/{plan.md, ownership.json, steps.json,
                       meta.json}
  patch-edit      \u2192 components/Button.tsx mutated via unified diff;
                       plan/receipts/step-rewrite-button.json written
  build-test      \u2192 critique/build-test.json (no-op commands so we
                       don't shell out to a real toolchain in CI)
  diff-review     \u2192 review/{diff.patch, summary.md, decision.json,
                       meta.json}
  handoff         \u2192 ArtifactManifest.handoffKind='deployable-app'
                       (accept + both signals + cli exportTarget)

Also adds a second case covering the reject ladder rung
(reject + missing build-test \u2192 design-only).

This is the first integration test that walks every code-migration
atom in one go; it locks the inter-atom file contract so a future
PR can't break the chain by, say, renaming code/tokens.json or
adding a required field to plan/steps.json without updating every
downstream reader.

Daemon tests: 1639 \u2192 1641 (+2 cases on
plugins-code-migration-e2e: full chain ending on
deployable-app, reject \u2192 design-only).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 15:55:57 +00:00
Cursor AgentandTom Huang 6fa42ca159 feat(plugins): runHandoffAtom() pipeline-driven bridge (Phase 8 entry slice)
Plan S1 / spec §11.5.1 / §21.5.

apps/daemon/src/plugins/atoms/handoff.ts gains the on-disk bridge
runHandoffAtom({ cwd, manifest, exportTarget?, deployTarget? })
that reads the canonical state previous atoms wrote and returns
the updated manifest with the right handoffKind / exportTargets[]
attached.

Promotion ladder (spec §11.5.1):

  decision='reject'                                       → 'design-only'
  decision='accept'/'partial' AND no build-test report    → 'implementation-plan'
  + build.passing OR tests.passing                        → 'patch'
  + build.passing AND tests.passing AND docker/cli export → 'deployable-app'

Inputs read from cwd:

  <cwd>/review/decision.json       — diff-review atom output
  <cwd>/critique/build-test.json   — build-test atom signals

Both files are optional; missing → the bridge skips the matching
ladder rung. Monotonicity enforced via recordHandoff() — a manifest
that already carries handoffKind='patch' won't demote to
'design-only' even if a follow-up reject arrives (the reject path
documents the rollback escape hatch via enforceMonotonicHandoff:
false).

Returns:

  { manifest, changed, signals: {
      decision?, buildPassing?, testsPassing?, deployable
  } }

Caller (the pipeline runner / od plugin export / docker tools-pack
hook) is responsible for persisting the updated manifest. Calls
are idempotent: re-running across already-recorded targets is a
no-op via the recordHandoff() append-only contract.

Daemon tests: 1629 → 1639 (+10 cases on plugins-handoff-pipeline:
all four ladder rungs (reject → design-only, accept-noBT →
implementation-plan, accept+oneSignal → patch, accept+bothSignals
+docker/cli → deployable-app), partial decision behaves like
accept, missing decision file leaves manifest alone, append-only
exportTargets[] dedupe across re-runs, refuses to demote across
the monotonic invariant, signals forwarding).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 15:53:30 +00:00
Cursor AgentandTom Huang a1bfac34f6 feat(plugins): wire diff-review GenUI response \u2192 review/decision.json (Phase 8)
Plan R1 / spec §10.3 / §21.5.

When the user (or agent) responds to the auto-derived
`__auto_diff_review_<stageId>` choice surface, the daemon now
immediately persists the decision into the run's project cwd as
`<cwd>/review/decision.json` so the next pipeline stage (handoff,
typically) sees the user's choice without an extra agent turn.

apps/daemon/src/plugins/atoms/diff-review-genui-bridge.ts

  - isDiffReviewSurfaceId(id)            — owns the
    '__auto_diff_review_' prefix; future renames flow from one
    constant.
  - parseDiffReviewGenuiResponse(value)  — strict JSON validator
    coercing the surface payload into runDiffReview's
    { decision, accepted_files?, rejected_files?, reason? }
    shape. Rejects non-object payloads + unknown decisions.
  - applyDiffReviewDecisionToCwd({ cwd, value, reviewer })
    — end-to-end glue: parses, calls runDiffReview(), returns
    structured ok/error so the caller can decide what to surface.

apps/daemon/src/server.ts POST /api/runs/:runId/genui/:surfaceId/respond
now becomes async and, when isDiffReviewSurfaceId() matches, looks
up the run's projectId via design.runs.get(), resolves the project
cwd via resolveProjectDir(), and calls
applyDiffReviewDecisionToCwd() with reviewer='user' (or 'agent' /
'auto' when respondedBy says so).

Best-effort wiring:
  - Failures are caught and surfaced on the response payload as
    `diffReviewBridge: { ok: false, error }` so the agent can
    retry without the GenUI respond contract regressing.
  - No-project runs return diffReviewBridge: { ok: false, error:
    'run is not linked to a project' }.

Daemon tests: 1618 → 1629 (+11 cases on
plugins-diff-review-genui-bridge: prefix detection, payload
parsing happy + sad paths (non-object, malformed decision,
non-string file lists, optional reason forwarding), end-to-end
applyDiffReviewDecisionToCwd writes review/decision.json on
accept, agent reviewer + reason forwarding, partial-missing-file
rejection, malformed value does NOT touch disk).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 15:48:21 +00:00
Cursor AgentandTom Huang d408d98a4c feat(plugins): figma-extract asset rasterisation second pass (Phase 6 entry slice)
Plan Q2 / spec §10.3.1 / §21.3.1.

apps/daemon/src/plugins/atoms/figma-extract.ts now honours
`offlineAssets: false` end-to-end. After the GET /v1/files pass,
the runner picks visible non-text leaf nodes that have a bounding
box, calls Figma's `GET /v1/images/<key>?ids=...&format=svg`
in 50-id chunks, and downloads each returned URL into
`<cwd>/figma/assets/<safe-id>.<ext>`.

Knobs:

  - `assetFormat`:    'svg' (default) | 'png' | 'jpg' | 'pdf'
  - `assetMaxBytes`:  per-asset cap, default 5 MiB
  - `offlineAssets`:  default true (preserves prior behaviour)

Per-id failures DO NOT abort the run — every issue lands in
`meta.unsupportedNodes` with reason='asset-too-large' / 'no
download URL returned' / 'download <status> <statusText>' / 'image
fetch error: <msg>' / 'figma error: <msg>'. The atom continues
through the remaining ids so a single CDN hiccup doesn't lose the
whole batch.

Filename safety: ids like '3:2' are passed through unchanged
(SQLite + JSON friendly); only chars outside [A-Za-z0-9_:-] are
collapsed to '-'.

atomDigest is re-computed after the asset pass settles so the
digest reflects which assets succeeded, even though the raw
bytes themselves are not part of the digest input (we hash the
issues list shape, not blobs).

Daemon tests: 1614 → 1618 (+4 cases on plugins-figma-extract:
multi-id download success, per-id null-URL surfaces issue without
aborting, assetMaxBytes skip + reason capture, offlineAssets=true
default keeps assets/ empty).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 15:40:13 +00:00
Cursor AgentandTom Huang 85beeb58c9 feat(web): native diff-review UI on GenUISurfaceRenderer (Phase 8 entry slice)
Plan Q1 / spec §21.5.

apps/web/src/components/GenUISurfaceRenderer.tsx now ships first-
class branches for the auto-derived diff-review choice surface and
generic single-enum-property choice surfaces.

Diff-review (DiffReviewChoiceSurface):
  - Three top-level buttons: 'Accept all' / 'Reject all' / 'Partial…'.
  - Optional 'Skip' when the host supplies onSkip.
  - Optional notes textarea — forwarded as decision.reason when non-empty.
  - On 'Accept all': submits { decision: 'accept', accepted_files,
    rejected_files: [] } using the touched file list from
    pending.context.touchedFiles. Daemon side default-fills when the
    list is empty.
  - On 'Reject all': symmetric.
  - On 'Partial…': reveals a per-file accept/reject toggle for each
    touched file. Submit refuses locally when ANY file is left
    undecided (mirrors the daemon's 'partial must cover every
    touched file' contract from §3.O5 so the user doesn't ping the
    server with an obviously-invalid payload).
  - Disabled when context.touchedFiles is empty (the daemon's
    default-fill path doesn't help with a partial decision).

Generic choice (GenericChoiceSurface):
  - Detects schemas of shape `{ properties: { <key>: { enum: [...] } } }`
    and renders one button per enum value. Property literally named
    'decision' wins over other enum properties when several are
    declared (so plugin-author-customised diff-review schemas keep
    rendering as accept/reject/partial buttons even if they add
    extra fields).

PendingSurface gains an optional `context: { touchedFiles?: [] }`
field. Future runtime-context entries plug in here without bloating
the GenUISurfaceSpec contract.

Web tests: 586 → 593 (+7 cases on
GenUISurfaceRenderer.diff-review: accept-all default-fill, reject-
all default-fill, partial union, partial blocks on undecided file,
partial disabled when context absent, optional reason forwarding,
generic single-enum choice button group).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 15:36:38 +00:00
Cursor AgentandTom Huang c65f9ccf7a feat(plugins): auto-derive choice surface for diff-review (Phase 8 entry slice)
Plan P3 / spec §10.3.1 / §21.5.

Mirrors the connector-gate's auto oauth-prompt pattern: when the
EFFECTIVE pipeline (consumer-declared OR scenario-fallback) contains
a stage whose atoms[] list `diff-review`, applyPlugin() now
synthesises an implicit `choice` GenUI surface so the user can
accept / reject / partial without the plugin author having to
declare the surface by hand.

apps/daemon/src/plugins/atoms/auto-surfaces.ts:

  deriveAutoAtomSurfaces({ pipeline }) → GenUISurfaceSpec[]

  For each stage that lists 'diff-review', emits one surface:

    id:      '__auto_diff_review_<stageId>'
    kind:    'choice'
    persist: 'run'                  (per-run; user sees one prompt
                                     per cycle through the stage)
    trigger: { stageId, atom: 'diff-review' }
    schema:  { decision: ['accept' | 'reject' | 'partial'],
               accepted_files: string[],
               rejected_files: string[],
               reason: string }
             required: ['decision']
    timeout:    24 h (a diff review may sit overnight)
    onTimeout:  'abort'

apply.ts integration:

  - Auto-surface derivation moved AFTER the bundled-scenario
    fallback resolution (P3 / O1) so a consumer that inherits
    the bundled scenario's diff-review stage still gets the
    auto-surface.
  - Plugin-author-declared surface with the same id wins, via
    the existing mergeAutoOAuthPrompts dedupe (re-used so we
    don't duplicate dedupe logic).

Daemon tests: 1608 → 1614 (+6 cases on plugins-auto-surfaces:
multi-stage diff-review surface fan-out, no-pipeline returns
empty, no-diff-review returns empty, plugin-declared pipeline
integration, scenario-fallback pipeline integration, plugin-
author-declared surface with same id wins).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 15:30:45 +00:00
Cursor AgentandTom Huang 8ecc7cbdb9 feat(plugins): figma-extract atom impl (Phase 6 entry slice)
Plan P2 / spec §10 / §21.3.1.

apps/daemon/src/plugins/atoms/figma-extract.ts ships the daemon-side
Figma REST shell-out behind the SKILL.md fragment landed in §3.M3.
Given a Figma file URL (or pre-extracted fileKey) + an OAuth token
the runner produces the spec §21.3.1 on-disk snapshot:

  <cwd>/figma/tree.json    flat node tree { id / name / type /
                           parent / children / box / fill / stroke /
                           cornerRadius / text / componentRef }
  <cwd>/figma/tokens.json  design-extract-shaped token bag so
                           token-map can crosswalk figma + code
                           sources through one helper
  <cwd>/figma/meta.json    fileUrl + fileKey + version +
                           lastModified + exportedAt + atomDigest +
                           unsupportedNodes[] + nodeCount

Public surface:

  runFigmaExtract({ cwd, fileUrl?, fileKey?, token, fetchFn?,
                    offlineAssets? })
    → FigmaExtractReport

URL parser accepts both legacy /file/<KEY> and /design/<KEY>
formats. fileKey supplied directly bypasses URL parsing.

Network is pluggable: tests pass a stub via fetchFn; production
defaults to globalThis.fetch. The OAuth token is forwarded as
Authorization: Bearer <t> and never persisted by the runner — the
daemon's connector-gate (spec §10.3.1 implicit oauth-prompt)
remains the only place that touches the token store.

Token lift heuristics (spec §21.3.1 anti-pattern: 'never make up
node ids'):
  - SOLID fills + strokes → colors[] with #RRGGBB(AA)
  - cornerRadius           → radius[] (e.g. 12px)
  - FRAME / GROUP heights  → spacing[] candidates
  - GRADIENT / IMAGE fills → meta.unsupportedNodes[] with reason
                             so the human can audit gaps
  - Component instances    → componentRef so token-map can
                             de-duplicate at the right boundary

#RRGGBBAA emitted only when alpha < 1.

Daemon tests: 1600 → 1608 (+8 cases on plugins-figma-extract:
flat-tree walk + parent/box/fill capture, multi-kind token lift
(color + radius + spacing), persisted on-disk layout + meta digest,
gradient fill recorded in unsupportedNodes, fileKey override skips
URL parsing + bearer header forwarded, missing fileKey error,
missing token error, non-2xx surfaces upstream status text).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 15:27:30 +00:00
Cursor AgentandTom Huang bda5c0e3ed feat(plugins): token-map atom impl (Phase 6/7 entry slice)
Plan P1 / spec §10 / §21.3.1.

apps/daemon/src/plugins/atoms/token-map.ts ships the daemon-side
crosswalk behind the SKILL.md fragment landed in §3.M3. Given a
project cwd that already has a token bag (`code/tokens.json` from
design-extract OR `figma/tokens.json` from figma-extract) plus the
active design system's token vocabulary, the runner produces:

  <cwd>/token-map/colors.json
  <cwd>/token-map/typography.json
  <cwd>/token-map/spacing.json
  <cwd>/token-map/radius.json
  <cwd>/token-map/shadow.json
  <cwd>/token-map/unmatched.json     [{ source, sourceName?, kind, reason, hint? }]
  <cwd>/token-map/meta.json          { sourceKind, generatedAt,
                                       atomDigest, designSystemId?,
                                       targetTokenCount,
                                       sourceTokenCount,
                                       matchedTokenCount }

Match strategies (deterministic, first-win):

  1. Exact value match  (#5b8def === #5b8def).
  2. Normalised hex     (#abc → #aabbcc, ignores case).
  3. Fuzzy name         (--color-primary → --ds-color-primary; strips
                         leading -- / ds- / odds- / theme- prefixes
                         and lower-cases).

Anything else lands unmatched[] with one of:

  - 'no-target-equivalent'  no target with same value/name
  - 'target-collision'      multiple sources mapped to one target
                            (first wins; subsequent listed unmatched
                             with hint pointing at the first claimant)
  - 'invalid-source'        malformed source value

Strict mode aborts on any unmatched; soft mode (default) populates
unmatched.json + continues so the human can audit.

Helper:

  parseDesignSystemTokens(body) → DesignSystemToken[]

  Heuristic DESIGN.md token extraction. Lifts CSS custom properties
  (`--ds-color-fg: #111`) and markdown table rows that look like
  token declarations. Daemon callers may pass a hand-curated bag
  instead of relying on this parser.

Existing patch-edit typecheck noise fixed in passing:
  - PatchStepRecord now extends Omit<RewriteStep, 'rationale'> so
    optional override is sound under exactOptionalPropertyTypes.
  - rationale assignment guarded against undefined.

Daemon tests: 1588 → 1600 (+12 cases on plugins-token-map: exact
match, normalised hex, fuzzy name, no-target-equivalent, target-
collision, strict mode abort, multi-kind crosswalk, code+figma
disk inputs round-trip + bucket persistence + missing-input error,
DESIGN.md CSS custom property + markdown table parsers).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 15:24:22 +00:00
Cursor AgentandTom Huang 4687f9c060 feat(plugins): diff-review atom impl (Phase 7-8 entry slice)
Plan O5 / spec §20.3 / §21.3.2.

apps/daemon/src/plugins/atoms/diff-review.ts ships the daemon-side
impl behind the SKILL.md fragment landed in §3.M4. The runner walks
plan/receipts/ + plan/steps.json and emits the four files the
SKILL.md fragment promises:

  <cwd>/review/diff.patch     receipt-derived patch concat
  <cwd>/review/summary.md     per-step walkthrough w/ +/- stats
  <cwd>/review/decision.json  accept / reject / partial
  <cwd>/review/meta.json      generatedAt + atomDigest +
                              planRevision

Public surface:

  runDiffReview({ cwd, decision? }) → DiffReviewReport
    { files[], added, removed, receipts[], decision?, meta }

Decision composition rules (spec §20.3):
  - 'accept'  → accepted_files defaults to every touched file;
                rejected_files defaults to [].
  - 'reject'  → rejected_files defaults to every touched file;
                accepted_files defaults to [].
  - 'partial' → MUST cover every touched file via the union of
                accepted_files + rejected_files; otherwise the
                runner throws 'missing <file>' so the GenUI surface
                can re-prompt.

Round trip semantics: when decision is omitted, the runner reads
review/decision.json from disk if it exists and returns it on the
report so a subsequent stage can inspect the user's prior choice
without re-prompting.

The diff.patch artefact is intentionally a receipt-derived summary
(file list + added/removed counts + rationale comments). Precise
hunks live in plan/receipts/<id>.json — the SKILL.md fragment
spells this contract out so the agent doesn't claim diff.patch is
git-apply-shaped.

Daemon tests: 1581 → 1588 (+7 cases on plugins-diff-review:
artefact emission from receipts, aggregation + dedupe across multi-
file receipts, accept default-fills-all, reject default-fills-all,
partial requires full coverage, decision round-trip from disk,
empty-receipts dir non-crash).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 15:16:53 +00:00
Cursor AgentandTom Huang 561da49019 feat(plugins): patch-edit atom impl (Phase 7 entry slice)
Plan O4 / spec §20.3 / §21.3.2.

apps/daemon/src/plugins/atoms/patch-edit.ts ships the daemon-side
unified-diff applier behind the SKILL.md fragment landed in §3.M4.

Public surface:

  applyPatchForStep({ cwd, stepId, diff, rationale?, allowOutOfStepCreation? })
    → { status: 'completed' | 'failed' | 'skipped',
        filesTouched, added, removed, reason? }

  skipStep({ cwd, stepId, rationale })   — explicit skip path
  readPlanProgress(cwd)                  — { total, terminal } for
                                            the pipeline runner's
                                            until expression
                                            'plan.steps.terminal === plan.steps.total'

Diff parsing supports:

  - plain edits with @@ headers and ' '/-/+ body lines
  - file creation via '--- /dev/null' + '+++ b/<path>'
  - file deletion via '--- a/<path>' + '+++ /dev/null'
  - multiple file hunks in one diff (split on '--- ')

Safety contract enforced before any file is written:

  1. Path traversal guard ('..', leading '/').
  2. Every touched file MUST be in step.files[]. Out-of-step file
     creation requires allowOutOfStepCreation=true (test harness
     escape hatch).
  3. Touching a file classified as 'shell' tier in
     plan/ownership.json requires step.risk='high'. Spec §11.5.1
     boundary: layout.tsx / theme.ts / globals.css / providers.tsx
     can't be edited under risk='low'/'medium'.
  4. Context lines must match exactly; stale patches surface
     'context mismatch at line N' WITHOUT mutating disk.

After a successful apply, the runner:

  - sets plan/steps.json[id].status='completed' + completedAt
  - writes plan/receipts/step-<id>.json with files[], added, removed,
    rationale, completedAt

skipStep() and 'already in terminal state' returns are idempotent
so the pipeline runner can safely re-call after a crash.

Daemon tests: 1571 → 1581 (+10 cases on plugins-patch-edit:
single-file edit + receipt persistence, /dev/null file creation,
/dev/null file deletion, off-step file rejection, shell-tier risk
gate, path-traversal rejection, stale-patch context mismatch
rollback, idempotent terminal-state return, skipStep + receipt,
readPlanProgress aggregate).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 15:14:09 +00:00
Cursor AgentandTom Huang f2b62c0303 feat(plugins): rewrite-plan atom impl (Phase 7 entry slice)
Plan O3 / spec §20.3 / §21.3.2.

apps/daemon/src/plugins/atoms/rewrite-plan.ts produces the four
files the SKILL.md fragment promises:

  <cwd>/plan/plan.md          — narrative (heuristic baseline; LLM
                                step overwrites)
  <cwd>/plan/ownership.json   — { file, layer } per file across the
                                imported repo, sorted lexicographically
  <cwd>/plan/steps.json       — ordered { id, files[], rationale, risk }
  <cwd>/plan/meta.json        — generatedAt + atomDigest +
                                tokenMapDigest + intent

Ownership classifier (spec §11.5.1 / §20.3 tiers):

  shell  layout.tsx / _app.tsx / _document.tsx / providers.tsx /
         theme.ts / globals.css / tokens.css / design-tokens.css
  route  app/.../page.tsx, pages/index.tsx, route.ts entries
  shared hooks/ lib/ utils/ providers/ context/ store/ stores/
         services/ api/ shared/ common/, plus repo-root config files
  leaf   everything else (default)

The classifier keeps shell rare so patch-edit's safety gate doesn't
lock the agent out of plain component edits.

Step generator:
  - tokens-alignment   (low risk) when design-extract surfaced
                       inline literals; files[] is the leaf set
                       carrying those literals.
  - rewrite-<slug>     (low risk) one per leaf component file;
                       sibling stylesheets (Button.tsx + Button.css)
                       fold into the same step.
  - shared-and-route-touchups (medium risk) when shared/route files
                       exist, marks the cross-cutting boundary.
  - build-test         (low risk) closes every plan; rewrite-plan
                       refuses to drop this step.

atomDigest is over a canonicalised view of code/index.json so re-walks
that don't change the file roster don't invalidate the plan; intent is
not in the digest. tokenMapDigest is over code/tokens.json or 'none'.

Daemon tests: 1564 → 1571 (+7 cases on plugins-rewrite-plan: tier
classification across leaf/shared/route/shell, tokens-alignment
emission, per-leaf rewrite step + sibling stylesheet bundling,
build-test always last, persisted file layout, digest stability
across runs, missing code/index.json error path).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 15:10:47 +00:00
Cursor AgentandTom Huang c17d4ab952 feat(plugins): design-extract atom impl (Phase 6/7 entry slice)
Plan O2 / spec §10 / §21.3.2.

apps/daemon/src/plugins/atoms/design-extract.ts ships the
daemon-side implementation behind the SKILL.md fragment landed in
§3.M4 / §3.N. Given a project cwd that already has
`code/index.json` (from code-import) and the imported repo path,
the runner walks every scannable file (css/scss/ts/tsx/js/jsx/
html/json) and extracts:

  colors      — hex (#abc / #aabbcc / #aabbccdd), rgba(), hsla(),
                CSS custom properties (--*-color / --*-bg / etc),
                Tailwind config quoted hex palette entries.
  typography  — font-family declarations.
  spacing     — px / rem / em values on padding/margin/gap/inset/
                top/left/right/bottom.
  radius      — border-radius declarations.
  shadow      — box-shadow declarations.

Each token is deduped by canonical value and carries:
  - sources[]: '${path}:${line}' entries (audit trail token-map
    references).
  - usage[]:   bare basenames so a designer can spot 'this colour
    is referenced from Header.tsx + Footer.tsx + button.css'.
  - name?:     populated for CSS custom properties; absent for
    inline literals.

The pass is heuristic by design: false negatives are preferable to
false positives because token-map asks the human to confirm each
match. Files larger than 256 KiB are skipped (default; configurable)
to keep the regex pass bounded on bundled output.

Output: `<cwd>/code/tokens.json` — the exact shape the SKILL.md
fragment promises.

Daemon tests: 1556 → 1564 (+8 cases on plugins-design-extract:
hex/rgba/CSS-variable colour extraction, font-family capture,
spacing extraction across px+rem, border-radius + box-shadow,
Tailwind config quoted hex palette, persisted JSON layout, missing
code/index.json error path, empty-bag round trip).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 15:08:11 +00:00
Cursor AgentandTom Huang 694289295a feat(plugins): wire bundled-scenario pipeline fallback through apply (spec §23.3.3)
Plan O1 / spec §23.3.3.

When a plugin omits `od.pipeline`, applyPlugin() now consults the
bundled scenario plugins registered by the daemon's bundled boot
walker (apps/daemon/src/plugins/bundled.ts) and copies the matching
scenario's pipeline verbatim into the snapshot. Match key:
`od.taskKind` (defaulting to 'new-generation' when absent).

Pure-side (packages/plugin-runtime):

  - new resolveAppliedPipeline({ manifest, scenarios }) →
      { pipeline, source: 'declared' | 'scenario' | 'none', scenarioId? }
  - RegistryView gains optional `scenarios?: ScenarioRegistryEntry[]`
    so the daemon registry view propagates the lookup table without
    coupling the pure resolver to SQLite.

Daemon-side (apps/daemon):

  - applyPlugin() calls resolveAppliedPipeline and stores the result on
    BOTH `ApplyResult.pipeline` and `AppliedPluginSnapshot.pipeline`
    so a replay reproduces the same stages without re-consulting the
    scenario registry.
  - server.loadPluginRegistryView() now collects bundled scenarios
    from `installed_plugins` (source_kind='bundled', od.kind='scenario',
    non-empty pipeline). Third-party scenarios are intentionally NOT
    eligible — only rows seeded by the bundled boot walker.

Invariants preserved:
  - Apply stays pure: scenarios are read once at the registry-view
    construction site and passed in; apply never opens a DB itself.
  - Scenario plugins themselves never fall back to themselves
    (resolveAppliedPipeline returns 'none' when od.kind='scenario').
  - manifestSourceDigest is unaffected by the fallback (digest is over
    manifest + inputs + resolvedContextRefs); replay invariance held.

Tests: +15 (plugin-runtime: 7 cases on resolveAppliedPipeline;
daemon: 8 cases on plugins-scenario-fallback covering declared
pipeline win, scenario fallback by taskKind, default to
new-generation, scenario plugins don't recurse, no scenarios →
pipeline=undefined, manifestSourceDigest stability across applies,
DB collector round-trip).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 15:05:54 +00:00
Cursor AgentandTom Huang cfdc79f859 feat(plugins): bundled scenario plugins (spec §23.3.3)
Plan N4 / spec §10.1 / §23.3.3.

Lands one bundled `od.kind: 'scenario'` plugin per taskKind enum
value under plugins/_official/scenarios/. The daemon's bundled
boot walker (§3.I3) already registers them via the existing
'tier/<id>' layout, so no changes to bundled.ts.

Four scenarios:

  od-new-generation  discovery → plan → generate → critique (devloop)
  od-figma-migration extract  → tokens → generate → critique (devloop)
                     declares od.connectors.required: [{id:'figma'}]
                     so the daemon auto-derives the figma-oauth
                     surface at apply time
  od-code-migration  import   → tokens → plan
                     → verify (patch-edit ↔ build-test devloop, until
                       'build.passing && tests.passing || iterations>=8')
                     → review → handoff
  od-tune-collab     direction → patch → critique (devloop) → handoff

Locks the spec §23.3.3 patch 3 contract: 'no od.pipeline + has
taskKind → look up the bundled scenario plugin'. The daemon-side
resolver wiring is the next PR; the substrate is in place so a
plugin author who omits the pipeline gets a deterministic default.

Daemon tests: 1543 → 1548 (+5 cases on
plugins-bundled-scenarios-roster: roster shape + per-scenario
taskKind / kind / pipeline-stage-id assertions). The atom roster
test from §3.M3+M4 stays focused on plugins/_official/atoms/.

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 14:52:59 +00:00
Cursor AgentandTom Huang e6eaa62294 feat(plugins): handoff atom + ArtifactManifest provenance fields
Plan N3 / spec §11.5.1 / §21.5.

@open-design/contracts ArtifactManifest gains the spec §11.5.1
provenance + downstream-distribution surface as additive optional
fields:

  sourcePluginSnapshotId / sourcePluginId / sourcePluginVersion /
    sourceTaskKind / sourceRunId / sourceProjectId / parentArtifactId
  artifactKind / renderKind / handoffKind
  exportTargets[] / deployTargets[]

Spec §11.5.1 invariants:
  - sourcePluginSnapshotId NEVER changes after first write.
  - exportTargets[] / deployTargets[] are append-only.
  - handoffKind promotes monotonically along
    design-only < implementation-plan < patch < deployable-app.

apps/daemon/src/plugins/atoms/handoff.ts ships the daemon-side
helper:

  recordHandoff({ manifest, exportTarget?, deployTarget?,
                  handoffKind?, enforceMonotonicHandoff? })
    → { manifest, changed }

  - Idempotent: a (surface, target) pair only ever lands once on
    exportTargets[]; same for (provider, location) on deployTargets[].
  - handoffKind defaults to monotonic; pass enforceMonotonicHandoff:
    false on a rollback path.

  isDeployableAppEligible({ manifest, buildPassing, testsPassing })
    → boolean

  Spec §11.5.1 promotion rule for the deployable-app tier: requires
  build.passing + tests.passing AND at least one exportTargets[]
  entry on docker / cli surface. Centralises the rule so plugins
  don't reimplement it.

packages/contracts/src/index.ts now uses .js extensions on every
re-export so the daemon's NodeNext moduleResolution picks up the
new types end-to-end.

Daemon tests: 1534 → 1543 (+9 cases on plugins-handoff: appends
exportTargets / deployTargets, idempotency, monotonic handoffKind
promotion, downgrade refusal vs. rollback escape, deployable-app
eligibility rule).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 14:48:29 +00:00
Cursor AgentandTom Huang 0ab7ecf403 feat(plugins): code-import atom repo walker (Phase 7 entry slice)
Plan N2 / spec §10 / §21.3.2 / §16 Phase 7.

apps/daemon/src/plugins/atoms/code-import.ts ships the daemon-side
implementation of the SKILL.md fragment landed in §3.M4. Given a
repo path + a project cwd, the runner walks the tree once, writes
`<cwd>/code/index.json`, and never re-walks until the next
explicit re-import.

The walker:
  - Honours OD_CODE_IMPORT_BUDGET_MS (default 60s) and stops cleanly
    when the budget is exhausted, recording every unwalked
    directory under skipped[] with reason='budget-exceeded'.
  - Skips the canonical no-go folders: node_modules, .git, .next,
    .svelte-kit, dist, build, out, coverage, .vercel, .vscode,
    .turbo, .cache, .pnpm-store, .parcel-cache, .nuxt, .astro.
    Each entry lands in skipped[] with reason='directory-skiplist'.
  - Marks symlinks as skipped (reason='symlink') without following.
  - Records files larger than largeFileBytes (default 1 MiB) in
    skipped[] AND in files[] (so the agent sees they exist), but
    omits their imports[] to keep memory bounded.
  - Detects framework: next (app vs pages router), sveltekit, astro,
    remix, vite, cra, custom, unknown.
  - Detects package manager from lockfile presence (pnpm-lock.yaml
    > yarn.lock > bun.lockb > package-lock.json > unknown).
  - Detects style system: tailwind / styled-components / emotion /
    css.
  - Lightweight import edge extraction (regex) for ts/tsx/js/jsx;
    documented as heuristic in the SKILL.md fragment.

Daemon tests: 1528 → 1534 (+6 cases on plugins-code-import:
next-app router + tailwind + pnpm detection, skiplist enforcement,
symlink skip, large-file imports omission, persisted JSON layout,
non-directory repoPath rejection).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 14:33:53 +00:00
Cursor AgentandTom Huang 5b6a0899ce feat(plugins): build-test atom shell-out runner (Phase 7 entry slice)
Plan N1 / spec §10 / §22.4 / §16 Phase 7.

apps/daemon/src/plugins/atoms/build-test.ts ships the daemon-side
implementation that the bundled plugins/_official/atoms/build-test
SKILL.md fragment locks the contract for:

  runBuildTest({ cwd, buildCommand?, testCommand?, ... }) → BuildTestReport

The runner:
  - Honours explicit overrides (highest priority).
  - Otherwise infers commands from package.json scripts:
      typecheck (or build) → `{pm} run typecheck`
      test                  → `{pm} run test`
  - Auto-picks the package manager from lockfile presence
    (pnpm-lock.yaml → pnpm; yarn.lock → yarn; else npm).
  - Skips a half cleanly when no command resolved, recording an
    explicit reason. Skipped doesn't flip the matching signal off
    (a project that opts out of typecheck still gets build.passing=true).
  - Caps combined stdout+stderr at 1 MiB by default, marks
    truncated output with '…[truncated]'.
  - Per-command timeout 5min default, sends SIGTERM at expiry.
  - Spawn function is pluggable so callers can inject a stub.

writeBuildTestReport() persists the canonical critique/build-test.json
+ critique/build-test.log layout the SKILL.md fragment promises.

UntilSignals gains 'build.passing' + 'tests.passing' so a plan can
write `until: 'build.passing && tests.passing'` directly. Closes
the spec §22.4 limit-1 gap that was blocking scenario 2 (code
migration) from natively expressing 'build/test pass = converged'.

The score axis (legacy critique.score) maps:
  both pass        → 5
  only build pass  → 3
  both fail        → 1
so existing pipelines that read critique.score keep working.

Daemon tests: 1521 → 1528 (+7 cases on plugins-build-test:
both-pass, build-fail, skip-with-reason, log truncation, npm
script inference, pnpm preference, JSON + log persistence).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 14:30:36 +00:00
Cursor AgentandTom Huang 030d5ce7b2 feat(plugins): Phase 6 + 7 + 8 atom SKILL.md substrate
Plan M3 + M4 / spec §21.4 / §23.3.2.

Lands the prompt-fragment substrate for every atom spec §10
reserves under (planned) ids. The directory now ships nine new
atom plugins under plugins/_official/atoms/, each a SKILL.md +
open-design.json pair the daemon's bundled boot walker registers
on startup:

  Phase 6 (figma-migration native):
    figma-extract  pull node tree + tokens + assets into project cwd
    token-map      crosswalk source tokens onto active DESIGN.md

  Phase 7 (code-migration native):
    code-import    normalise repo into code/index.json snapshot
    design-extract lift tokens out of source / Figma / screenshots
    rewrite-plan   author the multi-file plan with ownership tiers
    patch-edit     execute one plan step per turn with ownership guards
    diff-review    surface unified diff + capture accept/reject
    build-test     run framework-default test commands; emit
                   build.passing + tests.passing signals

  Phase 8 (production code delivery):
    handoff        push artifact to cli/desktop/web/docker/github/
                   figma/code-agent surfaces; append to
                   ArtifactManifest.exportTargets[]

The implementations stay deliberately prose-only in v1 — the
fragments teach the agent what each atom expects, the spec §22.5
promotion path covers iterating each one out-of-tree first. The
matching FIRST_PARTY_ATOMS catalog rows already mark them
'planned'; today's commit gives the bundled boot walker something
to register so a plugin author who references one of these ids in
od.pipeline.stages[*].atoms[] sees the SKILL.md inside the
container without the doctor warning.

apps/daemon/tests/plugins-bundled-atoms-roster.test.ts (2 cases)
pins the on-disk inventory so a future PR can't drop or rename an
atom folder without touching the spec §10 / §21 / §23 tables in
the same patch.

Daemon tests: 1519 → 1521 (+2 cases on the new roster suite).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 14:22:18 +00:00
Cursor AgentandTom Huang d491b3b7da feat(plugins): wire activeStageBlocks under OD_BUNDLED_ATOM_PROMPTS=1
Plan M2 / spec §23.4 / §23.3.2 patch 2.

Closes the runtime half of the §23.3.2 lift. When
OD_BUNDLED_ATOM_PROMPTS=1 is set AND the run carries an
appliedPluginSnapshotId whose snapshot has a non-empty
od.pipeline.stages[*], composeDaemonSystemPrompt now:

  1. Fetches the snapshot from applied_plugin_snapshots.
  2. For each stage, calls loadAtomBodies(db, stage.atoms ?? []).
  3. Calls renderActiveStageBlock({ stageId, bodies }) for each
     stage; skips blocks that come back empty (no bundled atom
     resolved).
  4. Threads the resulting array as composeSystemPrompt's
     activeStageBlocks, which the composer splices in immediately
     after the  block.

Default behaviour (flag unset OR no snapshot OR no pipeline) is
byte-equal to today's prompt. Errors during atom-body loading
surface as a single console.warn and never block the run; the
composer falls through to the inline DISCOVERY_AND_PHILOSOPHY
constants.

The run's appliedPluginSnapshotId is forwarded from the in-memory
run object set up by POST /api/runs (plan §3.A1). Non-plugin runs
pass undefined and skip the entire branch.

Daemon tests stay at 1519/1519 (the new branch is opt-in; existing
tests don't set the env flag, so the default path is exercised
unchanged. The activeStageBlocks composer field already has 3
unit tests from §3.L2.)

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 14:12:04 +00:00
Cursor AgentandTom Huang 1fdda937f7 feat(plugins): OD_SNAPSHOT_RETENTION_DAYS referenced-row TTL (PB2)
Plan M1 / spec PB2 / §16 Phase 5.

Closes the 'expire even referenced rows' knob spec PB2 reserved as
operator-opt-in. When OD_SNAPSHOT_RETENTION_DAYS is set,
pruneExpiredSnapshots additionally retires snapshot rows whose
project_id no longer exists in the projects table AND whose
applied_at is older than the configured window. Live-project rows
stay pinned forever (reproducibility wins).

The 'project deleted' check is the v1 stand-in for the spec's
'referencing run/conversation/project is itself terminal' clause:
runs are in-memory in v1 (no SQLite signal we can read), and
conversations have no archived_at column. Deleting a project is
the loudest 'this is over' signal in v1, and it's the failure mode
that actually accumulates dangling snapshot rows in practice.

apps/daemon/src/plugins/gc.ts threads
readPluginEnvKnobs().snapshotRetentionDays into both the periodic
tick and the synchronous sweep() handle so the operator escape
hatch (od plugin snapshots prune --before <ts>) and the worker
share one rule.

Daemon tests: 1516 → 1519 (+3 cases on plugins-snapshot-gc:
deleted-project + old-enough-row pruned, deleted-project + recent
row survives, live-project + ancient row never pruned).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 14:07:02 +00:00
Cursor AgentandTom Huang 0c2c2bb0c7 feat(plugins): ProjectStorage + DaemonDb adapter substrate (Phase 5)
Plan L4 / spec §15.6.

apps/daemon/src/storage/project-storage.ts ships:

  - ProjectStorage interface (read / write / list / delete / stat).
  - LocalProjectStorage (v1 default; backed by fs/promises with the
    same traversal guards the legacy projects.ts helpers enforce).
  - S3ProjectStorage stub — interface-locked; throws StorageError(IO)
    on every operation until the AWS SDK wiring lands. The stub still
    exposes keyFor() so tests can lock the bucket-prefix join.
  - resolveProjectStorage({ projectsRoot, env }) reads
    OD_PROJECT_STORAGE / OD_S3_BUCKET / OD_S3_REGION / OD_S3_PREFIX /
    OD_S3_ENDPOINT and returns the matching backend.

apps/daemon/src/storage/daemon-db.ts ships:

  - resolveDaemonDbConfig({}) returns { kind: 'sqlite' } by default.
  - With OD_DAEMON_DB=postgres + OD_PG_HOST/DATABASE/USER (and
    optional OD_PG_PORT / OD_PG_SSL_MODE), returns a postgres
    configuration object the future adapter will consume.
  - Throws DaemonDbConfigError on missing required env vars or
    unknown OD_DAEMON_DB values.

Neither adapter is wired into the daemon's existing project routes
yet — the substrate slice keeps the call sites unchanged so a wrong
adapter never silently corrupts user data on roll-out. The next
Phase 5 PR routes apps/daemon/src/projects.ts through the resolved
ProjectStorage and lands the real AWS SDK + Postgres impls behind
these interfaces.

Daemon tests: 1503 → 1516 (+13 cases on storage.test.ts:
LocalProjectStorage round-trip + nested list + traversal rejection +
NOT_FOUND, S3 stub key-builder + throw-on-op + bucket/region guard,
resolver default + s3 + DaemonDb sqlite default + postgres parsing +
incomplete env + unknown kind).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 13:58:27 +00:00
Cursor AgentandTom Huang 61931faef9 feat(plugins): plugin-bundled component surface (sandboxed iframe)
Plan L3 / spec §10.3.5 / §9.2.

Web: GenUISurfaceRenderer now renders a sandboxed iframe when the
surface declares a component path. Communication is one-way: the
iframe posts `{ kind: 'genui:respond', surfaceId, value }` envelopes
back to the parent via window.postMessage; the parent forwards to
onAnswered. The iframe carries `sandbox="allow-scripts"` only — no
allow-same-origin / forms / popups / downloads, exactly as spec §9.2
mandates.

PendingSurface gains `componentPluginId`, supplied by the host (from
the run's AppliedPluginSnapshot.pluginId). Surfaces missing that
field render an inline error so a misconfigured host fails loudly.

Daemon: GET /api/plugins/:id/asset/* serves arbitrary files inside an
installed plugin's fsPath under the §9.2 preview CSP:

  Content-Security-Policy: default-src 'none'; img-src 'self' data: blob:;
                           media-src 'self' data: blob:;
                           style-src 'self' 'unsafe-inline';
                           script-src 'self' 'unsafe-inline';
                           connect-src 'none'; frame-ancestors 'self'
  X-Content-Type-Options: nosniff

Three guards: unknown plugin id → 404, traversal segments / absolute
paths → 400, escape-via-resolved-path → 400. Content-Type maps
common asset extensions; everything else falls back to
application/octet-stream so the browser's nosniff respects it.

Daemon tests: 1499 → 1503 (+4 cases on plugins-asset-route covering
404 / traversal-rejection / 200 with CSP headers / asset-not-found).
Web typecheck clean; jsdom tests stay at 586/586 (the iframe path is
DOM-shape-locked and has no callable surface to drive without a
real browser).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 13:55:04 +00:00
Cursor AgentandTom Huang dfbb646b5d feat(plugins): composeSystemPrompt accepts activeStageBlocks (spec §23.4)
Plan L2 / spec §23.3.2 patch 2 / §23.4.

Both composers (daemon-side apps/daemon/src/prompts/system.ts and
contracts-side packages/contracts/src/prompts/system.ts) now accept
an optional `activeStageBlocks` field on ComposeInput. When the
field is present the composer splices every non-empty block in
order, immediately after the `## Active plugin` block and before
the metadata block. Empty / whitespace-only entries are skipped so
a pipeline whose stages don't resolve any bundled atom bodies
produces zero extra prompt mass.

This is the substrate slice the §23.3.2 lift needs:

  - Inputs: activeStageBlocks: ReadonlyArray<string> | undefined
  - Producers: pipelineRunner / pre-run hook can build each block
    from `renderActiveStageBlock(stageId, loadAtomBodies(db, atoms))`
    (both helpers shipped in plan §3.J3).
  - Effect: when OD_BUNDLED_ATOM_PROMPTS is set (the next PR), the
    pipeline runner stamps the run's snapshot pipeline through the
    pair and feeds the assembled blocks to composeSystemPrompt;
    until then the field stays undefined and the prompt is byte-equal
    to today's output.

The hard-coded DISCOVERY_AND_PHILOSOPHY constant in system.ts stays
in place for v1 — replacing it would swap the entire prompt floor
on every run, which needs the bundled-atom SKILL.md bodies to fully
cover the discovery + critique workflow first. This commit only
adds the splice point.

Daemon tests: 1496 → 1499 (+3 cases: order-preserving multi-stage
splice, empty-block skip, no-op when undefined / empty).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 13:50:36 +00:00
Cursor AgentandTom Huang 6b114910e4 feat(plugins): od.genui.surfaces[].component manifest field (Phase 4)
Plan K3 / spec §10.3.5 alignment-roadmap row 2.

Plugin authors can now declare a per-surface React component path:

  od.genui.surfaces[*].component: {
    path:    string,           # required, no traversal segments
    export?: string,           # optional named export
    sandbox?: 'iframe' | 'react'
  }

The capability gate is wired in three places:

- packages/contracts: GenUISurfaceSpecSchema.component is a passthrough
  Zod object so the validator accepts the new field on round-trip.
- apps/daemon/src/plugins/trust.ts: 'genui:custom-component' joins the
  KNOWN_TOP_LEVEL_CAPABILITIES set so od plugin trust can grant it.
- apps/daemon/src/plugins/doctor.ts: doctorPlugin emits
  genui.component-capability when a surface ships a component without
  the matching capability declared, and genui.component-traversal when
  the path includes '..'.

The web GenUISurfaceRenderer (apps/web/src/components/GenUISurfaceRenderer.tsx)
keeps its built-in renderer for v1; loading the bundled component is
the next slice and depends on a sandbox wrapper that doesn't exist
yet (the spec §9.2 preview sandbox is the inspiration but
insufficient — components need React boundaries).

Daemon tests: 1490 → 1496 (+6 cases on plugins-genui-component:
schema accepts the new field shape, rejects empty path, capability
appears in the §5.3 vocabulary, doctor errors when capability is
missing, doctor passes when granted, doctor catches traversal).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 13:37:20 +00:00
Cursor AgentandTom Huang bef9add981 feat(plugins): bound-API-token guard + bearer middleware (Phase 5)
Plan K1 / spec §15.7 / §16 Phase 5.

The daemon now ships a two-part hosted-mode safety floor:

1. startServer() refuses to start when OD_BIND_HOST is set to a
   non-loopback address and OD_API_TOKEN is unset. Loopback hosts
   (127.0.0.1 / ::1 / localhost) keep the existing zero-config
   desktop / dev experience. The error message points the operator
   at `openssl rand -hex 32` so the next attempt succeeds in one
   step.

2. When OD_API_TOKEN is set, every /api/* request must carry an
   `Authorization: Bearer <OD_API_TOKEN>` header. Three escape
   hatches:
     - Loopback peers (req.socket.remoteAddress matches
       isLoopbackPeerAddress) skip the check — the desktop UI / local
       CLI never need a bearer.
     - The /api/health, /api/version, and /api/daemon/status probes
       remain open so monitoring + cloud orchestrators (k8s readiness,
       Compose healthcheck) work without touching secrets.
     - Mismatched / missing headers return 401 API_TOKEN_REQUIRED.

This closes a previously documented Phase 5 hazard: an operator
spinning up the daemon with OD_BIND_HOST=0.0.0.0 (the docker-compose
default) without a token would have published an unsecured API.

Daemon tests: 1486 → 1490 (+4 cases on api-token-guard:
non-loopback bind without token rejected, public host with token
boots, loopback callers still pass without bearer, probes stay open).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 13:29:31 +00:00
Cursor AgentandTom Huang 847304ebc5 feat(plugins): atom SKILL.md body loader + renderActiveStageBlock (spec §23.4)
Plan J3 / spec §23.3.2 patch 2 / §23.4.

Lays the substrate slice for migrating prompt fragments out of
`apps/daemon/src/prompts/system.ts` and into the bundled atom
SKILL.md bodies registered by §3.I3.

apps/daemon/src/plugins/atom-bodies.ts owns the daemon-side loader:

  loadAtomBodies(db, atomIds) → AtomBodyEntry[]

The function looks each atom id up in installed_plugins (bundled
rows win), reads the matching fsPath/SKILL.md, strips
front-matter, and returns the raw body. Atoms with no installed
plugin or unreadable SKILL.md are silently skipped — the caller
drops empty entries from the prompt.

packages/contracts/src/prompts/atom-block.ts ships the pure
renderer:

  renderActiveStageBlock({ stageId, bodies, iteration? }) → string

Mirrors spec §23.4's composeSystemPrompt sketch. Empty bodies
return ''; multiple bodies are separated by '---' with no trailing
separator. Lives in contracts so the daemon-side composer and any
future contracts-side composer share one definition (§11.8 PB1
single-import guarantee).

The composeSystemPrompt() rewiring itself is the next PR — this
commit gives that PR zero scaffolding to build: the helpers are
reachable, tested, and the bundled atom plugins from §3.I3 already
have the matching SKILL.md bodies on disk.

Tests: contracts 8 → 12 (+4 cases on atom-block); daemon
1482 → 1486 (+4 cases on plugins-atom-bodies covering the
end-to-end loadAtomBodies → renderActiveStageBlock path).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 13:15:52 +00:00
Cursor AgentandTom Huang 0631f04a00 feat(plugins): @open-design/agui-adapter package + GET /api/runs/:id/agui
Plan J1 + J2 / spec §10.3.5 / Phase 4.

New workspace package: packages/agui-adapter/. Pure-TS
bidirectional bridge between OD's native PersistedAgentEvent /
GenUIEvent / PluginPipelineStageEvent union and the AG-UI canonical
event protocol (https://github.com/CopilotKit/CopilotKit).

  - src/types.ts        — AGUIEvent discriminated union (agent.message,
                          tool_call, state_update, ui.surface_requested,
                          ui.surface_responded, run.lifecycle).
  - src/encode.ts       — encodeOdEventForAgui(event, ctx): maps every
                          OD native event onto the canonical shape; drops
                          events the encoder can't translate so external
                          AG-UI clients always see a clean stream.
  - tests/encode.test.ts (9 cases) covers message_chunk, tool_call,
                          run_started, end → started/completed/failed/
                          cancelled, pipeline_stage_started/completed,
                          genui_surface_request/response/timeout,
                          genui_state_synced, and the unknown-event drop.

apps/daemon/src/server.ts mounts GET /api/runs/:id/agui:

  - 404 for unknown run ids.
  - Replays the run's recorded events through the encoder on subscribe
    (so a reconnecting client with Last-Event-ID picks up exactly the
    AG-UI events it missed).
  - Subscribes to future events via a thin adapter client wrapper that
    routes through the existing run.clients fan-out, so the encoder
    runs lazily on each broadcast (no double event buffering).

Daemon depends on @open-design/agui-adapter; the package builds clean
and ships pure ESM. v1 plugins consume CopilotKit / agent-protocol
clients without modification — the adapter ships independently from
daemon main, so upstream protocol revs do not couple to the daemon
release cadence (per spec §10.3.5 Phase 4 contract).

Tests: agui-adapter 9/9, daemon 1481 → 1482 (+1 case on agui-route).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 13:11:48 +00:00
Cursor AgentandTom Huang 2848327199 feat(plugins): bundled atom plugins + boot walker (Phase 4 / spec §23)
Plan I3 / spec §23.3.5 + §23.3.4.

Lays the entry slice for spec §23 self-bootstrapping: the daemon's
own behavioural content moves out of system.ts hard-coded constants
and into per-atom SKILL.md files under plugins/_official/atoms/.

New ground:

- plugins/_official/atoms/ ships four canonical atom SKILL.md +
  open-design.json pairs (discovery-question-form, todo-write,
  direction-picker, critique-theater). Each pair is a fully valid
  plugin that the existing local-folder installer can pick up; the
  manifest declares od.kind='atom' so the future doctor warns when a
  pipeline references the matching id but the bundled folder is
  missing.

- apps/daemon/src/plugins/bundled.ts owns the boot walker:
  registerBundledPlugins({ db, bundledRoot }) walks one level of
  subdirectories (atoms/, scenarios/, bundles/) plus direct
  plugins, registers every match into installed_plugins under
  source_kind='bundled' / trust='bundled', and is idempotent across
  daemon restarts (upserts on every boot).

- defaultBundledRoot(workspaceRoot) returns
  <workspaceRoot>/plugins/_official; server.ts boot calls
  registerBundledPlugins(..., defaultBundledRoot(PROJECT_ROOT)) and
  prints a one-line summary. ENOENT is silent — running the daemon
  outside the dev tree just skips this step.

The full §23 lift (system.ts prompt fragments → SKILL.md bodies +
plugin.kind='atom' → composeSystemPrompt resolving each stage's
atoms[] through the registry) stays open. This commit is the
substrate slice: bundled plugins are now first-class
installed_plugins rows under the new tier, doctor / list / info /
apply all pick them up, and a future PR that wires composeSystemPrompt
to read SKILL.md bodies has zero registration work to do.

Daemon tests: 1476 → 1481 (+5 cases on plugins-bundled covering
tiered + direct layouts, idempotent reruns, ENOENT, miss-skips).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 13:00:27 +00:00
Cursor AgentandTom Huang 671293e7f4 feat(cli): od doctor + od config get/set/list/unset
Plan I2 / spec §12.2.

`od doctor` is a single-shot diagnostics command that:
  - hits /api/daemon/status (fails with exit 64 if unreachable)
  - lists installed plugins and runs the per-plugin doctor route
  - lists skills / design-systems / atoms for inventory
  - aggregates issues by severity (warn / error)
  - exits 1 when any plugin doctor returns ok=false, otherwise 0

This is the CLI entry point a CI runner uses to gate a release on
plugin-system health before publishing. --json emits the full
report so downstream tooling (e.g. a GitHub Action summary step)
can render its own table.

`od config list/get/set/unset` wraps GET/PUT /api/app-config so a
code agent can flip provider keys / orbit settings / pet config
headlessly:

  od config list
  od config get <key>            (or --json for the value alone)
  od config set <key> <value>    (positional or --value <text>)
  od config set <key> --value-json '<json>'   (nested values)
  od config unset <key>

set on a top-level key only — nested merges go through --value-json
which keeps the daemon's existing PUT semantics (full-document
replace) intact.

Daemon test suite stays at 1476/1476 (the new CLI verbs are thin
wrappers over endpoints that already have integration coverage).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 12:54:03 +00:00
Cursor AgentandTom Huang deb74d91c4 feat(plugins): wire pipeline runner into POST /api/runs (e2e-3 full contract)
Plan I1 + I4 / spec §10.1 / plan §8 e2e-3.

Lifts e2e-3 from entry-slice to the full §8 contract: 'first ND-JSON
event has kind=pipeline_stage_started'.

POST /api/runs gains a synchronous firePipelineForRun() call that
runs immediately after design.runs.create() + linkSnapshotToRun(),
BEFORE design.runs.start() schedules the agent. The first
pipeline_stage_started event lands on the run's SSE buffer ahead of
any message_chunk the agent emits. The remaining stage timeline
(stage_started/completed for stages 2..N, plus the
run_devloop_iterations audit row per stage) fires asynchronously
while the agent runs.

The stage runner is a stub that returns converged signals
(critique.score=4, preview.ok=true, user.confirmed=true) so a
non-loop pipeline walks through every stage in O(stages) time.
Loop stages still respect OD_MAX_DEVLOOP_ITERATIONS. Phase 4's atom
migration into plugins/_official/atoms/<atom>/SKILL.md will swap the
stub for a real per-stage worker that drives the agent.

Errors inside the pipeline runner emit pipeline_stage_failed onto
the run stream and never block the agent process — a buggy plugin
manifest can never deadlock a chat run.

apps/daemon/tests/plugins-headless-run.test.ts gains a second case
that installs a fixture plugin with two declared stages, creates a
project + run, then reads the SSE event stream and asserts the
first observed stage event is pipeline_stage_started. The fixture
grants pipeline:* via the resolver's grantCaps[] field so the
restricted-trust capability gate stays in force for non-pipeline
runs.

Daemon tests: 1475 → 1476.

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 12:51:12 +00:00
Cursor AgentandTom Huang 712c18dbc2 feat(cli): od atoms / skills / design-systems / craft / status / version + marketplace search
Plan H2 + H3 + H4 / spec §12.2 / §11.7.

Closes the CLI parity remainder: every UI feature reachable via
/api/* now has a matching CLI verb.

  od atoms list                       List first-party atoms.
  od atoms show <id>                  Print one atom (status + taskKinds).

  od skills list / show <id>          Wraps GET /api/skills{,/:id}.
  od design-systems list / show <id>  Wraps GET /api/design-systems{,/:id}.
  od craft list / show <id>           NEW endpoints GET /api/craft{,/:id}
                                      walk the craft/ directory and
                                      emit { id, label, bytes }.
  od status                           Alias of `od daemon status`.
  od version                          Wraps GET /api/version; --json
                                      emits the structured shape.

  od marketplace search "<query>"     New verb. Walks every configured
                  [--tag <tag>]         marketplace's plugins[] and
                                       matches by substring on
                                       name + description + tags.

The HTTP additions are GET /api/craft (catalog summary) and
GET /api/craft/:id (raw markdown body), both safe-slug-validated.

Daemon tests: 1472 → 1475 (+3 cases on craft-route covering catalog
shape, malformed slug rejection, miss-as-404).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 12:41:06 +00:00
Cursor AgentandTom Huang 45f52d71c4 feat(plugins): od plugin publish — catalog PR-template launcher
Plan H1 / spec §14.1.

apps/daemon/src/plugins/publish.ts owns the URL-builder side. Pure
string assembly per catalog target:

  anthropics-skills      → github.com/anthropics/skills/issues/new
                           ?title=…&body=…
  awesome-agent-skills   → github.com/VoltAgent/awesome-agent-skills/...
  clawhub                → github.com/openclaw/clawhub/...
  skills-sh              → https://skills.sh/ + npx skills add owner/repo
                           (skills.sh autodiscovers; no PR form needed)

The PR body carries plugin id / version / repo URL plus a compatibility
checklist confirming SKILL.md + open-design.json so the upstream
reviewer can validate against the §3 cross-catalog matrix at a glance.

CLI:
  od plugin publish <pluginId> --to <catalog>
                    [--repo <github-url>] [--snapshot-id <id>]
                    [--open] [--json]

  --open auto-launches the system browser (open / xdg-open / start) on
  the catalog URL, so a code agent driving the headless flow can hand
  off to the human author with a single command.

The CLI never POSTs anywhere — every catalog upstream stays under the
author's review control.

Daemon tests: 1465 → 1472 (+7 cases on plugins-publish covering URL
shape per target + skills.sh fallback + unknown-catalog rejection).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 12:37:05 +00:00
Cursor AgentandTom Huang 411d83b0bf feat(web): MarketplaceView + PluginDetailView + /marketplace routes
Plan G4 / spec §11.6.

router.ts gains two new Route variants — 'marketplace' and
'marketplace-detail' — plus parsing for both /marketplace/<id> and
the /plugins/<id> alias the public site (§13) reserves. App.tsx
dispatches them outside the EntryView / ProjectView split so the
discovery surface stays independent of any active project.

New components:

- MarketplaceView (apps/web/src/components/MarketplaceView.tsx)
  - Card grid of every installed plugin with trust-tier filters
    (All / Trusted / Restricted).
  - Secondary 'Configured catalogs' panel listing every row in
    /api/marketplaces with id / url / trust / plugin count.
  - Cards link to /marketplace/<id>.

- PluginDetailView (apps/web/src/components/PluginDetailView.tsx)
  - Loads /api/plugins/:id, renders header (title, version, trust,
    sourceKind, taskKind), description, capability checklist,
    connector requirements (required + optional), and declared GenUI
    surfaces.
  - 'Use this plugin' button calls applyPlugin(id) and navigates
    back to Home so the existing inline rail / NewProjectPanel
    surface picks up the snapshot.

Web tests: 579 → 586 (added router-marketplace 5 cases +
MarketplaceView 2 cases). Typecheck clean.

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 12:28:59 +00:00
Cursor AgentandTom Huang b436f0b33d feat(web): mount PluginsSection in ChatComposer
Plan G3 / spec §8.4 / §11.6.

ChatComposer renders `<PluginsSection variant="strip" />` directly
above the existing `composer-input-wrap` whenever a projectId is
known. Behaviour mirrors the NewProjectPanel mount from §3.F5:

  - The strip variant is the slim horizontal layout the spec calls
    out for ChatComposer (versus 'wide' on Home).
  - Apply hydrates the draft (only when empty), so a user mid-typing
    is never overwritten.
  - The existing send / @-mention / staged-attachment chrome is
    untouched.

The deeper inputs-form gating + chip-strip removal of the active
plugin go through the same PluginsSection state, so a Phase 2B
follow-up that wires the Send-button to onValidityChange will not
need to touch ChatComposer again.

Web tests stay at 579/579 (the strip variant flows through the same
component primitives that already have jsdom coverage).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 12:24:35 +00:00
Cursor AgentandTom Huang 04784013e6 feat(plugins): od plugin scaffold + od plugin export (Phase 4 author tooling)
Plan G1 + G2 / spec §14.

Two new author-side CLI verbs that close the spec §14 'one repo, every
catalog' loop without leaving the terminal.

apps/daemon/src/plugins/scaffold.ts owns the scaffold path:

  od plugin scaffold --id <id> [--title] [--description]
                     [--task-kind <new-generation|code-migration|
                                   figma-migration|tune-collab>]
                     [--mode <m>] [--scenario <s>]
                     [--out <dir>] [--with-claude-plugin]

  Writes <out>/<id>/{SKILL.md, open-design.json, README.md} (plus the
  optional .claude-plugin/plugin.json for cross-catalog publishing).
  Refuses to overwrite an existing scaffold; rejects unsafe ids.

apps/daemon/src/plugins/export.ts + POST /api/applied-plugins/export
own the export path. Three targets:

  od            → SKILL.md + open-design.json + provenance README
  claude-plugin → SKILL.md + .claude-plugin/plugin.json + README
  agent-skill   → SKILL.md + README only (every catalog accepts this)

  od plugin export <projectId> --as od|claude-plugin|agent-skill --out <dir>
  od plugin export --snapshot-id <id> --as <target> --out <dir>

The exporter pulls SKILL.md straight off the installed plugin's fsPath
when available and otherwise synthesises one from the snapshot's
frozen plugin metadata, so a publishable folder is reproducible even
after od plugin update / uninstall rotates the live source. The
generated open-design.json carries a provenance block recording
snapshot id + manifestSourceDigest so a downstream catalog viewer can
reverse-resolve the originating run.

The HTTP route is loopback-only (requireLocalDaemonRequest) because
it writes to the host filesystem; the CLI is the canonical caller.

Daemon tests: 1455 → 1465 (+10): plugins-scaffold (5 cases) +
plugins-export (5 cases).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 12:22:16 +00:00
Cursor AgentandTom Huang daceadefbd feat(web): mount PluginsSection in NewProjectPanel
Plan §3.F5 / spec §8 / §11.6.

New apps/web/src/components/PluginsSection.tsx bundles InlinePluginsRail,
ContextChipStrip, and PluginInputsForm into one host-agnostic widget.
The host wires three optional callbacks:

  - onApplied(brief, applied)   fires on apply + every input change
  - onCleared()                  fires when the user removes a context
                                 chip (clears the active plugin)
  - onValidityChange(valid)      mirrors the inputs-form validity gate

renderPluginBriefTemplate substitutes {{var}} placeholders inside
useCase.query against the live inputs map so the brief output stays
in sync as the user types.

NewProjectPanel mounts PluginsSection right under the project-name
input. The section is purely additive: clicking a plugin card hydrates
the project name field (only when empty) with the rendered brief, so
the existing Send-button rules are unchanged. The deeper composer
gating + ChatComposer mount stay scheduled for the Phase 2B PR.

Web test suite: 575 → 579 (added 4 cases on PluginsSection: empty
state, apply hydration, input change re-emission, chip-remove
clearing).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 12:12:34 +00:00
Cursor AgentandTom Huang 921df7cbbd test(plugins): e2e-3 headless install→project→run anchor
Plan §3.F4 / spec §8 e2e-3 (entry slice).

apps/daemon/tests/plugins-headless-run.test.ts walks the §12.5
headless flow at the HTTP layer with no electron / no agent backend:

  1. POST /api/plugins/install (local fixture, SSE) succeeds.
  2. POST /api/projects { pluginId, pluginInputs } resolves via the
     snapshot resolver and returns appliedPluginSnapshotId.
  3. POST /api/runs { projectId, pluginId, appliedPluginSnapshotId,
     pluginInputs } returns 202 + runId.
  4. GET /api/runs/:id surfaces the same appliedPluginSnapshotId on
     the run status payload (the JSON shape od run watch / od run info
     read against).
  5. GET /api/applied-plugins/:id returns the same snapshot the
     replay path reads.

The full e2e-3 contract from §8 ('first ND-JSON event has
kind=pipeline_stage_started') still depends on plugging
runPipelineForRun() into startChatRun()'s actual agent loop — that
slice is deferred (and called out in plan §3.A7). This test locks
the public HTTP shape so the next slice doesn't accidentally drift
the snapshot wiring while wiring the agent loop.

Daemon tests: 1454 → 1455.

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 12:08:25 +00:00
Cursor AgentandTom Huang 9927707361 feat(plugins): od plugin install <name> resolves through marketplaces
Plan §3.F3 / spec §7.2 + §6 / §16 Phase 3.

`resolvePluginInMarketplaces(db, name)` walks every configured
marketplace in registration order and returns the first matching
plugin entry. The matched entry carries:
  - the canonical source string (github:owner/repo, https://…tar.gz)
    that the existing installer backends already understand
  - the marketplace trust tier and id for the audit trail
  - the optional description for surface display

POST /api/plugins/install detects bare-name sources (anything that
isn't a local path / github: / https://) and re-routes through
`resolvePluginInMarketplaces` before handing off to the installer.
On miss the route returns 404 plugin-not-found with structured error
data, so the CLI's structuredHttpFailure helper maps it to exit 65.

`od plugin install <name>` (no flag) now works end-to-end. The
existing `./folder`, `github:…` and `https://*.tar.gz` shapes are
unchanged.

Note: per spec §9, marketplace trust does NOT propagate to the
installed plugin: a plugin pulled from a 'trusted' marketplace still
defaults to its source-kind tier. Trust elevation goes through
`od plugin trust` / the Phase 3 trust UI.

Daemon tests: 1450 → 1454 (added 4 cases to plugins-marketplaces:
canonical-source resolution, case insensitivity, miss → null,
registration-order tie-break).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 12:06:59 +00:00
Cursor AgentandTom Huang 1ae977e066 feat(cli): od daemon start/status/stop + new HTTP daemon-lifecycle routes
Plan §6 Phase 1.5 / spec §11.7.

The desktop default `od` (no subcommand) keeps its current behaviour
for back-compat. New explicit lifecycle entry:

  od daemon start [--headless] [--serve-web] [--port <n>] [--host <addr>]
                  [--no-open] [--namespace <ns>]

  --headless / --no-open / --serve-web all imply 'do not auto-open the
  browser', so a code agent / CI runner / Docker entry point can launch
  the daemon without electron. The port + host flags fall through to
  OD_PORT / OD_BIND_HOST when unset, matching every other surface.

  od daemon status [--json] [--daemon-url <url>]
  od daemon stop   [--daemon-url <url>]

These two CLI verbs hit the new HTTP routes:

  GET  /api/daemon/status      → { ok, version, bindHost, port, pid,
                                   dataDir, mediaConfigDir, namespace,
                                   installedPlugins, shuttingDown }
  POST /api/daemon/shutdown    → 202; emits SIGTERM so the existing
                                   graceful close path runs unchanged.
                                   requireLocalDaemonRequest enforces the
                                   same loopback / origin check used by
                                   composio config writes (no public
                                   shutdown surface).

This makes `od daemon start --headless --port 17456` followed by
`od plugin install ./fixtures/sample-plugin` and the rest of the
§12.5 walkthrough reachable without electron — exactly the substrate
e2e-3 needs.

Daemon tests: 1448 → 1450 (added daemon-lifecycle with 2 cases:
status snapshot shape; shutdown rejects non-loopback callers).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 12:04:42 +00:00
Cursor AgentandTom Huang 3d0ce28346 feat(cli): od project / run / files / conversation subcommand routers
Plan §6 Phase 1 follow-up + Phase 2C / spec §12.2.

Wraps the existing daemon HTTP endpoints behind stable CLI subcommands
so a code agent can drive the §12.5 walkthrough end-to-end without
hitting /api/* directly. Every UI feature now has a CLI equivalent
(spec §11.7 invariant).

New CLI groups:

  od project create [--name] [--skill] [--design-system] [--plugin]
                    [--inputs <json>] [--metadata-json <path|->]
                    [--grant-caps a,b]
  od project list / info / delete

  od run start --project <id> [--conversation] [--message] [--plugin]
               [--inputs <json>] [--grant-caps] [--agent] [--model]
               [--snapshot-id] [--follow]
  od run watch  <runId>     ND-JSON SSE relay on stdout
  od run cancel <runId>
  od run list   [--project <id>]
  od run info   <runId>

  od files list   <projectId>
  od files read   <projectId> <relpath>
  od files write  <projectId> <relpath>     (reads stdin)
  od files upload <projectId> <localpath> [--as <relpath>]
  od files delete <projectId> <name>

  od conversation list <projectId>
  od conversation info <conversationId>

Plugin runs route through the snapshot resolver from §3.A1 — a code
agent can pass --plugin / --inputs / --grant-caps directly to
`od project create` or `od run start` and the daemon pins the
applied plugin snapshot to the new project / run. Capability gate
failures map to exit 66 / capabilities-required, missing inputs to
exit 67 / missing-input via the structured error envelope from §3.B1.

Daemon test suite stays at 1448/1448 (CLI handlers are thin HTTP
wrappers; the underlying routes already have integration coverage).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 12:01:02 +00:00
Cursor AgentandTom Huang adc2afd769 feat(web): plugin composer surface — applyPlugin + Rail + Inputs + GenUI renderer
Plan §3.C1–§3.C4.

Web composer integration for the plugin system:

- apps/web/src/state/projects.ts gains:
  * applyPlugin(pluginId, { inputs?, projectId?, grantCaps? }) — wraps
    POST /api/plugins/:id/apply and returns the typed ApplyResult.
  * listPlugins() — wraps GET /api/plugins.
  * renderPluginBriefTemplate(template, inputs) — substitutes
    {{var}} placeholders inside useCase.query as the user types so the
    composer's brief textarea re-renders live.

- New components:
  * InlinePluginsRail — the card strip that lives below the input box
    on Home and inside ChatComposer. Supports 'wide' / 'strip' layouts
    + taskKind / mode filters.
  * ContextChipStrip — typed ContextItem chips above the brief input.
    Optional onRemove for clearing the applied plugin.
  * PluginInputsForm — JSON-Schema-light form rendered between the
    input and Send. Required fields gate Send via onValidityChange;
    string/text/select/number/boolean field types are supported.
  * GenUISurfaceRenderer — first-class confirmation + oauth-prompt
    surfaces (form + choice fall back to a JSON Schema preview +
    free-form textarea until Phase 2A.5).
  * GenUIInbox — drawer that lists every persisted surface answer for
    a project; revoke calls POST /api/projects/:id/genui/:sid/revoke.

- jsdom tests under apps/web/tests/components/:
  * InlinePluginsRail (mount fetch, click → applyPlugin → onApplied,
    taskKind filter)
  * PluginInputsForm (validity gating, default hydration, select
    options)
  * GenUISurfaceRenderer (confirmation true/false branches; oauth
    surface forwards { authorized, connectorId } per spec §10.3.1)

Web test suite: 567 → 575 (added 8 plugin component cases). The
NewProjectPanel / ChatComposer / ProjectView mounts will land in the
follow-up commit so this PR's diff stays reviewable.

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 11:47:12 +00:00
Cursor AgentandTom Huang 5961c877b6 test(plugins): definition-of-done suite — e2e-2, e2e-4, e2e-6, e2e-8
Plan §8.

apps/daemon/tests/plugins-dod-e2e.test.ts anchors the four §8 e2es that
don't require Playwright UI flow or the Phase 1.5 headless flag:

  e2e-2 pure apply across runs: two consecutive applies share digest
        and produce no FS mutation. Snapshot count stays at 0 because
        applyPlugin() itself never writes to applied_plugin_snapshots.
  e2e-4 replay invariance: the snapshot's prompt block (rendered via
        renderPluginBlock from @open-design/contracts) is byte-equal
        before and after a same-id plugin upgrade, even though the
        live applyPlugin against the upgraded plugin produces a
        different manifestSourceDigest.
  e2e-6 connector trust gate: a restricted plugin that declares
        od.connectors.required=[{id:'slack'}] without connector:slack
        capability fails resolvePluginSnapshot with HTTP 409 / exit 66
        and capabilitiesRequiredError envelope shape. Independently the
        token-issuance gate (checkConnectorAccess) refuses the same
        call, so even a leaked tool token can't bypass §5.3.
  e2e-8 apply purity regression: 100 applies leave the project cwd
        byte size unchanged, write zero .mcp.json files, and grow the
        snapshot count by exactly 100 only when the resolver path
        explicitly persists.

Plus a guardrail on capabilitiesRequiredError() so the §12.4 / exit-66
data shape stays stable for code agents reading it.

Daemon tests: 1443 → 1448 (+5).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 11:39:56 +00:00
Cursor AgentandTom Huang 0df9176c2f feat(plugins): marketplace registry + od marketplace CLI verbs
Plan §3.B4 / spec §6 / §7.2 / §16 Phase 3 entry slice.

The storage half of federated catalogs:

- New apps/daemon/src/plugins/marketplaces.ts owns add / list / get /
  refresh / remove / set-trust over the pre-existing plugin_marketplaces
  SQLite table. Uses parseMarketplace from @open-design/plugin-runtime so
  validation matches the schema in docs/schemas/open-design.marketplace.v1.json.
  Default trust tier is 'restricted' (discovery only) per spec §9.
- HTTP routes:
  * GET    /api/marketplaces
  * POST   /api/marketplaces                      (body { url, trust? })
  * GET    /api/marketplaces/:id
  * DELETE /api/marketplaces/:id
  * POST   /api/marketplaces/:id/refresh
  * POST   /api/marketplaces/:id/trust            (body { trust })
  * GET    /api/marketplaces/:id/plugins          (catalog passthrough)
- CLI verbs: `od marketplace add/list/info/refresh/remove/trust`.
  --trust accepts 'trusted', 'restricted', or 'official'.

Phase 3 follow-up wires `od plugin install <name>` resolution + the trust
UI on top; this commit is the data-layer half so the CLI can already
register and inspect catalogs.

Daemon tests: 1438 → 1443 (added plugins-marketplaces with 5 cases).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 11:37:12 +00:00
Cursor AgentandTom Huang fbabb27143 feat(plugins): CLI structured error envelope + apply --grant-caps / --input k=v
Plan §3.B1 + §3.B2 / spec §12.4.

- New exitWithStructuredError() + structuredHttpFailure() helpers in
  cli.ts. Maps a daemon HTTP error envelope (or a synthetic local
  reason) to the §12.4 exit codes (64–73): daemon-not-running,
  plugin-not-found, capabilities-required, missing-input,
  project-not-found, run-not-found, provider-not-configured,
  plugin-requires-daemon, snapshot-stale, genui-surface-awaiting.
- od plugin apply now:
  * accepts --inputs <json> AND repeated --input k=v (auto-coercing
    'true'/'false' booleans and numerics) so a code agent can build the
    inputs map without shell-escape gymnastics
  * accepts --grant-caps a,b for per-snapshot capability grants
  * routes 422 (missing inputs) → exit 67, 409 capabilities-required →
    exit 66, daemon-down → exit 64, all via the structured envelope.

apps/daemon/src/plugins/installer.ts gets a small TS soundness fix on
the tar filter callback (entry can be Stats or ReadEntry — narrow before
reading .type).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 11:32:45 +00:00
Cursor AgentandTom Huang 531df0e270 feat(plugins): pipeline-runner — stage events + GenUI cache integration
Plan §3.A7 + §3.A8 / spec §10.1 / §10.2 / §10.3.3.

New apps/daemon/src/plugins/pipeline-runner.ts is the wiring layer
between the pure pipeline scheduler and a live run's event stream:

- Walks the pipeline through runPipeline(), emitting
  pipeline_stage_started / pipeline_stage_completed events for SSE
  consumers and persisting one row per iteration into
  run_devloop_iterations.
- Pre-stage GenUI: surfaces with no trigger.stageId (e.g. the
  __auto_connector_<id> oauth-prompts the apply path auto-derives) are
  raised once up-front via requestOrReuseSurface(). On a project-tier
  cache hit the registry emits genui_surface_response { respondedBy: 'cache' }
  WITHOUT broadcasting a new request — this is the e2e-5 anchor.
- Stage-bound surfaces fire on stage entry only when the surface's
  trigger.stageId matches.

The actual stage worker is supplied by the caller; in v1 the daemon
plugs in a stub that records iteration counts. Phase 4 swaps in
critique-theater scoring + build-test signals without touching this
public contract.

Daemon tests: 1434 → 1438 (added plugins-pipeline-runner with 4 cases:
event sequence, devloop convergence, e2e-5 cross-conversation cache,
stage-bound surface gating).

Co-authored-by: Tom Huang <1043269994@qq.com>
2026-05-09 11:29:57 +00:00