diff --git a/.changeset/workflow-custom-columns-traits.md b/.changeset/workflow-custom-columns-traits.md new file mode 100644 index 0000000000..ace3140c36 --- /dev/null +++ b/.changeset/workflow-custom-columns-traits.md @@ -0,0 +1,9 @@ +--- +"@runfusion/fusion": minor +--- + +Add workflow-defined custom columns with composable traits, behind the `experimentalFeatures.workflowColumns` flag (off by default). + +Workflows can now define their own columns, each carrying composable traits (declarative flags plus lifecycle hooks) instead of the fixed `triage → todo → in-progress → in-review → done → archived` pipeline. The dashboard board renders one lane per workflow in use, and graphs gain `hold`, `split`, and `join` nodes for passive dwell and parallel fan-out/join branches. The built-in default workflow reproduces today's pipeline verbatim, and migration rewrites zero task rows — a null workflow selection resolves to the default workflow at read time. With the flag off, the legacy board, transitions, and engine behavior are unchanged. + +**ROLLBACK:** Workflow IR now has a `v2` on-disk shape (custom columns + `hold`/`split`/`join` nodes). Pre-v2 binaries hard-reject any IR whose `version !== 'v1'`, so a naive downgrade would brick rows that had been re-serialized as v2. To keep rollback safe, the store downgrades a workflow back to the `v1` shape on save whenever (a) the `experimentalFeatures.workflowColumns` flag is OFF, and (b) the graph is "pure v1" — only `start`/`prompt`/`script`/`gate`/`end` nodes, no `hold`/`split`/`join`, and exactly the synthesized default columns at their default seam-derived placement. v2 is persisted only when the flag is ON or a genuine v2 feature (custom column, applied trait, custom placement, or a v2-only node) is in use. Reading a downgraded `v1` row on a v2 binary re-upgrades it to the identical v2 graph, so this is lossless. Rollback is therefore only unsafe for workflows that actually use v2 features with the flag ON; turn the flag OFF and re-save such workflows (or delete them) before downgrading to a pre-v2 binary. diff --git a/CONCEPTS.md b/CONCEPTS.md index 0825bb7ffb..7d892fe025 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -141,6 +141,30 @@ A plugin that ships inside the Fusion distribution itself rather than being inst *Avoid:* built-in plugin (as a distinct concept; the Settings label uses "Built-in" for the same thing) A Bundled Plugin must be registered in several independently maintained surfaces — the Settings catalog, the dashboard server's bundled-id fallback set, the CLI's startup auto-install list, and the build step that stages a loadable copy into the distribution. The surfaces do not cross-check each other: a plugin registered in some but not all appears installable yet fails to install or load, so adding one means mirroring an existing bundled plugin across every surface. +## Workflow columns & traits + +*Behind the `experimentalFeatures.workflowColumns` flag. With the flag off, the legacy fixed pipeline (the closed column enum + `VALID_TRANSITIONS`) is authoritative and unchanged.* + +### Column (workflow-defined) +A first-class, workflow-defined unit of task state: an id, a display name, and a set of Trait configurations. A Task's board position is its current column, persisted in `tasks."column"`. Column validity is workflow-scoped — the legacy closed enum widens to a string validated against the Task's resolved workflow. The Default workflow's column ids are byte-identical to the legacy enum values, so no task row is ever rewritten. + +### Trait +Composable column configuration: declarative flags (e.g. `complete`, `archived`, `countsTowardWip`) plus optional lifecycle hooks (`guard`, `gate`, `onEnter`, `onExit`, `releaseCondition`). Built-in and plugin-contributed traits register through one registry. Sync `guard` hooks and the `complete`/`archived` flags are built-in-only; plugin traits get async hook points only. A column's effective flags are the merged flags of its traits; conflicting compositions are rejected at save (server-side and in the editor). + +### Lane +A horizontal row on the multi-lane board, one per workflow in use by visible cards. Each lane renders its own workflow's columns. Tasks with no workflow selection appear in the Default workflow's lane; every card appears in exactly one lane. Zero-card lanes are hidden; lanes are collapsible with persisted state. + +### Hold node +A workflow node kind expressing passive dwell — a card rests in its column until a release condition fires: manual promote, timer, downstream capacity available, dependency satisfied, or external event. Hold release is evaluated by a substrate sweep (the generalized scheduler), which reserves worktree + semaphore slots before issuing the release move. + +### Split / Join +Parallel-branch node kinds. A `split` launches its outgoing edges concurrently; a `join` synchronizes them with `mode: all | any | quorum(n)` and `onBranchFailure: fail-fast | collect`. During the parallel window the card stays in the split's column (its board position never forks); on join resolution it advances to the join's column. `execute`/`merge` seam nodes are forbidden inside branches (one worktree/session per task; merge is exclusive). Per-branch run state persists in SQLite so a crashed branch resumes where it died. + +### Default workflow +The built-in workflow (`builtin:coding`) that reproduces the legacy pipeline verbatim: six columns whose ids equal the legacy enum values, with traits matching legacy semantics (`triage`=intake, `todo`=hold+reset-on-entry, `in-progress`=wip+abort-on-exit+timing, `in-review`=merge-blocker+stall-detection+merge, `done`=complete, `archived`=archived). A null workflow selection resolves to it at read time. Non-editable, non-deletable. + +### transitionPending +A persisted crash-safe marker (`tasks.transitionPending`) written in the same transaction as a column change, recording the post-commit hooks (`hooksRemaining`) that still owe idempotent execution. Cleared once they complete. Recovery reads it exclusively from SQLite (the authoritative store); a crash mid-transition re-runs the idempotent hooks. A throwing or missing hook degrades (audit) and clears its entry — it never strands the card or wedges the task lock. ## Flagged ambiguities diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 288ee955b5..2c25c2b751 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -20,6 +20,7 @@ A comprehensive guide to creating Fusion plugins that extend the task board with 14. [Example Plugins](#14-example-plugins) 15. [Registering Skills](#15-registering-skills) 16. [Registering Workflow Steps](#16-registering-workflow-steps) +16.5. [Contributing Column Traits](#165-contributing-column-traits) 17. [Contributing Prompt Modifications](#17-contributing-prompt-modifications) 18. [Plugin Binary Setup Hooks](#18-plugin-binary-setup-hooks) @@ -1476,6 +1477,134 @@ Use `mode: "prompt" | "script"` and `toolMode: "readonly" | "coding"`. Plugin-contributed workflow steps are materialized through core `resolvePluginWorkflowStep(...)`; `mode`, `phase`, `scriptName`, `toolMode`, `defaultOn`, `modelProvider`, and `modelId` are preserved from your contribution (with defaults when omitted). +## 16.5. Contributing Column Traits + +> Requires the `experimentalFeatures.workflowColumns` flag. Traits are the +> composable building blocks of workflow-defined columns (declarative flags + +> lifecycle hooks). Plugins contribute traits the same way they contribute +> workflow steps: declare them on the plugin object and the engine aggregates, +> caches, and invalidates them through the `PluginRunner` (mirroring +> `workflowSteps`). + +A plugin trait is registered into the core trait registry under a +plugin-namespaced id `plugin::`, so it can never collide +with a built-in trait or another plugin's trait, and it resolves through the +same registry lookup as the 14 built-in traits. + +```typescript +import type { PluginTraitContribution } from "@fusion/plugin-sdk"; + +const traits: PluginTraitContribution[] = [ + { + traitId: "security-approval", + name: "Security Approval Gate", + description: "Holds a card until a security review prompt passes.", + schemaVersion: 1, + flags: { gate: true }, + hooks: { + gate: { + mode: "prompt", + prompt: "Approve this change for security-sensitive paths?", + gateMode: "blocking", + }, + }, + }, + { + traitId: "slack-notify", + name: "Slack Notify", + description: "Posts to Slack when a card enters/leaves the column.", + schemaVersion: 1, + flags: { notify: true }, + hooks: { + onEnter: { mode: "script", scriptName: "slack-notify-enter" }, + onExit: { mode: "script", scriptName: "slack-notify-exit" }, + }, + }, +]; + +export default definePlugin({ + manifest: { id: "my-plugin", name: "My Plugin", version: "1.0.0" }, + hooks: {}, + traits, +}); +``` + +### Contribution shape + +| Field | Required | Notes | +|---|---|---| +| `traitId` | yes | kebab-case slug, unique within the plugin | +| `name` | yes | display name | +| `description` | no | UI description | +| `schemaVersion` | yes | must be `1` — the versioned hook-descriptor contract (see below) | +| `flags` | no | declarative flags (restricted flags rejected, see below) | +| `configSchema` | no | declarative config fields (`{ fields: [...] }`) | +| `hooks` | no | async hook descriptors (see below) | + +### Hook points (async only) + +Plugin traits get **async hook points only**: + +- `gate` — evaluated **before** a card moves into the column (pre-move, outside + the task lock). The verdict is recorded and re-checked cheaply when the move + commits. +- `onEnter` / `onExit` — post-commit, async, idempotent effects. +- `releaseCondition` — evaluated by the hold/release sweep for `hold` columns. + +The synchronous `guard` hook point is **built-in-only** and is rejected at +validation. Sync guards run inside the task lock and must be fast and pure — a +plugin hook there could wedge the lock, so plugins use the async `gate` surface +instead. + +Each hook descriptor mirrors the workflow-step shape: + +```typescript +{ mode: "prompt" | "script", prompt?: string, scriptName?: string, gateMode?: "blocking" | "advisory" } +``` + +Hooks execute through the **same prompt-session / script / verdict machinery** +contributed workflow steps use — plugin trait code never runs raw in-process. + +### Gate semantics + +- `gateMode: "blocking"` (default for gates) **fails closed**: a non-pass + verdict — or no recorded verdict at move time — rejects the move with a typed + `TransitionRejection`. +- `gateMode: "advisory"` **records and allows**: the verdict is logged but the + move proceeds. +- Engine-sourced and recovery moves bypass gates entirely (they carry + `bypassGuards`), so self-healing is never blocked by a plugin gate. + +### Restricted flags + +A plugin trait may **not** declare these flags (rejected at validation, and as a +backstop at registry registration): + +- `complete` — a terminal-success column that silently satisfies dependencies. +- `archived` — globally hidden column semantics. + +A plugin needing those semantics composes its trait **alongside** the built-in +`complete` / `archived` trait on the same column. + +### Versioned hook-descriptor schema + +`schemaVersion: 1` is required. It pins the hook-descriptor contract so the +built-in trait vocabulary can grow additively (new flags, hook points, config +fields) without breaking already-published plugin traits. Validate your +contribution with `validatePluginTraitContribution(...)` from +`@fusion/plugin-sdk`. + +### Disabling a plugin with live dependents + +If a card is currently sitting in a column that uses one of your plugin's +traits, disabling/uninstalling the plugin is **blocked** with a typed error +listing the dependent tasks (mirroring the built-in-workflow deletion block). + +A **force** path degrades the affected columns to **passive**: the trait's hooks +become no-ops (the registry resolves them to a no-op plus an audit warning), a +single audit event is emitted, and the cards remain fully movable. A degraded +gate column never blocks a card. + ## 17. Contributing Prompt Modifications Prompt contributions let a plugin inject additional instructions into specific prompt surfaces. diff --git a/docs/architecture.md b/docs/architecture.md index 98469f2b47..b23c6e640a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1230,6 +1230,23 @@ Detection is visibility-only: no scheduler/self-healing actions are triggered by Tune sensitivity by adjusting the exported constants in `stalled-review-detector.ts`. Increase thresholds to reduce noise; decrease thresholds only with incident evidence, because lower values can over-flag transient recovery bursts. +--- + +### Workflow-defined columns & traits (`experimentalFeatures.workflowColumns`) + +*Behind the `workflowColumns` flag (accessor: `packages/core/src/workflow-columns-settings.ts`). With the flag off the legacy pipeline above is authoritative and untouched. The flag default-flips only when the graduation report (below) shows zero drift — a field decision, not yet taken.* + +**Engine as substrate, workflows as policy.** The flag inverts the architecture: the engine becomes a **capability substrate** (worktree/git/session mechanics, persistence, crash recovery, audit, machine resource ceilings — non-configurable) and **workflows carry the operating logic** as composable column traits. The mechanism/policy line (KTD-4): + +- **Substrate (engine-owned, never workflow-configurable):** `AgentSemaphore`, checkout leases, worktree/git/session ops, SQLite + WAL, the crash-recovery machinery, the audit trail, the global max-sessions cap, and the three non-configurable lost-work merge guards (no sibling `fusion/fn-*` target, line-anchored attribution, no `modifiedFiles` clear on a no-op finalize). +- **Policy (workflow/trait-owned):** transition validity, WIP/capacity, hold/release, drag meaning, retries, merge strategy, squash posture, file-scope enforcement mode. + +**Transition authority.** `moveTaskInternal` remains the single transition authority. Flag-on, it swaps the `VALID_TRANSITIONS` lookup for workflow-resolved column-graph validation (`resolveAllowedColumns`/`workflowHasColumn` in `workflow-transitions.ts`) plus sync trait guards run in-lock; rejections are typed `TransitionRejection`s. `VALID_TRANSITIONS` and the closed `Column`/`COLUMNS` helpers in `types.ts` are `@deprecated` while the flag exists — retained as the flag-off authority and the parity oracle, not yet removed. + +**Trait model.** A trait is declarative flags + optional lifecycle hooks (`guard`, `gate`, `onEnter`, `onExit`, `releaseCondition`), resolved through one registry (`trait-registry.ts`, built-ins in `builtin-traits.ts`). Sync `guard` and the `complete`/`archived` flags are built-in-only; plugin traits (KTD-7) get async hook points only and route through the prompt-session/script machinery. Composition conflicts are rejected at save both in the editor and server-side (`assertColumnTraitsValid` in `createWorkflowDefinition`/`updateWorkflowDefinition`, surfaced as a 400). Capacity is enforced in-txn (KTD-10), never bypassable — not a guard. Enter/exit effects run post-commit, idempotent, guarded by the `transitionPending` marker; a throwing/missing plugin hook degrades (audit) and never strands the card or wedges the lock. + +**Graduation.** The flag default-flip is gated by `computeWorkflowColumnsGraduationReport()` (`workflow-parity.ts`; store method `TaskStore.computeWorkflowColumnsGraduationReport`), aggregating: five-invariant dual-observe parity, default-workflow transition parity vs `VALID_TRANSITIONS` (`checkTransitionParity`), and the U6 dual-accept marker/column disagreement count. `ready` is true only when all gates pass over a non-empty observation window. The report is the gate; it does not flip the flag. + ## 10) Agent System Fusion has two complementary agent models: diff --git a/docs/plans/2026-06-03-002-feat-workflow-interpreter-cutover-plan.md b/docs/plans/2026-06-03-002-feat-workflow-interpreter-cutover-plan.md index 8e0db0e2b0..93cd5b76ff 100644 --- a/docs/plans/2026-06-03-002-feat-workflow-interpreter-cutover-plan.md +++ b/docs/plans/2026-06-03-002-feat-workflow-interpreter-cutover-plan.md @@ -1,7 +1,7 @@ --- title: "feat: Workflow interpreter cutover — graph owns the full lifecycle" type: feat -status: active +status: superseded date: 2026-06-03 depth: deep origin: docs/plans/2026-06-03-001-feat-executable-custom-workflows-node-editor-plan.md (Deferred Track) diff --git a/docs/plans/2026-06-03-003-feat-workflow-custom-columns-traits-plan.md b/docs/plans/2026-06-03-003-feat-workflow-custom-columns-traits-plan.md new file mode 100644 index 0000000000..1b93c91c66 --- /dev/null +++ b/docs/plans/2026-06-03-003-feat-workflow-custom-columns-traits-plan.md @@ -0,0 +1,499 @@ +--- +title: "feat: Workflow-defined custom columns — engine as substrate, workflows as operating logic" +type: feat +status: completed +date: 2026-06-03 +depth: deep +origin: none (solo planning bootstrap) +supersedes: docs/plans/2026-06-03-002-feat-workflow-interpreter-cutover-plan.md +--- + +# feat: Workflow-defined custom columns — engine as substrate, workflows as operating logic + +## Summary + +Invert the architecture: the engine becomes a **capability substrate** (worktree/git/session mechanics, persistence, crash recovery, audit, resource ceilings) and **workflows become the operating logic**. Columns become first-class, workflow-defined task state carrying **composable, pluggable traits** (declarative flags + executable lifecycle hooks). All policy — state transitions, retries, WIP/capacity, hold/dwell states, drag semantics, merge/PR orchestration, merge strategy, squash contract, file-scope guards — moves into workflows as trait configuration over substrate capabilities. The dashboard board becomes multi-lane (one lane per workflow in use, each rendering its workflow's columns). Today's fixed `triage → todo → in-progress → in-review → done → archived` pipeline is recast as a built-in **default workflow** whose trait configuration reproduces current behavior verbatim, with a flag-gated, additive-only migration. Workflow graphs additionally gain **parallel fan-out/join branches** (`split`/`join` nodes), and the built-in trait vocabulary is fully defined in this plan (see Trait Vocabulary). + +--- + +## Problem Frame + +The executable-custom-workflows MVP (origin: `docs/plans/2026-06-03-001-feat-executable-custom-workflows-node-editor-plan.md`, completed) shipped persisted `WorkflowIr` definitions, a React Flow node editor, and per-task workflow selection. The interpreter-cutover track (plan 002, M-A–M-C implemented behind the `workflowGraphExecutor` flag) lets a graph drive execute → review → merge sequencing through seams. But both stop at the same ceiling: **the board model is still the engine's**. Columns are a closed enum (`COLUMNS`, `packages/core/src/types.ts:18`), the transition graph is a hardcoded constant (`VALID_TRANSITIONS`), scheduling is a hardcoded "pull from todo, run N agents" loop, and every reliability invariant is keyed to literal column names across ~158 files. + +Consequences: + +- A workflow cannot express its own lifecycle — e.g., a passive "Planning" holding column, planning processing in "Todo", then capacity-limited pickup into execution. The graph can only decorate the fixed pipeline. +- Custom workflows have no board presence: every task renders into the same six columns regardless of what its workflow actually does. +- Policy and mechanism are fused: `store.moveTask` (`packages/core/src/store.ts`, `moveTaskInternal`) hardcodes transition validity, merge-blocker gating, timing accounting, and reopen semantics in one branch-per-column-name function; the scheduler hardcodes WIP policy; the merger hardcodes merge policy alongside merge mechanics. + +The user has explicitly waived the FN-4359 reliability freeze for this track (carried over from plan 002's waiver). The five lifecycle invariants remain the correctness bar — but as **trait configuration of the default workflow**, not as hardcoded law. + +--- + +## Scope Boundaries + +### In scope + +- Workflow-defined columns with composable traits (flags + lifecycle hooks); trait registry with built-in and plugin-contributed traits sharing one interface; the full built-in trait vocabulary (see Trait Vocabulary). +- In-graph parallelism: `split`/`join` node kinds with `all | any | quorum(n)` join modes, fail-fast or collect failure policies, and per-branch crash-recoverable run state (U13). +- Policy inversion: transitions, retries, WIP/capacity, hold/release, drag semantics, merge/PR orchestration, merge strategy, squash contract, file-scope guard configuration — all workflow-expressed. +- Substrate hardening: the engine keeps mechanisms (worktree/git/session ops, `AgentSemaphore`/leases, persistence, crash recovery, audit, machine resource ceilings) exposed as capabilities traits bind. +- Built-in default workflow reproducing today's pipeline and invariants verbatim; flag-gated cutover; one-time additive migration. +- Multi-lane dashboard board; node-editor support for columns, traits, and hold nodes. +- Graceful degradation for CLI TUI and mobile surfaces that don't yet understand custom columns. + +### Deferred to Follow-Up Work + +- **Removing the legacy engine pipeline code** — only after graduation (U12) proves parity in the field; tracked as the absorbed FN-5719 Phase 4 follow-up. +- Workflow/trait versioning and history, import/export, cross-project workflow sharing. +- Mobile/desktop native surfaces — the mobile app embeds the dashboard web UI and inherits the multi-lane board from U9; `packages/mobile/` contains only native-shell bridging plugins (no task-list views), so there is no mobile degradation work in this plan. Any future native task-list surface is follow-up work. +- Concurrent execution of `execute`/`merge` seam nodes within parallel branches — one worktree/session per task and exclusive merge are physical constraints; the U1 validator rejects such graphs. + +### Outside this product's identity + +- A general-purpose BPM engine: traits exist to run *coding-agent task lifecycles*, not arbitrary business processes. No human-task assignment systems, SLAs, or cross-system orchestration. +- Runtime plugin code isolation/sandboxing beyond the existing install-time trust model — plugin trait hooks route through the existing prompt-session/script machinery (KTD-7); a vm-level sandbox is a separate product decision. + +--- + +## Key Technical Decisions + +- **KTD-1 — Default workflow column IDs are the legacy enum values.** The default workflow's columns are `triage`, `todo`, `in-progress`, `in-review`, `done`, `archived` — byte-identical to today's `tasks."column"` values. Migration therefore rewrites **zero task rows**: a task with no workflow selection resolves to the default workflow, and its stored column value is already a valid column ID in it. `Column` widens from a closed union to `string` validated against the task's workflow definition. + +- **KTD-2 — A trait is declarative flags + optional executable lifecycle hooks, with two guard classes.** Hook points: `guard(move)`, `gate(move)`, `onEnter(task)`, `onExit(task)`, `releaseCondition(task)`. **Sync guards** run inside `withTaskLock`/`moveTaskInternal` and must be fast and pure (DB reads only) — **built-in traits only**. **Async gates** (script/prompt verdicts) are evaluated *before* the move is attempted, outside the lock; the verdict is recorded and re-checked cheaply (a DB read) in-lock at move time — this is the plugin-facing gate surface, and it removes any path where plugin code can block or wedge the task lock. Enter/exit effects run **post-commit, async, idempotent**, tracked by a persisted `transitionPending` marker so crash-mid-transition is recoverable by re-running the idempotent hook (see KTD-9); `transitionPending` recovery reads **exclusively from SQLite** (the authoritative store per ADR-0001 — `task.json` is a follower written post-commit and may be stale across a crash). A hook failure degrades that column's behavior with an audit event — it never strands the card or wedges the task lock. + +- **KTD-3 — `moveTaskInternal` remains the single transition authority.** It swaps its `VALID_TRANSITIONS` lookup for workflow-resolved column-graph validation plus trait guards, but stays the only sanctioned path — all 40+ self-healing call sites, the scheduler, drag handlers, and seams keep calling it. Guard rejections become a **typed `TransitionRejection`** (reason code, user-facing message key, retryable flag) replacing today's thrown strings, so dashboard drops, CLI, and recovery share one rejection contract. + +- **KTD-4 — The mechanism/policy line.** Substrate (engine-owned, not workflow-configurable): `AgentSemaphore`, checkout leases (409 = hard conflict), worktree/git/session operations, SQLite persistence + WAL, crash-recovery machinery, audit trail, machine resource ceilings (a global max-sessions cap survives as physical governance). Policy (workflow/trait-owned): everything currently keyed on a column literal — transition validity, WIP counts and where they apply, retries, hold/release, drag meaning, merge strategy, squash posture, file-scope enforcement mode. This is DAG ADR-0001's enqueue-only posture generalized: traits *configure and invoke* capabilities; they never reimplement them. + +- **KTD-5 — No reserved column names; completion and archival are trait flags.** `complete: true` and `archived: true` trait flags replace string equality on `done`/`archived` everywhere: dependency satisfaction (`scheduler` gating becomes "dependency's task is in a `complete`-flagged column"), archive log, board filters, `clearDoneTransientFields`. Per FN-5719, scheduler dependency checks dual-accept (explicit handoff marker OR complete-flag column) with audit-diff logging during the transition window. + +- **KTD-6 — Merge is a trait that enqueues; it never awaits inside a graph walk.** The merge trait binds the persisted merge-request queue (`enqueueMergeQueue` / `pickNextMergeTaskId` / `ProjectEngine.onMerge` resolver) and configures policy: merge strategy (squash / merge-commit / rebase / PR-only), squash-contract posture, file-scope enforcement (`strict` / `warn` / `off` / custom scope rules), conflict strategy. The substrate merge capability keeps its mechanics (staging allowlist, deterministic subject, audit). The three 2026-05-23 lost-work guards are **capability-level and non-configurable**: never resolve a merge target to a sibling `fusion/fn-*` branch, line-anchored commit attribution only, never clear `modifiedFiles` on a no-op finalize that claimed work. **Identity note — the deliberate power-over-safety-floor posture:** integrity guarantees (the lost-work trio, git-state safety, crash recovery, audit) are non-configurable; *enforcement-mode* protections (`fileScope`, squash posture, review gates) are user-lowerable, but only inside an explicitly authored workflow — never as ambient settings — and the editor surfaces lowered floors visibly. A workflow *can* author itself footguns; it cannot author data loss. + +- **KTD-7 — Plugin traits follow the PluginRunner contribution pattern.** Plugins declare traits in their manifest (flags + hook descriptors), aggregated/cached/invalidated by `PluginRunner` exactly like `PluginWorkflowStepContribution`. Executable hooks route through the existing prompt-session/script/verdict machinery (readonly-tool-policy aware) — traits never get raw in-process execution. Plugin traits get **async hook points only** (`gate`, `onEnter`, `onExit`, `releaseCondition`); the sync `guard` hook point is built-in-only (KTD-2). **Restricted flags**: `complete`, `archived`, and sync-guard capability cannot be declared by plugin traits — a plugin trait declaring `complete: true` could silently satisfy dependencies and poison scheduling; plugins needing those semantics compose alongside the built-in traits on the same column. Disabling/uninstalling a plugin whose trait has live dependents (cards currently in columns using it) is blocked with a clear error, mirroring the built-in-workflow deletion block (`store.ts` `isBuiltinWorkflowId` guard); a force path degrades affected columns to passive (hooks become no-ops, audit event emitted, cards remain movable). + +- **KTD-8 — Flag-gated cutover; legacy path retained until graduation.** A new `experimentalFeatures.workflowColumns` flag gates the whole model. Off: the legacy enum/`VALID_TRANSITIONS` path runs untouched. On: workflow-resolved columns drive everything. The default workflow's parity is machine-checked (extending `compareWorkflowRunObservations` / `workflow-parity.ts` and a dedicated transition-parity suite) before the flag defaults on. This plan **supersedes plan 002**: its M-A–M-C implementations (seams, handlers, runner, executor wiring) are this plan's foundation; its M-D graduation criteria are absorbed into U12. + +- **KTD-9 — Single recovery authority; engine-sourced moves bypass trait hooks.** Self-healing keeps exclusive ownership of recovery transitions (FN-5335 triple-proof, FN-5704 non-oscillation). Moves with `moveSource: "engine"` bypass trait guards and `abort-on-exit` effects (the generalization of today's `skipMergeBlocker`), carrying an explicit `bypassGuards` field in the move options so the bypass is visible in audit. A workflow and self-healing never both claim transition authority over the same task: trait hooks observe engine moves (for bookkeeping like WIP counters, which must stay consistent) but cannot veto or side-effect them. + +- **KTD-10 — Capacity is enforced under the store transaction, not by trait code, and is never bypassable.** WIP limits are trait *configuration*; enforcement is a substrate capability: a per-(workflow, column) active-count check inside `moveTaskInternal`'s transaction plus the existing `AgentSemaphore` for session slots. The in-txn capacity check is **not a guard** — it runs regardless of `bypassGuards`, so engine/recovery/sweep moves honor it too. Hold release is a substrate sweep (the generalized scheduler) that evaluates `releaseCondition`s and calls `moveTask` with a distinct `moveSource: "scheduler"` — releases serialize through the same transaction-time check, so two holds can't release into one slot. Ordering contract with out-of-txn resources: the sweep **reserves worktree + semaphore slots before issuing the move** and releases the reservation if the move's capacity check rejects — a card is never moved into a processing column it cannot actually start in. The scheduler's three-gate diagnostic (`computeConcurrencyGateDiagnostic`) is preserved, generalized to report per-column capacity gates. + +- **KTD-11 — Fan-out runs branches concurrently; the card's board position does not fork.** `split` launches all outgoing edges concurrently; `join` synchronizes with `mode: all | any | quorum(n)` and `onBranchFailure: fail-fast` (cancel siblings via the abort machinery) or `collect` (wait for all, evaluate at join). During parallel execution the card **stays in the split node's column** with per-branch progress surfaced on the card; on join resolution it proceeds to the join's column — one-card-one-position (R16) holds by construction. `execute` and `merge` seam nodes are **forbidden inside branches** (one worktree/session per task; merge is exclusive — physics, not policy); prompt/script/gate nodes are allowed and stay bounded by `AgentSemaphore` + node capacity. Per-branch run state persists in SQLite so a crashed branch resumes where it died (ADR-0001). + +--- + +## Trait Vocabulary + +A trait is declarative flags + config + optional hooks (KTD-2's two guard classes: sync guards built-in-only; async gates are the plugin surface). The built-in set ships in U2: + +| Trait | Category | Config | Hooks | Notes | +|---|---|---|---|---| +| `intake` | identity | `autoTriage?` | — | Where new cards land; exactly one per workflow (validated) | +| `complete` | identity | — | — | Terminal success; satisfies dependencies. **Restricted flag** | +| `archived` | identity | — | — | Hidden from board; global semantics. **Restricted flag** | +| `merge-blocker` | gate | — | sync guard | Generalized FN-5147: entry to `complete`-bound columns blocked until the merge-class node completed (reads `getTaskMergeBlocker`) | +| `wip` | flow | `limit`, `countPending?` | — | Substrate-enforced in-txn (KTD-10); never bypassable | +| `hold` | flow | `release: manual \| timer \| capacity \| dependency \| external-event` | releaseCondition | Passive dwell; `external-event` = webhook/API release | +| `human-review` | gate | `approvers?`, `checklist?` | sync guard (exit) | Card cannot leave until explicit human approval (approval state is a DB read — sync-safe). **Not on the default workflow** — legacy in-review has no human gate; adding one would break R12 parity | +| `gate` | gate | `gateMode: blocking \| advisory`, prompt/script | async gate | Workflow-step gate semantics generalized to columns; the plugin-facing gate surface; blocking gates fail closed | +| `merge` | capability | `strategy`, `fileScope`, `squash`, `conflictStrategy` | onEnter (enqueues), onExit (dequeues) | KTD-6; `onExit` absorbs `dequeueMergeQueueOnColumnExit` | +| `abort-on-exit` | lifecycle | `direction: backward \| any`, `confirm?` | onExit | Generalized hard-cancel; bypassed by engine-sourced moves (KTD-9) | +| `reset-on-entry` | lifecycle | `preserveProgress?` | onEnter | Legacy reopen-to-todo field/step resets | +| `timing` | lifecycle | — | onEnter/onExit | `cumulativeActiveMs` accounting generalized | +| `stall-detection` | observability | `timeoutMs`, `action: annotate \| notify \| move` | (sweep-evaluated) | In-review stall signals generalized to any column | +| `notify` | observability | events, channel | onEnter/onExit | Basic notifications; richer notification traits are the canonical plugin example | + +**Default workflow mapping** (reproduces legacy behavior verbatim, R12): `triage` = `intake`; `todo` = `hold(capacity)` + `reset-on-entry`; `in-progress` = `wip(maxConcurrent)` + `abort-on-exit` + `timing`; `in-review` = `merge-blocker` + `stall-detection` + `merge`; `done` = `complete`; `archived` = `archived`. + +**Extension contract:** the plugin-facing `PluginTraitContribution` carries a **versioned hook-descriptor schema** so the vocabulary can grow additively (new flags, hook points, config fields) without breaking published plugin traits. + +--- + +## High-Level Technical Design + +### Component topology — substrate, traits, workflows + +```mermaid +flowchart TB + subgraph Workflows["Workflow layer (policy)"] + WD["WorkflowDefinition v2
columns + traits + nodes"] + DW["Built-in default workflow
(reproduces legacy pipeline)"] + PT["Plugin-contributed traits"] + end + + subgraph Registry["Trait registry"] + TR["TraitRegistry
built-in + plugin traits
one interface: flags + hooks"] + end + + subgraph Authority["Transition authority (core)"] + MT["moveTaskInternal
workflow-resolved validation
trait guards (sync, in-lock)
transitionPending marker"] + end + + subgraph Substrate["Engine substrate (mechanism)"] + CAP["Capabilities:
execute (sessions/worktrees)
merge (queue, squash, scope)
review, schedule"] + HOLD["Hold/capacity sweep
(generalized scheduler)"] + SH["Self-healing / recovery
(single recovery authority,
bypasses trait hooks)"] + DB[("SQLite
tasks, workflows,
task_workflow_selection")] + end + + WD --> TR + DW --> TR + PT --> TR + TR --> MT + WD -->|"node placement,
capacity config"| HOLD + HOLD -->|moveTask| MT + SH -->|"moveTask
(bypassGuards)"| MT + MT --> DB + TR -->|"hooks invoke
capabilities only"| CAP + CAP --> DB +``` + +### Transition sequence — guard in-lock, effects post-commit + +```mermaid +sequenceDiagram + participant Caller as Caller (drag / sweep / seam / recovery) + participant Store as moveTaskInternal (withTaskLock) + participant Traits as Trait guards/hooks + participant DB as SQLite + + Caller->>Store: moveTask(id, toColumn, opts) + Store->>Store: resolve task's workflow → column graph + alt opts.bypassGuards (engine/recovery move) + Store->>Store: skip guards & abort-on-exit + else user/workflow move + Store->>Traits: guard(from, to) — sync, fast, pure + Traits-->>Store: ok | TransitionRejection{code, msgKey, retryable} + end + Store->>DB: write column + transitionPending marker (one txn,
capacity check enforced here) + Store-->>Caller: moved (or typed rejection) + Store->>Traits: onExit(from) / onEnter(to) — async, idempotent + Traits->>DB: clear transitionPending on success + Note over Traits,DB: crash here → recovery re-runs idempotent
hooks from transitionPending marker +``` + +### Worked example — the "Planning hold" workflow (directional) + +``` +[Planning col] [Todo col] [In-Progress col] [Review col] [Done col] + traits: hold traits: wip(planning), hold traits: wip(2), traits: traits: + abort-on-exit human-review complete + hold ──(manual)──► prompt: plan ──► hold ──(capacity)──► seam: execute ──► seam: review ──► seam: merge ──► end + (merge trait enqueues) +``` + +Cards rest in Planning untouched → manual promote releases them → the planning prompt runs while the card sits in Todo → card rests as "ready" → pulled into In-Progress when a WIP slot frees → review under a human-review-trait column → merge trait enqueues onto the merge-request queue → card lands in the `complete`-flagged column. This sketch is directional guidance, not implementation specification. + +--- + +## Requirements + +**Column & workflow model** + +- R1. A workflow defines an ordered list of columns; each column has an ID, display name, and a set of trait configurations. +- R2. Workflow nodes are placed in columns; a card's board position derives from its current column (persisted in `tasks."column"`), which the workflow's graph and traits drive. +- R3. A `hold` node kind expresses passive dwell, with release conditions: manual promote, timer, downstream capacity available. +- R4. Column validity is workflow-scoped: the closed `Column` union and `VALID_TRANSITIONS` constant are replaced by per-workflow column graphs (legacy path retained behind the flag until graduation). + +**Traits** + +- R5. A trait is composable configuration: declarative flags plus optional lifecycle hooks (`guard`, `onEnter`, `onExit`, `releaseCondition`). +- R6. Built-in and plugin-contributed traits register through one registry interface; plugins declare traits via manifest contributions. +- R7. The built-in trait set is the Trait Vocabulary table: `intake`, `complete`, `archived`, `merge-blocker`, `wip`, `hold`, `human-review`, `gate`, `merge`, `abort-on-exit`, `reset-on-entry`, `timing`, `stall-detection`, `notify`. +- R8. Trait composition is validated at workflow save time; conflicting combinations are rejected with actionable messages. + +**Policy inversion** + +- R9. State transitions, retries, WIP/capacity limits, hold/release, and drag semantics are workflow-expressed; no engine code keys policy off literal column names when the flag is on. +- R10. Merge strategy, squash posture, and file-scope enforcement mode are workflow-configurable via the merge trait; the three lost-work guards remain capability-level and non-configurable. +- R11. The substrate retains mechanisms only: worktree/git/session operations, semaphore/leases, persistence, crash recovery, audit, machine resource ceilings. + +**Invariant preservation** + +- R12. The built-in default workflow reproduces current behavior verbatim: `VALID_TRANSITIONS` parity, FN-5147 terminal-until-merged, `in-progress → todo` hard-cancel, in-review stall detection, file-scope guard, squash contract — machine-checked by parity tests before graduation. +- R13. `moveTaskInternal` remains the single transition authority; guard rejections are typed (`TransitionRejection`) across all surfaces. +- R14. Engine-sourced moves (`moveSource: "engine"`) bypass trait guards and abort-on-exit effects; self-healing remains the single recovery authority. +- R15. No card is ever stranded: trait hook failure, plugin disable, workflow edit/delete, and crash mid-transition all have defined recovery paths. + +**Board & surfaces** + +- R16. The dashboard board renders one lane per workflow in use by visible cards (tasks without a selection appear in the default-workflow lane); every card appears in exactly one lane. +- R17. Drag-and-drop semantics are trait-defined; rejected drops surface the typed rejection reason to the user. +- R18. The CLI TUI degrades gracefully: cards in columns it doesn't recognize map by trait flags into its existing buckets or a read-only "other" bucket — never silently disappear. (Mobile embeds the dashboard web UI and inherits R16/R17.) + +**Migration & rollout** + +- R19. Migration is additive-only, forward-only, idempotent, and rewrites zero task rows (KTD-1); the whole model is gated by `experimentalFeatures.workflowColumns`. +- R20. Workflow edit/delete/switch with live cards follows a defined reconciliation policy (U5); no operation leaves a card in a column its workflow doesn't define. + +**Parallelism & trait governance** + +- R21. Workflow graphs support `split`/`join` parallel branches per KTD-11: join modes `all | any | quorum(n)`, fail-fast or collect failure policies, per-branch crash-recoverable state, seam nodes forbidden inside branches, and the card's board position never forks. +- R22. Plugin traits are restricted to async hook points (`gate`, `onEnter`, `onExit`, `releaseCondition`); the sync `guard` hook point and the `complete`/`archived` flags are built-in-only (KTD-2, KTD-7). + +--- + +## Implementation Units + +Phased for independent landability. Phases A–C make the model real behind the flag; D–E extend policy coverage; F ships the surfaces; G migrates and graduates. + +### Phase A — Column & trait model (core) + +### U1. WorkflowIr v2: columns, node placement, hold nodes + +- **Goal:** Extend the IR so workflows define columns and place nodes in them, with a `hold` node kind and release conditions plus `split`/`join` parallel-branch nodes, while v1 graphs keep parsing. +- **Requirements:** R1, R2, R3, R4 (model half), R21 (IR half) +- **Dependencies:** none +- **Files:** `packages/core/src/workflow-ir-types.ts`, `packages/core/src/workflow-ir.ts`, `packages/core/src/workflow-definition-types.ts`, `packages/core/src/builtin-coding-workflow-ir.ts`, `packages/core/src/builtin-workflows.ts`, `packages/core/src/__tests__/workflow-ir.test.ts`, `packages/core/src/__tests__/builtin-workflows.test.ts` +- **Approach:** Bump IR to `version: "v2"` (FN-5769 froze v1 — this is the explicit, budgeted contract change). Add `columns: [{id, name, traits: [{trait, config}]}]`, `node.column` placement, `kind: "hold"` with `release: manual | timer | capacity | dependency | external-event` config, and `kind: "split"` / `kind: "join"` (join config: `mode: all | any | quorum(n)`, `onBranchFailure: fail-fast | collect`). Validator rules for parallelism (KTD-11): every split has a reachable matching join (recursively for nested splits); `execute`/`merge` seam nodes inside a branch reject with a named error. `parseWorkflowIr` upgrades v1 graphs by synthesizing default-workflow columns and placing nodes by their seam (execute → `in-progress`, review → `in-review`, merge → `in-review`, others → `todo`) — this read-path upgrade also covers v1 IR JSON already persisted in `workflows` rows (no row rewrite; upgraded shape persists on next save). Extend `BUILTIN_CODING_WORKFLOW_IR` into the full default workflow: six columns whose IDs are the legacy enum values (KTD-1), traits matching legacy semantics, intake/hold/seam nodes reproducing the current pipeline. +- **Patterns to follow:** existing `parseWorkflowIr` validation + `WorkflowIrError` shape; `builtin:` ID prefix and read-only semantics from `builtin-workflows.ts`. +- **Test scenarios:** + - v2 graph with columns/placement/hold parses; node referencing an undefined column ID rejects with a named error. + - v1 graph parses and upgrades: nodes land in synthesized default columns by seam; round-trips through the editor mapping unchanged. + - Hold node with each release kind (manual/timer/capacity/dependency/external-event) parses; unknown release kind rejects. + - Split/join: balanced split-join parses (incl. one nested level); split without reachable join rejects; execute or merge seam node inside a branch rejects with the seam-in-branch error; quorum(n) with n exceeding branch count rejects. + - Default workflow: column IDs exactly equal the legacy enum values, in legacy order; built-in remains non-editable/non-deletable. + - Duplicate column IDs within a workflow reject at parse. +- **Verification:** `pnpm test` green in core; v1 fixtures from existing tests still parse. + +### U2. Trait registry and built-in trait definitions + +- **Goal:** One registry resolving trait IDs to definitions (flags + hooks) for both built-ins and (later) plugins; ship the built-in set. +- **Requirements:** R5, R6, R7, R8 +- **Dependencies:** U1 +- **Files:** `packages/core/src/trait-types.ts` (new), `packages/core/src/trait-registry.ts` (new), `packages/core/src/builtin-traits.ts` (new), `packages/core/src/__tests__/trait-registry.test.ts` (new), `packages/core/src/__tests__/builtin-traits.test.ts` (new) +- **Approach:** `TraitDefinition { id, flags, hooks? }` with flags like `countsTowardWip`, `complete`, `archived`, `hiddenFromBoard`, `abortOnExit`, `humanReview`, `intake`, and hook descriptors per KTD-2 (two guard classes: sync guard built-in-only; async gate for plugins). Built-ins: the full Trait Vocabulary table (`merge` config stub here; behavior in U7). Restricted-flag enforcement: registry rejects non-builtin registrations declaring `complete`/`archived`/sync-guard (R22). Save-time composition validator (R8): reject nonsense combos (e.g., `complete` + `countsTowardWip`, two capacity traits on one column) with named reason codes; re-validate persisted workflows at load and degrade (audit + advisory) rather than brick definitions that predate a newly added rule. Registry follows the DI/static-import conventions (no dynamic `@fusion/engine` imports; engine registers hook implementations into core's registry the way `setCreateFnAgent` does). +- **Patterns to follow:** `createDefaultNodeHandlers(seams, runCustomNode)` handler-injection shape in `packages/engine/src/workflow-node-handlers.ts`; core→engine DI seam (`setCreateFnAgent`). +- **Test scenarios:** + - Registering a duplicate trait ID rejects; `builtin:` namespace cannot be overridden by a non-builtin registration. + - Composition validator: each documented conflict pair rejects with its reason code; a valid default-workflow column set passes. + - Flag resolution: a column's effective flags are the merged flags of its traits; conflicting boolean flags reject at save, not at runtime. + - Hook descriptor without a registered implementation resolves to a no-op with an audit warning (degraded, not crashed). + - Restricted flags: a plugin-namespace registration declaring `complete: true` (or `archived`, or a sync guard) rejects with the restricted-flag reason code. + - Load-time re-validation: a persisted workflow violating a newly added composition rule loads degraded with an audit event, not an error. +- **Verification:** core tests green; default workflow's columns validate through the composition validator. + +### U3. Typed transition contract and `transitionPending` marker + +- **Goal:** The shared transition rejection type and the crash-safe hook protocol, before any behavior moves. +- **Requirements:** R13, R15 +- **Dependencies:** U1 +- **Files:** `packages/core/src/transition-types.ts` (new), `packages/core/src/db.ts` (migration slot: `tasks.transitionPending` JSON column via `addColumnIfMissing`), `packages/core/src/__tests__/transition-types.test.ts` (new) +- **Approach:** `TransitionRejection { code, messageKey, retryable }` + a `TransitionResult` union; reason codes for guard-rejected, capacity-exhausted, unknown-column, workflow-mismatch, merge-blocked. `transitionPending` persists `{toColumn, hooksRemaining, startedAt}` written in the same transaction as the column change (KTD-2); cleared when post-commit hooks complete. Happy-path dispatch owner: `moveTaskInternal` itself schedules the hook runner immediately after commit (fire-and-forget with audit); the recovery sweep is the backstop for crashes, not the primary driver. Additive-only migration in the next version-gated slot. +- **Patterns to follow:** `addColumnIfMissing` migration pattern in `packages/core/src/db.ts`; existing typed-error conventions. +- **Test scenarios:** + - Each rejection code serializes/deserializes across the API boundary shape. + - Marker lifecycle: set in-txn with the move, cleared after hooks; a simulated crash (hooks never run) leaves the marker recoverable with `hooksRemaining` intact. + - SQLite-authoritative recovery (KTD-2): crash between the SQLite commit and the post-txn `task.json` write — recovery reconciles from the SQLite row, not the stale JSON. + - `hooksRemaining` referencing a now-uninstalled plugin hook: recovery clears the entry with a degraded-hook audit event and completes the marker — the card is never stuck waiting for a missing handler. + - Migration is idempotent (runs twice without error) and a no-op on a fresh DB. +- **Verification:** core tests green; schema fingerprint compatibility check passes. + +### Phase B — Transition authority cutover + +### U4. Workflow-resolved transitions in `moveTaskInternal` + default-workflow parity + +- **Goal:** Replace `VALID_TRANSITIONS` lookup with workflow column-graph validation plus trait guards, behind the flag, with the default workflow proving verbatim parity. +- **Requirements:** R4, R9 (transition half), R12, R13, R14 +- **Dependencies:** U1, U2, U3 +- **Files:** `packages/core/src/store.ts` (`moveTaskInternal`, `handoffToReview`), `packages/core/src/board.ts`, `packages/core/src/task-merge.ts` (terminal-guard reads `getTaskMergeBlocker`), `packages/core/src/workflow-parity.ts`, `packages/core/src/__tests__/move-task-workflow.test.ts` (new), `packages/core/src/__tests__/transition-parity.test.ts` (new) +- **Approach:** Flag on: resolve the task's workflow (null selection → default workflow), validate the move against its column graph, run trait guards sync-in-lock, enforce capacity in-txn (KTD-10 — enforcement lands fully in U6; the txn-time check slot is created here), write `transitionPending`, run effects post-commit. Flag off: legacy path byte-identical. Engine moves carry `bypassGuards` (KTD-9), subsuming `skipMergeBlocker` — the terminal-guard trait on `in-review` reads the same `getTaskMergeBlocker`, and `handoffToReview`'s bypass maps onto `bypassGuards`. Legacy per-column side effects (timing/`cumulativeActiveMs`, reopen field resets, autoMerge stamping + merge-queue enqueue) become the default workflow's trait hook implementations — moved, not duplicated. **Worktree allocation is explicitly NOT migrated to hooks**: it stays a substrate capability invoked synchronously by the scheduler *before* the move (the scheduler depends on allocation-before-session ordering; a post-commit async hook would break it). `bypassGuards` is engine-internal: API move endpoints hardcode it off and never forward a caller-supplied value (same posture as the existing hardcoded `moveSource: "user"`). +- **Execution note:** Characterization-first. Before changing `moveTaskInternal`, add a characterization suite capturing current `VALID_TRANSITIONS` outcomes and side effects for every (from, to, moveSource) combination that has a call site; the trait-driven path must reproduce it exactly. +- **Test scenarios:** + - Transition-parity: for every legacy (from, to) pair, flag-on default-workflow validation matches `VALID_TRANSITIONS` exactly (allowed and rejected sets identical). + - FN-5147: `autoMerge:false` task in `in-review` — engine-sourced backward moves rejected/annotation-only exactly as today; user move to `done` blocked by the terminal-guard with the merge-blocked rejection code. + - Hard-cancel: user drag `in-progress → todo` triggers abort-on-exit (session abort) and sets `userPaused`; engine-sourced same move bypasses abort-on-exit and does not set `userPaused`. + - `handoffToReview` succeeds with `bypassGuards` mapping; autoMerge stamping + merge-queue enqueue fire via the default workflow's onEnter hook identically to legacy. + - Guard rejection returns typed `TransitionRejection` (not a thrown string); legacy flag-off path still throws the legacy strings (no behavior change while off). + - Crash-mid-transition: marker present, hooks re-run idempotently on recovery sweep; double-running onEnter is a no-op. + - Unknown column for the task's workflow → `unknown-column` rejection, card untouched. + - Worktree ordering: under the flag, a scheduled pickup allocates the worktree before the move commits and before session start (the not-a-hook classification above). + - `bypassGuards` hardening: the HTTP move endpoint ignores a caller-supplied `bypassGuards: true`. + - Capacity deliberately unenforced until U6: a documenting test asserts the U4 capacity-check slot is a pass-through, and no U4 flag-on test exercises a WIP-constrained scenario (prevents misleading green between U4 and U6 landings). + - Handoff enqueue exactly-once: simulated crash between the column commit and the merge-enqueue onEnter hook → recovery re-runs the hook and the queue holds exactly one entry. +- **Verification:** characterization + parity suites green flag-on and flag-off; full existing store/self-healing/scheduler suites green with flag off (zero behavior change) and with flag on (parity). + +### U5. Workflow lifecycle reconciliation: switch, edit, delete with live cards + +- **Goal:** Defined behavior whenever a card's column could stop existing under it. +- **Requirements:** R15, R20 +- **Dependencies:** U4 +- **Files:** `packages/core/src/store.ts` (workflow CRUD + selection paths, `deleteWorkflowDefinition`), `packages/core/src/workflow-reconciliation.ts` (new), `packages/core/src/__tests__/workflow-reconciliation.test.ts` (new), `packages/dashboard/src/routes/register-workflow-routes.ts`, `packages/dashboard/src/routes/register-task-workflow-routes.ts` +- **Approach:** Policy: (a) **workflow switch** — card maps to the new workflow's entry (intake-flagged or first) column unless the new workflow defines a column with the same ID, which is preserved; in-flight processing nodes are aborted via the same abort-on-exit machinery first. (b) **workflow edit removing an occupied column** — save is blocked with a typed error listing occupant counts, plus an explicit "save and re-home occupants to column X" option in the API contract. (c) **workflow delete** — extends the existing cascade (`deleteWorkflowDefinition`): blocked for built-ins as today; for custom workflows, occupants re-home to the default workflow's entry column with selection rows cleared, one audit event per card. No path leaves a card in an undefined column (invariant test). +- **Test scenarios:** + - Switch with same-ID column preserves position; without, lands in entry column; active session aborted first. + - Edit removing occupied column blocks with occupant count; re-home option moves all occupants and emits audits. + - Delete with occupants re-homes to default entry, clears selection, preserves task fields (`preserveProgress` semantics). + - Property-style invariant: after any sequence of switch/edit/delete operations, every task's column exists in its resolved workflow. + - Concurrent move-vs-delete: task lock ordering means the card ends either moved-then-re-homed or re-homed; never lost. +- **Verification:** reconciliation suite green; existing workflow CRUD tests green. + +### Phase C — Scheduling & capacity + +### U6. Capacity enforcement and the hold/release sweep (generalized scheduler) + +- **Goal:** WIP/capacity as trait config enforced in-txn; hold release conditions evaluated by a substrate sweep; dependency satisfaction via complete-flag. +- **Requirements:** R3 (behavior half), R9 (capacity half), R11 +- **Dependencies:** U4 +- **Files:** `packages/engine/src/scheduler.ts`, `packages/engine/src/concurrency.ts`, `packages/core/src/store.ts` (in-txn capacity check), `packages/engine/src/hold-release.ts` (new), `packages/engine/src/__tests__/hold-release.test.ts` (new), `packages/engine/src/__tests__/scheduler.test.ts` +- **Approach:** Flag on, the scheduler becomes the hold/release sweep: for each workflow in use, evaluate hold nodes' release conditions (manual flags, timers via fake-timer-friendly clock, capacity-available against per-column WIP config) and call `moveTask` for eligible cards — releases serialize through the in-txn capacity check (KTD-10), with `AgentSemaphore` still gating actual session starts. Legacy `maxConcurrent` maps to the default workflow's `in-progress` WIP config so settings carry over. Sweep moves carry `moveSource: "scheduler"` and reserve worktree + semaphore before issuing the move, releasing the reservation on a capacity rejection (KTD-10). Dependency gating switches to complete-flag with FN-5719 dual-accept (handoff marker OR complete column) + audit-diff logging; the dual-accept window **closes at graduation** (U12), and any marker/column disagreement above zero during the observation period blocks graduation rather than just logging. The hold/release sweep inherits the scheduler's existing poll-interval setting. `computeConcurrencyGateDiagnostic` generalizes to per-column capacity gates, preserving the three-gate report shape. +- **Test scenarios:** + - Two holds, one slot: exactly one releases (txn-time check); the other releases on the next sweep after the slot frees. + - Timer release fires at its deadline under fake timers; manual release only on the explicit promote call. + - Capacity-available release respects downstream WIP including cards mid-`transitionPending`. + - Dependency in a custom workflow's complete-flagged column unblocks a dependent in another workflow; dual-accept logs a diff when marker and column disagree. + - Legacy parity: flag-on default workflow with `maxConcurrent: 2` schedules identically to flag-off legacy scheduler (same pickup order, same gating diagnostics). + - Paused/recovery-backoff tasks (`nextRecoveryAt`) are skipped exactly as today. + - Sweep release into a full column is rejected by the in-txn capacity check even though `moveSource: "scheduler"` bypasses trait guards — capacity is not a guard. + - Interleaving: column capacity check passes but the semaphore is exhausted — the reservation-first ordering means the move never commits; reservation released, card stays held. + - In-txn WIP count includes cards mid-`transitionPending` (they hold their destination slot from commit time). +- **Verification:** scheduler + hold-release suites green; no slow tests (fake timers per FN-5048). + +### U13. Fan-out/join branch execution + +- **Goal:** `WorkflowGraphExecutor` walks `split`/`join` graphs concurrently with crash-recoverable per-branch state. +- **Requirements:** R21 +- **Dependencies:** U1, U4 +- **Files:** `packages/engine/src/workflow-graph-executor.ts` (extend existing), `packages/engine/src/workflow-graph-task-runner.ts` (extend existing), `packages/core/src/db.ts` (per-branch run-state persistence, additive), `packages/engine/src/__tests__/workflow-graph-fanout.test.ts` (new) +- **Approach:** Branch walking via concurrent node execution per branch with per-branch persisted state (branch id, current node, status) written through the existing run-state path so a restart resumes each branch where it died (ADR-0001 reconstructibility). Join synchronization per KTD-11 (`all | any | quorum(n)`); `fail-fast` cancels sibling branches through the same abort machinery as `abort-on-exit`; `collect` waits and exposes branch outcomes to the join's outgoing edge conditions. The card's column stays at the split's column during parallel execution, advancing on join resolution (KTD-11); branch progress is exposed on the task record for U9's per-branch badges. Branch node sessions remain bounded by `AgentSemaphore` + node capacity. +- **Test scenarios:** + - Two-branch split, `mode: all`: both branches complete (any order, fake timers), join fires once, card advances to the join's column. + - `mode: any`: first branch completion fires the join; the slower branch is cancelled (fail-fast) or allowed to finish without re-firing the join (collect). + - `quorum(2)` of 3: join fires on the second completion; third branch handled per failure policy. + - Branch failure under `fail-fast`: siblings receive aborts; join routes the failure edge. Under `collect`: all branches finish; join evaluates combined outcomes. + - Crash mid-branch: restart resumes the incomplete branch from its persisted node without re-running completed branches' nodes (idempotency assertion). + - Card position invariant: task's column equals the split's column for the entire parallel window, never a branch node's column. + - Concurrency bound: branches queue on `AgentSemaphore` when slots are exhausted rather than oversubscribing. +- **Verification:** fan-out suite green; existing sequential-walk executor tests unchanged (no regression for linear graphs). + +### Phase D — Merge & policy traits + +### U7. Merge trait: enqueue-only orchestration with configurable policy + +- **Goal:** Merge/PR orchestration, merge strategy, squash posture, and file-scope mode become merge-trait configuration over the substrate merge capability. +- **Requirements:** R10 +- **Dependencies:** U4 +- **Files:** `packages/engine/src/merge-trait.ts` (new), `packages/engine/src/merger.ts` (read policy from trait config; mechanics untouched), `packages/engine/src/__tests__/merge-trait.test.ts` (new), `packages/core/src/builtin-traits.ts` (merge trait config schema) +- **Approach:** The merge trait's onEnter enqueues onto the persisted merge-request queue and resolves via the queue's completion callback — never awaited inside a graph walk or a transition (KTD-6, the deadlock hazard plan 002 flagged). Config: `strategy` (maps onto existing `directMergeCommitStrategy` values + PR-only), `fileScope` (`strict`/`warn`/`off`/custom rules → feeds the existing `FileScopeViolationError` check and `scopeOverride` path), `squash` posture, `conflictStrategy`. Existing settings knobs become the default workflow's merge-trait config (settings read-through for back-compat). The merge trait's `onExit` absorbs the existing `dequeueMergeQueueOnColumnExit` behavior (leaving the merge column dequeues a pending request) — moved, not duplicated. The `fileScope: "warn"` audit event carries the violating file list (same payload as `FileScopeViolationError`); `fileScope: "off"` still emits one per-merge audit event recording that scope enforcement was disabled by workflow config, and per-task `scopeOverride` is a documented no-op in that mode. The three lost-work guards stay in `merger.ts` mechanics, unreachable from config. +- **Test scenarios:** + - Each strategy value routes to the existing merger behavior it names; PR-only enqueues a PR flow without direct merge. + - `fileScope: "off"` skips the violation throw; `"warn"` logs + proceeds; `"strict"` matches today; custom rules evaluated. + - Lost-work regression trio: sibling `fusion/fn-*` merge target rejected regardless of config; attribution remains line-anchored; no-op finalize with claimed work blocks and preserves `modifiedFiles` (moves back with progress, emits `task:finalize-lost-work-blocked`). + - Merge completion drives the card to the next column via the queue callback, not inline; a queued merge surviving restart resumes from SQLite state. +- **Verification:** merge-trait + existing merger suites green; lost-work regression tests in place. + +### Phase E — Pluggable traits + +### U8. Plugin-contributed traits + +- **Goal:** Plugins declare traits; hooks execute through existing machinery; live-dependent protection. +- **Requirements:** R6, R15 (plugin half) +- **Dependencies:** U2, U4 +- **Files:** `packages/core/src/plugin-types.ts` (`PluginTraitContribution`), `packages/engine/src/plugin-runner.ts` (aggregation + disable guard), `packages/plugin-sdk/src/index.ts` (re-exports), `packages/engine/src/__tests__/plugin-traits.test.ts` (new), `docs/PLUGIN_AUTHORING.md` +- **Approach:** `PluginTraitContribution { traitId, name, flags, schemaVersion, hooks: {gate?, onEnter?, onExit?, releaseCondition?} }` (async hook points only, R22; restricted flags rejected at validation) with a **versioned hook-descriptor schema** so the vocabulary extends additively without breaking published traits. Validated like `PluginWorkflowStepContribution`; `PluginRunner` aggregates/caches/invalidates on `plugin:registered/unregistered`. Hook execution routes through the prompt-session/script/verdict machinery with `gateMode` semantics for gates (advisory vs blocking, fail-closed for blocking gates per the existing gate handler) — gates evaluate pre-move outside the lock per KTD-2. Disable/uninstall with live dependents → blocked with occupant detail; force → columns degrade to passive with audit (KTD-7). +- **Test scenarios:** + - Contribution validation rejects malformed trait manifests; valid ones resolve through the same registry lookup as built-ins. + - Plugin gate in blocking mode rejects a move via its pre-evaluated verdict (typed rejection); advisory mode records and allows; a plugin contribution declaring a sync `guard` hook rejects at validation. + - Disable with cards in a plugin-trait column blocks; force-disable degrades the column (hooks no-op, audit emitted, cards still movable). + - Plugin trait throwing inside onEnter: card stays in column, `transitionPending` cleared with a degraded-hook audit, no wedged lock. Assert against real engine wiring, not mocks of nonexistent methods (branch-group dead-wiring lesson). +- **Verification:** plugin-trait suite green; `docs/PLUGIN_AUTHORING.md` documents the contribution. + +### Phase F — Surfaces + +### U9. Multi-lane dashboard board + +- **Goal:** Lane per workflow in use; workflow-defined columns; trait-aware drag with typed rejection feedback. +- **Requirements:** R16, R17 +- **Dependencies:** U4, U5 +- **Files:** `packages/dashboard/app/components/Board.tsx`, `packages/dashboard/app/components/Column.tsx`, `packages/dashboard/app/components/TaskCard.tsx`, `packages/dashboard/app/components/Lane.tsx` (new), `packages/dashboard/app/utils/taskSorting.ts`, `packages/dashboard/app/hooks/useTasks.ts`, `packages/dashboard/src/routes/register-task-routes.ts` (typed rejection in move endpoint), `packages/dashboard/app/components/__tests__/Board.test.tsx`, `packages/dashboard/app/components/__tests__/Lane.test.tsx` (new) +- **Approach:** Flag on: group visible tasks by resolved workflow (null → default lane); `Lane.tsx` renders one workflow's columns from its definition; `Board.tsx`'s `COLUMNS.map` becomes lanes-of-columns. **Lanes stack vertically** — each lane is a full-width row containing its own horizontally-scrollable columns (contains the existing iOS scroll-stabilization behavior per lane instead of compounding it). **Zero-card lanes are hidden**; lanes are **collapsible with persisted collapse state** (the lane-density mitigation for many-workflow boards — the default lane stays primary). Lane header shows the workflow name + card count. Flag off: current single-lane board unchanged. Drag rejections: deterministic guard/capacity rejections are **no-move** (the card never renders in the target column); async merge-blocked rejections use optimistic move + snap-back, both surfacing the typed rejection's `messageKey` via i18n. Archived-flagged columns hidden per lane. Hold columns show a promote affordance with explicit states: loading/disabled during the call, capacity-exhausted shows inline column feedback (not a toast — multiple holds can promote concurrently), success moves optimistically; the promote/release endpoint ships in this unit's route file. **Workflow-switch UI**: switching a card's workflow with an active session shows a confirmation warning of abort + re-home (parallels the existing preserve-progress confirm in `Column.tsx`). `Column.tsx` bulk actions re-key from column-ID literals to trait-flag predicates. Cross-lane drag is rejected with a `workflow-mismatch` rejection pointing at the workflow-switch flow (drag never implicitly switches workflows). Cards in a parallel window render per-branch progress badges (U13's exposed branch state). SSE/`useTasks` flow unchanged; lane grouping is client-side derivation. All new strings `t()`-wrapped; follow existing lazy-load/CSS-token conventions (no new monolith CSS). +- **Test scenarios:** + - Tasks with no selection render in the default lane; each card appears in exactly one lane (R16 invariant test over a mixed fixture). + - Lane renders its workflow's columns in order; archived-flagged column hidden. + - Rejected drop (guard/capacity/merge-blocked) shows the translated rejection and snaps back; allowed drop optimistically moves. + - Manual-release promote button releases a hold card (calls the promote endpoint); capacity-exhausted promote shows inline feedback and re-enables. + - Zero-card lane is hidden; collapse state persists across reloads; lane header shows workflow name + count. + - Cross-lane drag rejects with the workflow-mismatch message; workflow switch with an active session shows the abort-warning confirmation. + - Flag off renders the legacy board byte-identically (snapshot). +- **Verification:** dashboard tests green; i18n extract shows no unwrapped strings; manual smoke via the running dashboard (do not kill the live instance on port 4040). + +### U10. Node editor: columns, traits, and hold nodes + +- **Goal:** Author columns/traits/placement in the existing React Flow editor. +- **Requirements:** R1, R2, R3 (authoring), R8 (surfacing validation) +- **Dependencies:** U1, U2 +- **Files:** `packages/dashboard/app/components/WorkflowNodeEditor.tsx`, `packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx`, `packages/dashboard/app/utils/workflow-flow-mapping.ts`, `packages/dashboard/app/components/WorkflowColumnPanel.tsx` (new), `packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts` +- **Approach:** Columns render as React Flow group/swimlane backgrounds; dragging a node into a column band sets `node.column`. A column panel manages add/rename/reorder and trait assignment (trait picker fed by the registry's catalog endpoint — same session-scoped access pattern as existing workflow routes, no new auth surface). Hold node type with release-condition config UI; split/join node types with join-mode and failure-policy config. Save round-trips `flowToIr`/`irToFlow` through `parseWorkflowIr` + the composition validator. Error surfacing: column-level violations render on the offending column; **unplaced-node errors render as an inline badge on the node** and block save with a summary count — both use the same error-state component. Read-only built-in banner replaces the save/edit toolbar with "Duplicate to customize" as its primary action, canvas stays inspectable. +- **Test scenarios:** + - `irToFlow`/`flowToIr` round-trips v2 columns, placement, hold config, and split/join config losslessly. + - Seam-in-branch validation error renders on the offending node at save. + - Dropping a node into a column band updates placement; saving a node outside any column is rejected with the parse error surfaced. + - Trait conflict from the validator renders on the column; save blocked until resolved. + - Editing the built-in default workflow remains blocked (read-only banner), with "duplicate to customize" affordance. +- **Verification:** mapping + editor tests green; manual editor smoke creating the worked-example workflow. + +### U11. CLI TUI graceful degradation + +- **Goal:** The TUI never drops cards in custom columns. +- **Requirements:** R18 +- **Dependencies:** U4 +- **Files:** `packages/cli/src/commands/dashboard-tui/app.tsx` (`KANBAN_COLUMNS` reconciliation), `packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx` +- **Approach:** Map unknown columns by trait flags into the TUI's existing buckets (wip-flagged → in-progress bucket, human-review/merge-blocker → in-review, complete → done, hold/intake → todo) and add a read-only **"Other (custom)"** bucket — ordered after in-review, before done — for unmapped ones; cards in it show their actual column name as a secondary label so users keep the real position. Moves from the TUI into custom columns it can't express are disabled with a hint. (Mobile embeds the dashboard web UI and inherits U9; `packages/mobile/` has no task-list views — see Scope Boundaries.) +- **Test scenarios:** + - Fixture with cards in custom columns: every card visible in some bucket; none dropped (the silent-disappearance regression test). + - Trait-flag mapping places each built-in trait correctly; unmapped column lands in "Other (custom)" with its column name as secondary label, in the in-review→done position. + - TUI move of a card in an unexpressible column is disabled with hint text. +- **Verification:** CLI tests green; TUI manual smoke (`fn` dashboard view; never SIGKILL live processes per repo policy). + +### Phase G — Migration & graduation + +### U12. Flag-gated cutover, migration, parity graduation, and supersession cleanup + +- **Goal:** Ship the model end-to-end behind `workflowColumns`, migrate, prove parity, default the flag on; absorb plan 002's M-D. +- **Requirements:** R12 (graduation half), R19 +- **Dependencies:** U4–U11, U13 +- **Files:** `packages/core/src/db.ts` (final migration slot), `packages/core/src/types.ts` (legacy constants marked deprecated, retained while flag exists), `packages/engine/src/workflow-parity-observer.ts` (extend existing), `packages/core/src/workflow-parity.ts` (extend existing), `docs/architecture.md`, `docs/workflow-steps.md`, `CONCEPTS.md`, `packages/core/src/__tests__/migration-workflow-columns.test.ts` (new) +- **Approach:** Migration: additive schema only (already landed incrementally in U3/U6); zero task-row rewrites (KTD-1); null selection resolves to default workflow at read time, so "migration" is mostly the resolution rule plus an idempotent integrity pass that audits any task whose stored column isn't valid in its resolved workflow (re-homing via U5's policy). Graduation: dual-observe on real runs via the extended parity machinery; flip `workflowColumns` default when the parity suite and field observations show zero drift across the five invariants + transition parity, **and** zero dual-accept marker/column disagreements (U6) over the observation period — closing the dual-accept window at graduation. Update `docs/architecture.md`, `docs/workflow-steps.md`, and `CONCEPTS.md` (new concepts: column, trait, lane, hold, default workflow); mark plan 002 superseded. Legacy-pipeline code removal is deferred follow-up. +- **Test scenarios:** + - Migration idempotent; fresh DB and aged fixture DB (tasks in every legacy column, some with workflow selections) both resolve every task to a valid (workflow, column) pair. + - Tasks in `done`/`archived` untouched by the integrity pass. + - Flag off after migration: legacy board and engine behavior fully intact (rollback safety). + - Parity drift injected deliberately (e.g., altered default-workflow guard) is caught by the parity suite — proving the graduation gate actually gates. +- **Verification:** full `pnpm test` + `pnpm verify:workspace` green flag-on and flag-off; graduation checklist documented in the plan-execution notes; changeset added (published `@runfusion/fusion` is affected via CLI). + +--- + +## System-Wide Impact + +- **Affected surfaces (FN-5893 Surface Enumeration):** engine (store/scheduler/self-healing/merger/executor), dashboard board + editor + API routes, CLI TUI, mobile list views, plugin SDK, docs. Any invariant change must be asserted across all of them; U4/U6/U9/U11 carry the per-surface tests. +- **Data lifecycle:** `tasks."column"` semantics widen from enum to workflow-scoped ID (values unchanged for default-workflow tasks); new `transitionPending` column; no destructive schema change (DB-corruption history makes additive-only non-negotiable). +- **Recovery:** self-healing remains the single recovery authority (KTD-9); the `transitionPending` marker adds one new recovery responsibility (complete-or-rerun idempotent hooks), wired into the existing sweep. +- **Performance:** guard hooks run in-lock — they must stay sync/fast (KTD-2); the hold-release sweep replaces the scheduler poll at the same cadence, so steady-state load is comparable. + +--- + +## Risks & Dependencies + +- **~200-file literal-column surface** (initial grep: ~158 in research, closer to ~198 including dashboard components). The flag means flag-off behavior never changes, but flag-on coverage depends on finding every policy-bearing literal. Mitigation: U4's characterization suite, trait-flag predicate helpers (`isCompleteColumn(task)` style) replacing string equality, and a lint/grep audit gate in U12 for remaining literals on flag-on paths. +- **Fan-out branch state is new recovery machinery.** Per-branch persisted state (U13) adds a second recovery responsibility beyond `transitionPending`. Mitigation: same SQLite-reconstructible posture (ADR-0001), crash-resume tests per branch, and the seam-in-branch prohibition keeps worktree/merge mechanics out of the concurrent paths entirely. +- **Flag-on/flag-off dual maintenance until graduation.** Both paths must stay in sync for engine bugfixes during the window. Mitigation: the transition-parity suite runs on both paths in CI for every change (not only at graduation), so a fix landed on one path that diverges the other fails fast. +- **Merge rewiring regression risk (lost-work class).** Mitigation: KTD-6 keeps the three guards capability-level + U7's regression trio; merger mechanics are read-only in this plan. +- **Hook protocol is the new invariant.** `transitionPending` + idempotent hooks is the one genuinely novel reliability contract. Mitigation: U3/U4 crash-simulation tests; hooks observable in audit; degraded-not-stranded posture everywhere. +- **Long-lived-branch risk.** Mitigation: every unit lands independently behind the flag; phases A–C are merge-safe with zero flag-off behavior change. +- **IR v1 freeze (FN-5769).** v2 is a deliberate, budgeted contract change; v1 parse compatibility (U1) is the containment. +- **Dependency:** plan 002's M-A–M-C implementations (`workflow-graph-executor.ts`, `workflow-node-handlers.ts`, `workflow-graph-task-runner.ts`, executor wiring) are assumed present and stay load-bearing; this plan supersedes only its remaining M-D milestone. + +--- + +## Sources & Research + +- `docs/plans/2026-06-03-001-feat-executable-custom-workflows-node-editor-plan.md` — completed MVP this builds on (IR, editor, selection). +- `docs/plans/2026-06-03-002-feat-workflow-interpreter-cutover-plan.md` — superseded; its seam characterization (review/merge plug-and-play; execute needed `runImplementationPhase` extraction; never compose full `execute()`) and KTDs are carried forward. +- `docs/rfcs/FN-5719-decouple-executor-merger.md` — handoff marker + persisted merge-request queue; the two enforcement points that must migrate (scheduler dependency satisfaction, in-review overlap leases) with dual-accept posture. +- `docs/dag/adr-0001-dag-orchestration.md` + `docs/dag/milestone-b-schema-migration-plan.md` — enqueue-only boundary; additive/forward-only migration rules; SQLite-reconstructible state. +- `docs/workflow-steps.md` — invariant bar and `gateMode` semantics generalized by trait hooks. +- `docs/incidents/2026-05-23-lost-work-tasks.md` — the three non-configurable merge guards (KTD-6). +- `docs/self-healing-backward-move-audit.md` (FN-5335) — triple-proof rule; single recovery authority (KTD-9). +- `docs/solutions/architecture-patterns/mass-migration-agent-fleet-orchestration.md` — behavior-invariant migration + clean-baseline diff triage. +- `docs/solutions/integration-issues/branch-group-single-pr-synthetic-id-dead-wiring.md` — no mock-masked engine assertions (U8 test posture). +- Key code anchors: `packages/core/src/types.ts:18` (`COLUMNS`), `packages/core/src/store.ts` (`moveTaskInternal`, workflow CRUD), `packages/engine/src/scheduler.ts` (`computeConcurrencyGateDiagnostic`), `packages/engine/src/workflow-node-handlers.ts` (handler-injection precedent for the trait registry), `packages/engine/src/plugin-runner.ts` (contribution aggregation precedent), `packages/cli/src/commands/dashboard-tui/app.tsx` (`KANBAN_COLUMNS`). diff --git a/docs/plans/2026-06-04-001-feat-step-inversion-workflow-modelable-steps-plan.md b/docs/plans/2026-06-04-001-feat-step-inversion-workflow-modelable-steps-plan.md new file mode 100644 index 0000000000..7b41265ecd --- /dev/null +++ b/docs/plans/2026-06-04-001-feat-step-inversion-workflow-modelable-steps-plan.md @@ -0,0 +1,411 @@ +--- +title: "feat: Step inversion — steps as workflow-modelable nodes" +type: feat +status: active +date: 2026-06-04 +depth: deep +origin: none (solo planning bootstrap; extends docs/plans/2026-06-03-003-feat-workflow-custom-columns-traits-plan.md) +--- + +# feat: Step inversion — steps as workflow-modelable nodes + +## Summary + +Extend the engine→workflow inversion to **task steps**. Today the engine owns step policy end-to-end: PROMPT.md `### Step N:` headings parse into `Task.steps[]`, the agent session executes them, per-step review happens through the in-session `fn_review_step` tool (verdicts `APPROVE | REVISE | RETHINK | UNAVAILABLE`), RETHINK does `git reset --hard` + session rewind + step reset, and merge is blocked while any step is non-terminal. After this plan, the substrate knows exactly one new thing — **how to run one step of a task inside its session and how to reset one step to its baseline** — and everything else (step granularity, per-step check/verdict, approval gates, rework/escalation routing) becomes user-authored workflow-graph structure: a runtime-expanding **`foreach` template region** instantiated once per planned step, a **`step-review` node** that surfaces review verdicts as outcome edges, and bounded **rework edges** that the executor permits as the only legal cycles. + +Steps additionally gain **parallel execution and worktree isolation as explicit foreach axes** (`mode: sequential|parallel`, `isolation: shared|worktree`): PROMPT.md steps may carry `depends:` metadata, and a parallel foreach runs dependency-satisfied instances concurrently — each in its **own worktree/branch off a common base** — with an **ordered integration stage** that lands step branches in step order and routes rebase conflicts to an `integration-conflict` rework outcome (KTD-11). + +Third, the **task shape itself becomes workflow-defined** (KTD-12/13/14): the existence of PROMPT.md and the `### Step N:` parsing convention stop being engine law — workflows declare their **artifacts** (named task documents), and **step parsing is itself a graph node**: `parse-steps(artifact, parser)` reads an artifact, runs a registry parser, and writes the step list, with routable `no-steps`/`parse-error` outcomes — and parsers are **plugin-pluggable**. A **`code` node** (KTD-15) runs arbitrary sandboxed TypeScript for logic no built-in covers. Workflows also declare **custom task fields** (typed, with enum options and rendering instructions); the task model reduces to core fields (title, description) plus standard metadata, with everything else as workflow-defined fields, and the task UI renders the field schema dynamically (detail form + card badges). + +The default workflow is untouched (monolithic `execute` seam, declares PROMPT.md + the step-headings parser + zero custom fields — byte-identical; it is the parity oracle, same posture as the columns track). Inversion is opt-in via custom workflows; a new built-in **stepwise coding workflow** demonstrates the full modeling and is the parity-comparison subject. + +--- + +## Problem Frame + +The columns/traits track (plan 2026-06-03-003, PR #1418) moved board policy — transitions, capacity, hold, merge orchestration — into workflow IR + traits. But **step policy is still engine law**: + +- The plan→steps breakdown is a hardcoded regex over PROMPT.md (`store.parseStepsFromPrompt`, `packages/core/src/store.ts:8534`). +- Per-step review is an in-session tool (`fn_review_step`, `packages/engine/src/executor.ts:7300+`) whose verdict handling — APPROVE auto-completes the step, REVISE re-prompts, RETHINK resets git + session + step — is fixed control flow inside a ~3k-line `execute()`. +- Rework routing (what happens after REVISE/RETHINK, how many times, who escalates) is not expressible by a user at all. +- The graph executor (`packages/engine/src/workflow-graph-executor.ts`) sees implementation as one opaque `execute` seam; a workflow cannot say "review each step with a read-only checker before the next step starts" or "send a rejected step to a senior-model rework node." + +A user who wants per-step plan review, a different verdict policy, a human approval between steps, or fan-out read-only checks per step has no modeling surface. That policy belongs in workflows; the engine should only supply the mechanisms (run step *i* in the session; reset step *i* to baseline; run the reviewer). + +The FN-4359 reliability-freeze waiver carried by plans 002/003 continues to apply to this track. The five lifecycle invariants (FN-5147 terminal-until-merged, hard-cancel, in-review stall, file-scope, squash) plus the lost-work guard trio remain the non-configurable correctness bar. + +--- + +## Scope Boundaries + +### In scope + +- New IR constructs (additive to WorkflowIr v2): `foreach` node kind with an inline per-step template subgraph; `step-execute` seam node (legal only inside a foreach template); `step-review` node kind; `rework` edge kind (the only legal cycle form); verdict outcome edges. +- Substrate capability extraction: `runTaskStep(task, stepIndex)` (run exactly one step inside the task's single session/worktree, commit with the existing `complete step N` convention) and `resetStepToBaseline(task, stepIndex, baselineSha)` (git reset + session rewind + `updateStep(...,"pending")`), both delegating to existing code, never reimplementing. +- Runtime template expansion with deterministic instance identity, persisted instance run-state (schema v108), crash/resume reconstruction, and a stale-instance recovery sweep. +- `Task.steps[]` kept as the **physical projection sink**: instance transitions call the existing `store.updateStep`, so the merge-blocker, dashboard step display, CLI/TUI, reconcile-from-git, and lost-work reset all keep working unchanged. +- **Parallel step execution** (KTD-11): `TaskStep.dependsOn` metadata parsed from PROMPT.md `### Step N (depends: 1,2):` annotations; foreach `concurrency` config; per-instance worktrees/branches off a common base via the existing worktree pool; ordered integration (rebase/cherry-pick in step order) with `outcome:integration-conflict` routed as rework on the updated base; branch-scoped RETHINK reset in parallel mode. +- **Workflow-defined task artifacts & a `parse-steps` node** (KTD-12): workflows declare named task documents (riding the existing task-documents machinery), and step parsing is a first-class graph node — `parse-steps(artifact, parser)` reads the artifact and writes the step list, with `no-steps`/`parse-error` outcome edges; `parseStepsFromPrompt` becomes the built-in `step-headings` parser in a parser registry; the default workflow keeps its legacy in-execute step init for byte-identical parity. +- **Workflow-defined custom task fields** (KTD-13): typed field definitions (string/text/number/boolean/enum/multi-enum/date/url) with enum options and rendering instructions in the IR; values stored per task and validated against the schema through a single store authority; agent-tool parity. +- **Pluggable step parsers**: plugins register parsers (namespaced ids) through the plugin runtime, mirroring the plugin-trait adapter; fail-closed to `parse-error`. +- **`code` node** (KTD-15): inline TypeScript compiled with esbuild, executed in a timeout-bounded child process against a narrow harness contract (`ctx` in, `{outcome, contextPatch, customFields}` out); save-time syntax validation. +- **Dynamic task UI** (KTD-14): TaskDetailModal renders the field schema as a form section; TaskCard renders card-front-placed fields as badges/chips; workflow editor gains a field-definitions panel. +- Built-in **stepwise coding workflow** (new, opt-in) modeling today's per-step review policy explicitly; parity assertions against legacy in-session behavior. +- Workflow node editor support for authoring foreach/step-review/rework constructs; agent-tool and plugin-SDK type parity; docs. + +### Out of scope (deferred for later) + +- Removing or rewriting the legacy in-session step path (`fn_review_step`, step-sessions). It remains the flag-OFF behavior and the default workflow's behavior, byte-identical. +- Re-expansion when the agent edits PROMPT.md after the foreach has expanded (instance count is pinned at expansion; documented limitation, surfaced via tool message — see KTD-3). +- Step-template authoring from the dashboard *board* (lanes/cards unchanged); authoring lives in the existing workflow node editor. +- Plugin-defined node kinds (plugins already reach gates via traits; new node kinds stay built-in this round). +- **Recasting existing built-in task fields** (priority, labels, etc.) as workflow custom fields — this plan ships the field *system*; migrating built-ins onto it is a follow-up with its own compatibility track (every built-in field has hardcoded consumers across ~150 files). The field system is designed so that migration is additive when it comes. +- Plugin-contributed field *types* (the field-type whitelist is built-in-only this round; step parsers ARE pluggable, KTD-12). +- Cross-workflow field identity (two workflows defining a field with the same id are distinct schemas; no shared/global field namespace yet). +- Graduating any flag default. + +--- + +## Key Technical Decisions + +### KTD-1 — Default workflow stays monolithic; inversion is opt-in (resolves the parity-vs-inversion contradiction) + +The built-in default coding workflow keeps its single `execute` seam node and is byte-identical flag-ON and flag-OFF (the existing characterization suites continue to prove this). Per-step modeling cannot be both "verbatim today" and "structurally inverted" — so the default is the **parity oracle**, and inversion ships as authoring capability plus a separate built-in **stepwise coding workflow** users can select. This mirrors exactly how the columns track kept `VALID_TRANSITIONS` as the deprecated-but-retained oracle. + +### KTD-2 — One new substrate seam pair: `runTaskStep` / `resetStepToBaseline` + +- `runTaskStep(task, stepIndex)` — drives execution of exactly step *i* and returns `{outcome, baselineSha, checkpointId}`. **Honest framing: this is part extraction, part new code.** The discrete per-step boundary (`onStepStart`/`onStepComplete`) exists today only in `StepSessionExecutor` (the `runStepsInNewSessions` path); the monolithic single-session path has no "run one step and return control" seam — the agent self-paces. Therefore: **graph-owned stepwise runs always use step-session physics** (`StepSessionExecutor`), regardless of the `runStepsInNewSessions` setting (pinned per run, like the flag in KTD-8). `runTaskStep` is a thin driver over `StepSessionExecutor` extracted characterization-first; no monolithic single-step driver is invented. The legacy monolithic path is untouched and remains the flag-OFF/default-workflow behavior. +- **Commit authorship is unchanged**: the step agent still authors its own `complete Step N — ` commits (the summary feeds `git log`, merger subject derivation, and `reconcileStepsFromGitHistory`, `executor.ts:11067`). `runTaskStep` **observes** the commit (captures the resulting SHA at step completion) — it never authors commits. +- **Baseline capture is a deliberate, documented behavior change**: today the RETHINK baseline is an agent-supplied tool parameter; in the inverted path the substrate runs `git rev-parse HEAD` at instance start. Because instances run sequentially (KTD-3), HEAD-at-instance-start is exactly the boundary after steps `0..i-1`'s commits — equivalent to what a well-behaved agent supplies today. U7's RETHINK parity test asserts the captured SHA matches the agent-equivalent baseline on scripted runs. +- `resetStepToBaseline(task, stepIndex, baselineSha, checkpointId?)` — the RETHINK mechanics, verbatim from `executor.ts:7455-7505`: `git reset --hard `, session rewind via `navigateTree`/`branchWithSummary` fallback, `updateStep(...,"pending")`. Partial-recovery behavior preserved: missing baseline → skip git reset; missing checkpoint → skip rewind (today's semantics, `executor.ts:7466,7492`). **Blast-radius guard** (shared isolation): because rework edges are intra-instance and shared-isolation instances are sequential, a reset for instance *i* can only fire while *i* is active — later instances have not run, and the per-instance baseline postdates steps `0..i-1`, so the reset can never destroy other steps' approved work. `resetStepToBaseline` asserts this invariant defensively (baseline is an ancestor of HEAD; no later instance row is `completed`) and refuses with an audited failure outcome if violated. Under worktree isolation (KTD-11) the reset targets the instance's own branch, making the guard structural. + +Crucially this **fixes the in-memory fragility**: today `stepCheckpoints`/`codeReviewVerdicts` are unsynchronized in-memory Maps lost on restart. `baselineSha`/`checkpointId` move into persisted instance run-state (KTD-6). The graph decides *when* a reset happens; the substrate owns *how*. + +### KTD-3 — `foreach` node with an inline template subgraph; expansion when the walk reaches it + +New node kind `foreach`, config: + +``` +{ + source: "task-steps", + maxReworkCycles?: number, + mode?: "sequential" | "parallel", // default "sequential" + concurrency?: number, // parallel mode only; default 2, cap 8 + isolation?: "shared" | "worktree", // default: "shared" for sequential, "worktree" for parallel + template: { nodes: [...], edges: [...] } +} +``` + +- `template` is an inline subgraph with exactly one entry and one exit (validated like the main graph; same `validateV2` rules applied recursively). +- Expansion happens **when the walk reaches the foreach node**: `source: "task-steps"` reads `Task.steps[]` at that moment — by then the planning seam / plan node has populated steps (today's `execute()` initializes steps from PROMPT.md before step work begins, `executor.ts:4263-4269`). +- **`mode` and `isolation` are explicit, independent authoring axes** (KTD-11 for the physics): + - `sequential` + `shared` (default): instances run in step order in the task's main worktree — the baseline physics every other KTD describes. + - `sequential` + `worktree`: instances still run one at a time, but each in its own worktree/branch with ordered integration — buys branch-scoped RETHINK and a clean per-step audit trail at sequential pace. + - `parallel` + `worktree`: dependency-aware concurrent execution (below). + - `parallel` + `shared`: **rejected by the validator** — concurrent write sessions in one worktree are unguardable races. +- **Parallel scheduling**: an instance becomes runnable when all of its step's `dependsOn` steps are integrated (KTD-11); up to `concurrency` runnable instances execute concurrently. `TaskStep.dependsOn?: number[]` is parsed from the PROMPT.md annotation `### Step N (depends: 1,2): Title`; a step with no annotation implicitly depends on the previous step, so an unannotated plan is fully sequential regardless of mode — parallelism is opt-in per step by the planner, not asserted globally by the workflow author. Dependency cycles are rejected at expansion with an audited failure. +- Instance identity is deterministic: `#:` — resume can reconstruct the full instance set from `(foreachNodeId, pinned step count)` without persisting the expansion itself. +- **Expansion-placement validation**: the validator (U1) requires that a `parse-steps` node (KTD-12) dominates — precedes on all paths — any `foreach` with `source: "task-steps"`. This prevents a silent wrong outcome where a mis-authored graph reaches the foreach before parsing, sees zero steps, and merges a task with no step work done. +- **Zero steps parsed** (after a dominating planning node) → the foreach immediately traverses its `success` edge (matches today: zero steps = no merge blocker, `task-merge.ts:202`). +- Step count is **pinned at expansion and persisted** (`pinnedStepCount` on the instance rows' run scope, KTD-6). PROMPT.md edits after expansion do not re-expand; `store.updateStep`'s auto-reinit path is bypassed for graph-owned tasks (the projection writes explicit indices). The agent gets a tool-message notice when it edits steps after expansion (implementation detail, U6). +- **Pin vs. git-reconcile on resume**: if resume-time `Task.steps[]` length differs from the persisted pin (re-parse or reconcile changed it), the run does **not** guess — it fails the foreach with an audited `pin-mismatch` outcome, instance rows are cleared, and the task follows the normal graph-failure recovery path (legacy requeue with git-reconciled `steps[]` as truth). U4 tests cover both grow and shrink. + +### KTD-4 — `step-review` node: verdicts become outcome edges + +New node kind `step-review`, config `{ type: "plan" | "code", model?: ... }`, legal only inside a foreach template. Its handler calls `reviewStep(...)` (`packages/engine/src/reviewer.ts:306`) under `semaphore.runNested` (same as today, `executor.ts:7397`) against the **current instance's** step, and maps the verdict to outcome edges: + +- `outcome:approve` → typically routes forward; the projection marks the step `done` (preserving today's APPROVE-auto-completes semantics for code reviews). +- `outcome:revise` → typically a **rework edge** back to the instance's `step-execute` node (no reset; the session revises in place — today's REVISE). +- `outcome:rethink` → a rework edge whose traversal triggers `resetStepToBaseline` first (today's RETHINK). +- `outcome:unavailable` → bounded retry (mirroring the in-session `planSpecUnavailableCounts` limiter, `executor.ts:7297`), then routes `outcome:unavailable` if still failing; the validator requires it routed or defaults it to the node's `success` path in advisory fashion (plan reviews are advisory today). + +The validator requires `approve` and `revise` to be routed; `rethink` defaults to the `revise` target with reset semantics if unrouted. Review nodes are read-only with respect to the worktree. **Verdict authority is single-writer**: only a step-review node on the instance's main path may author the verdict that routes the instance and writes the projection; step-review nodes inside `split` branches are advisory-only (validator-enforced), so fan-out checks can never clobber the authoritative verdict or race the rework budget. `step-execute` may not appear in split branches at all (validator-enforced, extending `SEAM_FORBIDDEN_IN_BRANCH`). + +### KTD-5 — Rework edges: the only legal cycles, bounded per instance + +New edge attribute `kind: "rework"`. **Mechanism**: foreach instances execute in an **iterative region sub-walk** modeled on `walkBranch` (`workflow-graph-branches.ts:240+`, a `for(;;)` loop over `currentId`), NOT through the recursive `walk`. The recursive walk's `inStack` cycle detector (`workflow-graph-executor.ts:145`) is untouched and still throws on any back-edge outside an active instance region — rework is a loop-back of `currentId` *within* the instance's iterative sub-walk, where cycles are naturally expressible. The two approaches are mutually exclusive; loosening the recursive detector is explicitly NOT the design. Rework traversal count per instance is bounded by `maxReworkCycles` (default 3, hard cap 10 — same clamp posture as `maxRetries`). Exhaustion emits `outcome:rework-exhausted` from the foreach instance; the validator requires it routed (escalation node, hold for human, or failure) or defaults to `failure`. This is deliberately distinct from per-node `maxRetries`, which stays exception-only. + +### KTD-6 — Persisted instance run-state; resume reconstructs deterministically (schema v108) + +New table `workflow_run_step_instances` (migration 108, additive): + +``` +taskId, runId, foreachNodeId, stepIndex, pinnedStepCount, currentNodeId, status, baselineSha, checkpointId, reworkCount, branchName, integratedAt, updatedAt +``` + +(`branchName`/`integratedAt` and the `awaiting-integration` status serve parallel mode, KTD-11; null/unused at `concurrency: 1`.) The same v108 migration adds `tasks.customFields TEXT DEFAULT '{}'` (KTD-13) — one schema bump for the whole plan. + +- **No new interface layer**: the CRUD pair (`saveWorkflowRunStepInstance` / `loadWorkflowRunStepInstances` / `clearWorkflowRunStepInstances`) are direct store methods wired into the executor via the same additive-guard pattern as `buildBranchPersistence()` (`executor.ts:3302`). A named persistence interface gets added only if a second adapter materializes in the same PR (e.g., in-memory for tests). +- On resume: rebuild the instance set from the persisted `pinnedStepCount` + rows (mismatch with live `steps[]` → `pin-mismatch` failure, KTD-3); completed instances skip; the in-flight instance seeds its iterative sub-walk position directly from persisted `currentNodeId` + `reworkCount` — **not** from a node-id-keyed `completedNodeIds` skip set, which is unsound under rework cycles (the same node id legitimately runs multiple times). `reconcileStepsFromGitHistory` remains the git-truth fallback and its verdict wins over stale instance rows (rows are corrected to match, never the reverse). +- A stale-instance recovery sweep mirrors `recoverStaleTransitionPending()` — instances `in-progress` with no live session lease are reset to the projection's truth. +- Rows are pruned per run like `clearWorkflowRunBranches` (#1412 pattern). Archived tasks freeze the projection (steps[] persists on `ArchivedTask`); instance rows are pruned. + +### KTD-7 — `Task.steps[]` stays the physical sink (emulation, not rewrite) + +Instance lifecycle writes go **through `store.updateStep`** (`store.ts:7546`) with explicit indices: instance start → `in-progress`, approve/complete → `done`, rethink reset → `pending` (via `resetStepToBaseline`). Consequences, all intentional: + +- Merge-blocker (`task-merge.ts:202`), dashboard step bars, TUI step lists, `mesh-lease-manager` work-started detection, and lost-work reset keep reading the same array with the same semantics — **no consumer changes**. +- `updateStep`'s regression/out-of-order guards stay active. The graph writes are ordered (sequential instances), so guards should never fire; if they do, that's a projection bug surfaced loudly in the audit log (U6 adds an audit warning on guard-suppressed graph writes rather than today's silent ignore). +- `currentStep` auto-advance behavior is preserved by construction. Rework loops legitimately move a step `done→pending` only via `resetStepToBaseline` (RETHINK), which today already does exactly that — board display of a step regressing is existing behavior. +- The merge-blocker race (instance completes vs. projection flush) is closed by ordering: `updateStep` is called **before** the instance row flips to `completed` (projection-first ordering, same reservation-first discipline as capacity in plan 003 KTD-10). + +### KTD-8 — Flag posture: `workflowGraphExecutor` gates it; flag pinned per run + +The foreach/step-review machinery is interpreter functionality, gated by the existing `experimentalFeatures.workflowGraphExecutor` flag (the columns flag is orthogonal and untouched). The flag is **read once at dispatch and pinned for the run** — a mid-flight toggle takes effect on the next dispatch, never mid-walk (closes the dual-writer hazard: legacy and graph paths both mutate `steps[]`/worktree). The same pin applies to execution physics: graph-owned stepwise runs force step-session mode for the run's duration regardless of the `runStepsInNewSessions` setting (KTD-2), so the flag-interaction matrix (graph ON × step-sessions OFF) cannot select an unsupported physics combination. Flag-OFF rollback mid-task follows the existing `fell-back`/recovery posture: instance rows are swept, `steps[]` (the projection — always git-reconcilable) is the surviving truth, and legacy resume reconciles from git exactly as it does today. IR with foreach nodes is v2-only; `downgradeIrToV1IfPure` already refuses non-v1 node kinds, so #1405's rollback contract is preserved automatically. + +### KTD-9 — Built-in stepwise coding workflow is the demonstration + parity subject + +A second built-in (`builtin-stepwise-coding-workflow-ir.ts`): plan seam → parse-steps(PROMPT.md, step-headings) → foreach(task-steps){ step-execute → step-review(code) with approve→exit, revise→rework, rethink→rework+reset, rework-exhausted→hold(manual) } → review seam → merge seam. Its observable step-state trajectory for equivalent inputs must match the **legacy step-session path's** trajectory (same `updateStep` sequence, same merge-blocker windows; the step-session path is the deterministic oracle — the agent-paced monolithic path is not deterministically comparable, see U7) — asserted by a characterization-style trajectory comparison, reusing the `workflow-parity.ts` observation machinery rather than inventing new drift tracking. + +### KTD-10 — Authoring surface: node editor additions, board untouched + +`WorkflowNodeEditor` gains foreach (container/group node rendering the template subgraph), step-review, and rework-edge authoring; `workflow-flow-mapping.ts` round-trips them. The board/lanes render nothing new — step progress continues to come from `Task.steps[]` (KTD-7). Per-instance metadata (rework counts, verdicts) surfaces only in the task detail workflow results area, additively. + +### KTD-11 — Parallel step execution: per-instance worktrees, ordered integration, optimistic conflicts + +`mode` and `isolation` are explicit foreach config (KTD-3). Worktree isolation applies whenever `isolation: "worktree"` — in `sequential` mode it yields one-at-a-time instances with branch isolation + ordered integration (clean branch-scoped RETHINK without concurrency); in `parallel` mode, when the dependency graph admits it, multiple step instances run concurrently. The physics: + +- **Isolation**: each worktree-isolated instance gets its **own worktree and branch** off the current **integration base** (the task's main branch tip at instance start), allocated through the existing worktree pool (`worktree-pool.ts`) with canonical per-instance branch names. Each instance is its own step-session (`StepSessionExecutor` already creates per-step sessions — natural fit) and acquires a normal `AgentSemaphore` lease, so machine resource ceilings apply unchanged; `concurrency` is additionally clamped by available semaphore slots (parallelism degrades gracefully to sequential under contention, never deadlocks waiting for slots it holds). +- **Ordered integration**: under worktree isolation, instance completion does NOT mark the step done. A foreach-internal **integration stage** lands completed step branches onto the integration base **in step order** (rebase/cherry-pick); only successful integration flips the projection (`updateStep(...,"done")`) and unblocks dependents. Shared isolation integrates trivially — work lands directly in the main worktree, and done-at-step-completion semantics match the rest of this plan unchanged. +- **Conflicts are optimistic**: no upfront file-scope declarations. A rebase/cherry-pick conflict during integration emits `outcome:integration-conflict` from that instance — default routing is a rework edge: the instance's branch is discarded, and the step re-executes **on the updated base** (counting against its `maxReworkCycles` budget; exhaustion routes `rework-exhausted` as usual). The validator requires `integration-conflict` routed or defaults it to the rework path. +- **RETHINK under worktree isolation is branch-scoped**: `resetStepToBaseline` resets the instance's branch only — sibling instances and the integration base are untouched, which makes the KTD-2 blast-radius guard structural rather than defensive in this mode. (Shared isolation keeps the KTD-2 guard as written.) +- **Reconcile alignment**: `complete step N` commits exist on instance branches before integration and on the main history after it; `reconcileStepsFromGitHistory` reads main-worktree history, so its verdict ("done iff integrated") agrees with the projection rule by construction. +- **Projection ordering guard**: `store.updateStep`'s out-of-order-done guard (`store.ts:7592-7610`) assumes index order; graph-source writes (U6's `source: "graph"`) relax it to **dependency order** — a done write is legal when all `dependsOn` steps are done. `currentStep` auto-advance (first non-done scan) is order-agnostic already. +- **Persistence**: instance rows gain `branchName` and `integratedAt`; status adds `awaiting-integration`. Crash under worktree isolation resumes by reconciling rows against branch existence: integrated → done; branch exists, not integrated → re-enter integration queue; branch missing → instance re-runs. + +### KTD-12 — Workflow-defined task artifacts & a `parse-steps` node + +Today `PROMPT.md` is hardcoded engine law: created by the planning phase, parsed by the fixed regex in `parseStepsFromPrompt` (`store.ts:8534`), and assumed by reconcile, step init, and resume prompts. Inversion — **step parsing is itself a graph node**, visible and reorderable workflow structure: + +- **IR gains an `artifacts` declaration**: `artifacts: [{ key, title?, producedBy?: "planning" | "manual", role?: "step-source" | "context" }]`. Artifacts ride the **existing task-documents machinery** (`TaskDocument`, `fn_task_document_write/read`) — no new storage; `PROMPT.md` becomes the default workflow's declared `step-source` artifact backed by its current file location (the document layer already fronts it). +- **New node kind `parse-steps`**, config `{ artifact: , parser: "step-headings" | "json-steps" }`. Its handler reads the artifact at walk time, runs the named parser, and writes the canonical step list through the projection sink (`Task.steps[]` via the store, graph source — same single authority as everything else). Outcomes: `success`, `outcome:no-steps` (parsed cleanly, zero steps — routable, defaults to success), `outcome:parse-error` (malformed artifact — routable, defaults to failure). The node is the *only* graph-side writer of the step list; running a `parse-steps` after a foreach has already expanded trips the KTD-3 pin protection (audited failure), so re-plan loops cannot silently desynchronize an expanded foreach. +- **Parser registry is pluggable**: the registry (same posture as the trait registry) holds built-ins `step-headings` (the extracted `parseStepsFromPrompt` logic, including the `(depends: …)` annotation from U1 — extraction, not rewrite) and `json-steps` (a structured `[{name, depends?}]` document for workflows that plan in JSON), and **plugins register parsers** under namespaced ids (`plugin::`) through the plugin runtime — mirroring `plugin-trait-adapter.ts`. Contract: `(artifactContent, ctx) → {steps: [{name, dependsOn?}]}`, executed through the plugin runner with a timeout; an unavailable or throwing plugin parser maps to `outcome:parse-error` (fail-closed, audited) — never a crash. IR referencing a parser absent from the live registry is rejected at save with the id named. +- **foreach consumes, never parses**: foreach `source: "task-steps"` just reads `Task.steps[]`; the KTD-3 dominance validation is now concrete — a `parse-steps` node must dominate any `foreach(source:"task-steps")` on all paths (the monolithic default workflow has no foreach and keeps its legacy in-`execute()` step init untouched). +- **The planning seam contract**: the workflow's `producedBy: "planning"` artifacts are what the planning seam is told to produce (surfaced in the planning prompt); the engine no longer assumes PROMPT.md by name outside the default workflow's declaration. Reconcile (`reconcileStepsFromGitHistory`) and resume read through the workflow's parse-steps declaration to know which artifact/parser governs the task. +- **Parity**: the legacy path (`parseStepsFromPrompt` callers in store/executor) delegates to the same extracted `step-headings` parser function — byte-identical, proven by the existing parse tests running against both the direct call and the registry resolution. The stepwise builtin's chain becomes: plan seam → `parse-steps(PROMPT.md, step-headings)` → foreach. + +### KTD-13 — Workflow-defined custom task fields + +The task model is recast as: **core fields** (title, description) + **standard metadata** (column/status, timestamps, branch/git state, workflow selection) + **workflow-defined custom fields** for everything else. This round ships the field system; built-ins stay where they are (see Out of scope). + +- **IR gains `fields`**: `fields: [{ id, name, type, required?, default?, options?, render? }]` where `type ∈ string | text | number | boolean | enum | multi-enum | date | url`; `options: [{value, label, color?}]` for enum kinds; `render: { placement?: "card" | "detail" | "detail-section", widget?: "select" | "radio" | "chips" | "input" | "textarea" | "toggle", badge?: boolean }` as rendering instructions. Validator enforces id uniqueness, type whitelist, options present iff enum-kind, render-hint whitelist. +- **Storage**: `tasks.customFields` JSON column (added in the same v108 migration as the instance table — one schema bump for the plan). Values keyed by field id. +- **Single write authority**: a store-level `updateTaskCustomFields(taskId, patch)` (and the same path inside `updateTask`) validates every write against the task's workflow field schema — type check, enum membership, required-on-transition is NOT enforced this round (fields are data, not gates; a `gate` trait can read them later). Invalid writes are typed rejections, mirroring `TransitionRejection` style. +- **Schema evolution**: editing a workflow's fields follows the reconciliation posture of columns (#1409/`rehome_to` precedent): removing a field orphans existing values (retained, rendered under an "orphaned fields" disclosure in detail UI, excluded from cards); an incompatible type change is rejected unless the update names `coerce: "drop" | "keep-orphaned"`. Tasks switching workflows keep values for ids the new workflow also defines (same id = same field by convention within a project), orphan the rest. +- **Agent-native parity**: `fn_task_update` accepts a `custom_fields` patch (validated through the same authority); `fn_workflow_create/update` accept `fields`; field values are surfaced in session/task context so agents can read and set them. + +### KTD-14 — Dynamic task UI from the field schema + +- **Task detail**: `TaskDetailModal` renders a schema-driven fields section — widget per `type`+`render.widget` (select/radio/chips for enums, toggle for boolean, date input, validated url/number inputs, textarea for text), grouped by `placement` (`detail` inline near description; `detail-section` as a collapsible group). Saves go through the dashboard route → store authority; validation errors surface inline per field (400 with field path). +- **Card front**: fields with `placement: "card"` render as badges/chips on `TaskCard` (enum colors from `options[].color`; boolean as a labeled chip when true). Card real estate is bounded: max 3 card-placed fields rendered, overflow indicated — the validator warns (not rejects) past 3. +- **Data flow**: field definitions ship with the board-workflows payload (`/api/tasks/board-workflows` already carries per-workflow data and invalidates on `workflow:updated` SSE); task field values are already on the task payload via `customFields`. +- **Workflow editor**: a **Fields panel** in the workflow editor (sibling to the column panel from the columns track) — add/edit/remove field definitions, enum option editor with color picker, render-placement controls, live badge preview. Reuses the editor's existing validation-at-save surfacing. +- **TUI**: read-only rendering of card-placed fields in the task detail view (chips → bracketed labels); no TUI editing this round. + +### KTD-15 — `code` node: arbitrary TypeScript in the graph + +A general computation escape hatch so workflows can express logic no built-in node covers (derive a field from artifacts, call an internal API, compute routing data): + +- **New node kind `code`**, config `{ source: string, timeoutMs?: number }` — inline TypeScript. Compiled in-memory with esbuild at execution (and syntax-checked at save: the validator runs the same transform and rejects IR whose source fails to compile), then run in a **child process** with cwd = the task's worktree. +- **Harness contract**: the script default-exports `async (ctx) => result`, where `ctx = { task, steps, customFields, context, artifacts: {read(key)}, instance? }` (instance present inside a foreach template — the `foreach:active` data). The returned `{ outcome?, value?, contextPatch?, customFields? }` maps to graph behavior: `outcome` routes `outcome:` edges (absent → success), `contextPatch` merges into walk context, `customFields` writes through the U11 validation authority. Throw/timeout/non-zero exit → `failure` outcome with the error audited. +- **Boundaries**: the code node does NOT get a store handle, engine internals, or the step-list write path — steps are written only by `parse-steps` (KTD-12), task fields only through the validated patch it returns. It can read artifacts and the worktree; it runs with the same trust as existing workflow script steps (workflows are project-local, user-authored config — `WorkflowStepMode "script"` already executes arbitrary project scripts today, so this adds expressiveness, not a new trust tier). Timeout clamped (default 30s, cap 300s); stdout/stderr captured and size-capped into the node result; source size capped (64KB). +- **Placement rules**: legal anywhere a script node is legal, including inside foreach templates and split branches (it is review-side/read-only with respect to git unless the author's code itself writes files in the worktree — same as script nodes today; the file-scope guard still applies to anything it commits via the session, and the code node itself never commits). +- **Editor**: palette entry + inspector with a code textarea (monospace, esbuild syntax errors surfaced at save), timeout input. + + +--- + +## Requirements + +- R1: A workflow can define a per-step template subgraph instantiated once per planned step at runtime (`foreach`, source `task-steps`). +- R2: Step execution is exposed by the substrate as the `step-execute` seam only. At `concurrency: 1` (default) instances run sequentially in step order in the task's main worktree; at `concurrency > 1` dependency-satisfied instances run concurrently in per-instance worktrees (R15). +- R3: Per-step review is a graph node whose APPROVE/REVISE/RETHINK/UNAVAILABLE verdicts route as outcome edges; validator enforces approve/revise routing and read-only placement rules. +- R4: RETHINK semantics (git reset to per-step baseline + session rewind + step→pending) are a substrate capability triggered by rework-edge traversal; baseline/checkpoint persist across restart. +- R5: Rework cycles are the only legal graph cycles, scoped to one template instance, bounded by `maxReworkCycles` (default 3, cap 10), with a routed exhaustion outcome. +- R6: `Task.steps[]` remains the projection sink via `store.updateStep`; merge-blocker, dashboard/TUI step display, reconcile, and lost-work behavior are unchanged for all tasks. +- R7: Instance run-state persists (schema v108), survives crash/restart with deterministic identity, is swept when stale, and is pruned per run; git reconcile remains authoritative over instance rows. +- R8: Zero planned steps → foreach no-ops through its success edge. +- R9: Default workflow byte-identical flag-ON/OFF (existing characterization suites stay green, unmodified in intent). +- R10: Flag pinned at dispatch; mid-flight toggles affect only subsequent dispatches; flag-OFF rollback mid-task converges via existing fell-back + git-reconcile recovery. +- R11: Built-in stepwise workflow reproduces the legacy per-step trajectory for equivalent runs (parity assertion via `workflow-parity.ts` observations). +- R12: Node editor can author/round-trip foreach, step-review, rework edges; IR validation errors surface at save time; i18n-wrapped strings; component tests registered in `qualityAppComponentTests`. +- R13: Agent tools and plugin SDK expose the new IR types (type-only in SDK); `fn_workflow_create/update` accept the new constructs with the same validation. +- R14: Five lifecycle invariants + lost-work guard trio remain non-configurable and covered by tests on the stepwise path. +- R15: Parallel step execution per KTD-11 — foreach exposes explicit `mode` (sequential|parallel) and `isolation` (shared|worktree) axes (parallel+shared validator-rejected); `dependsOn` parsed from PROMPT.md (unannotated steps depend on the previous step, preserving sequential behavior by default); per-instance worktrees off the integration base; ordered integration flips the projection (done iff integrated); rebase conflicts route `outcome:integration-conflict` to rework on the updated base within the rework budget; concurrency clamped by semaphore availability without deadlock; dependency cycles rejected at expansion. +- R16: Step parsing is a graph node per KTD-12 — `parse-steps(artifact, parser)` is the only graph-side writer of the step list, with routable `no-steps`/`parse-error` outcomes; workflows declare task artifacts; the parser registry's `step-headings` is the extracted `parseStepsFromPrompt`, byte-identical for legacy callers; a parse-steps node must dominate any foreach; parse-steps after foreach expansion trips the pin protection; plugin-registered parsers resolve through the plugin runtime and fail closed to `parse-error`. +- R17: Workflows define custom task fields (typed, enum options, render instructions) per KTD-13; values validated through a single store authority with typed rejections; field removal orphans (never destroys) values; agent tools have full read/write parity. +- R18: Task UI renders the field schema dynamically per KTD-14 — detail form widgets by type, card badges by placement, workflow-editor Fields panel; zero custom fields renders exactly today's UI. +- R19: `code` node per KTD-15 — save-time compile validation, child-process execution with clamped timeout and capped output, harness contract honored (no store handle, fields only via the validated patch, steps never), throw/timeout → audited failure. + +--- + +## Implementation Units + +### U1 — IR: foreach, step-review, rework edges, dependsOn parsing, validation + +- **Goal**: Additive WorkflowIr v2 extensions with full validation (R1, R3 validator half, R5 shape, R8 shape, R15 shape). +- **Files**: Modify `packages/core/src/workflow-ir-types.ts`, `packages/core/src/workflow-ir.ts`, `packages/core/src/types.ts` (`TaskStep.dependsOn?: number[]`), `packages/core/src/store.ts` (`parseStepsFromPrompt` regex extension for `### Step N (depends: 1,2): Title` — the current regex `^###\s+Step\s+\d+[^:]*:` breaks on the colon inside the annotation, so the updated regex must parse the annotation explicitly AND remain byte-identical for unannotated headings); tests `packages/core/src/__tests__/workflow-ir.test.ts` (extend), new `packages/core/src/__tests__/workflow-ir-foreach.test.ts`, store parse tests extended. +- **Approach**: Add `foreach`, `step-review`, and `parse-steps` to `WorkflowIrNodeKind` (parse-steps config validation: artifact key references a declared artifact, parser in the registry whitelist); `WorkflowIrEdge.kind?: "rework"`; foreach `config.template` as inline `{nodes, edges}` validated recursively (single entry/exit, no nested foreach this round, `step-execute` seam legal only here, `split` branches inside templates may contain only read-only nodes — extend `SEAM_FORBIDDEN_IN_BRANCH`). Verdict-routing validation per KTD-4 including the single-writer rule (step-review inside a split is advisory-only); **dominance validation**: a steps-populating node must precede any `foreach(source:"task-steps")` on all paths (KTD-3); rework edges legal only intra-template; `mode`/`isolation`/`concurrency` validation (parallel+shared rejected; concurrency ≥1, cap 8, parallel-mode-only); `V1_NODE_KINDS` untouched so `downgradeIrToV1IfPure` refuses these (KTD-8). +- **Patterns to follow**: `validateParallelism` / `walkBranchToJoin` (`workflow-ir.ts:92-180`) for region validation; hold-node config validation (`workflow-ir.ts:214-221`). +- **Test scenarios**: parse/validate happy path; template with 0/2 entries rejected; step-execute outside foreach rejected; step-execute inside a split branch rejected; rework edge crossing template boundary rejected; unrouted approve/revise rejected; unrouted rethink defaults to revise target; unrouted rework-exhausted defaults to failure; foreach not dominated by a parse-steps node rejected; parse-steps referencing an undeclared artifact or unknown parser rejected; verdict-authoring step-review inside a split rejected (advisory-only allowed); maxReworkCycles clamp (0→1? reject; 99→10); parallel+shared isolation rejected; concurrency clamp + concurrency-on-sequential rejected; `(depends: 1,2)` parsed into dependsOn; unannotated headings parse byte-identically to today; malformed depends annotation falls back to plain-name parse; v1 round-trip refusal (`downgradeIrToV1IfPure` returns v2 unchanged); JSON round-trip stability. +- **Verification**: new + extended IR tests green; `pnpm --filter @fusion/core build`. + +### U2 — Substrate seams: `runTaskStep` / `resetStepToBaseline` + +- **Goal**: Build the two substrate capabilities per KTD-2 (R2, R4 mechanics). **Execution note: characterization-first** — capture the `StepSessionExecutor` call sequence (updateStep ordering, commit observation, reset behavior incl. missing-baseline/checkpoint partial paths) before building on it. +- **Files**: Modify `packages/engine/src/executor.ts` (`:4291-4350` step-session paths and `:7455-7505` RETHINK block); new `packages/engine/src/step-runner.ts` if extraction warrants a module; tests `packages/engine/src/__tests__/step-runner.test.ts`, extend `packages/engine/src/__tests__/executor-step-session.test.ts`. +- **Approach**: `runTaskStep` is a thin driver over `StepSessionExecutor` (graph-owned runs force step-session physics, KTD-2/KTD-8); the RETHINK block is accessor-extracted verbatim into `resetStepToBaseline` with the blast-radius guard added. Baseline captured at instance start (substrate `git rev-parse HEAD` — documented behavior change, KTD-2). The agent still authors step commits; `runTaskStep` observes them. Legacy in-session path keeps calling the same underlying code — `fn_review_step` behavior is untouched. +- **Patterns to follow**: `runImplementationPhase` extraction (`executor.ts:3444-3455`); `executor-test-helpers.ts` harness. +- **Test scenarios**: runTaskStep marks in-progress→done with correct commit message; failure outcome leaves step non-done; reset with baseline+checkpoint does git reset + rewind + pending; reset missing baseline skips git reset but still flips pending; reset missing checkpoint skips rewind; legacy fn_review_step path byte-identical (characterization). +- **Verification**: characterization tests green pre- and post-extraction; engine vitest targeted suites; `pnpm --filter @fusion/engine exec tsc --noEmit`. + +### U3 — Graph executor: expansion, instance walk, bounded rework cycles + +- **Goal**: Executor support for foreach expansion, deterministic instance identity, sequential instance execution, rework back-edges with per-instance bounds, verdict outcome edges (R1, R2 ordering, R5, R8). +- **Files**: Modify `packages/engine/src/workflow-graph-executor.ts`, `packages/engine/src/workflow-node-handlers.ts`; tests new `packages/engine/src/__tests__/workflow-graph-foreach.test.ts`. +- **Approach**: On reaching a foreach node, read `Task.steps[]`, pin the count, materialize instance node ids `#:` and run each instance through an **iterative region sub-walk** modeled on `walkBranch` (KTD-5 — the recursive walk's cycle detector at `:145` is untouched); rework edges loop `currentId` back within the instance, decrementing the per-instance budget; exhaustion emits `outcome:rework-exhausted`. **Active-instance context is threaded via the existing `contextPatch` mechanism under the reserved key `foreach:active`** carrying `{foreachNodeId, stepIndex, baselineSha, checkpointId}` — handlers already read `context`, and U5/U8 depend on this key explicitly (decision promoted from Deferred; reserved-key prefix prevents collision with split/join context patches). Zero steps → success edge. Abort signal honored between nodes (existing posture). +- **Patterns to follow**: `runSplitJoin` / `walkBranch` (`workflow-graph-branches.ts:148-364`) for region sub-walks, abort, and completed-node skip; `executeNodeWithRetries` (`workflow-graph-executor.ts:244-280`) for bound clamping. +- **Test scenarios**: 3-step expansion runs 9 instance nodes in order; zero steps skips; revise rework loops twice then approves; rework exhaustion routes exhausted edge; rework budget is per-instance not shared; non-rework cycle still throws; abort mid-instance stops cleanly; outcome:unavailable retry-then-route; split fan-out of read-only checks inside a template joins correctly; `foreach:active` context key visible to template handlers and absent outside instances. +- **Verification**: foreach executor suite green; existing `workflow-graph-executor-parity.test.ts` and `workflow-graph-fanout.test.ts` untouched and green. + +### U4 — Persistence + resume + recovery (schema v108) + +- **Goal**: Instance run-state survives crash/restart; stale sweep; pruning (R7, R10 recovery half). +- **Files**: Modify `packages/core/src/db.ts` (migration 108), `packages/core/src/store.ts` (CRUD: `saveWorkflowRunStepInstance` / `loadWorkflowRunStepInstances` / `clearWorkflowRunStepInstances`), `packages/engine/src/executor.ts` (`buildStepInstancePersistence()`), `packages/engine/src/self-healing.ts` (stale sweep); tests `packages/core/src/__tests__/db-migrate.test.ts` (extend, incl. v107→108 forward path per #1417 pattern), new `packages/core/src/__tests__/workflow-step-instances.test.ts`, extend `packages/engine/src/__tests__/restart.integration.test.ts`. +- **Approach**: Mirror `workflow_run_branches` table + `WorkflowBranchPersistence` adapter + additive guard (`executor.ts:3302-3314`); resume reconstruction per KTD-6 with git-reconcile authoritative; sweep mirrors `recoverStaleTransitionPending`; prune per run. +- **Test scenarios**: migration 107→108 forward; CRUD round-trip; crash mid-instance-2 resumes at instance 2 with instances 0-1 skipped; crash during rework pass 2 resumes at pass 2 (seeded from `currentNodeId`+`reworkCount`, not re-running pass 1); steps[] grows on resume after pin → `pin-mismatch` failure; steps[] shrinks on resume → `pin-mismatch` failure; stale in-progress row with no lease swept to projection truth; git says step N done but row says in-progress → row corrected; rows pruned on run completion; pre-108 store → in-memory fallback (additive guard). +- **Verification**: core + engine suites green; schema version literal sweep done **up front, atomically with the migration commit**, using the broad pattern `grep -rn 'toBe(107)' packages/` (~40+ sites across at least 8 test files: db.test.ts alone has ~25; insight-store, goals-schema, run-audit, task-documents, store-merge-queue, merge-request-record, mission-store add more — the narrow `getSchemaVersion()).toBe(107)` pattern missed satellites last round). + +### U5 — step-review node handler + verdict wiring + +- **Goal**: Graph-native per-step review delegating to `reviewStep`, verdicts as outcomes, UNAVAILABLE limiter, rethink-triggers-reset (R3, R4 routing half). +- **Files**: Modify `packages/engine/src/workflow-node-handlers.ts`, `packages/engine/src/executor.ts` (handler wiring + reset trigger on rework traversal), `packages/engine/src/reviewer.ts` (only if a narrow option needs exposing); tests new `packages/engine/src/__tests__/workflow-step-review.test.ts`. +- **Approach**: Handler resolves the active instance from the `foreach:active` context key (U3), calls `reviewStep` under `semaphore.runNested`; verdict→outcome mapping per KTD-4 with single-writer verdict authority (split-branch reviews advisory-only); persists verdict + reworkCount into the instance row (replacing the in-memory maps **for graph-owned tasks only** — legacy maps untouched); rethink rework-edge traversal invokes `resetStepToBaseline` with persisted baseline/checkpoint before re-entering the instance. +- **Patterns to follow**: `createGateHandler` / `createPromptLikeHandler` (`workflow-node-handlers.ts:43-66`); fail-closed posture of gates. +- **Test scenarios**: approve marks step done (projection) and routes approve edge; revise routes rework without reset; rethink resets (git+session+pending) then re-executes; unavailable retries then routes; verdict persisted across simulated restart; review node in a split branch runs read-only against the same instance without verdict clobbering (serialized per instance). +- **Verification**: step-review suite green; reviewer suites untouched green. + +### U6 — Projection discipline: updateStep wiring + guard audit + +- **Goal**: All instance lifecycle writes flow through `store.updateStep` with projection-first ordering; guard-suppressed graph writes audit loudly; PROMPT.md-edit-after-expansion notice (R6, KTD-7). +- **Files**: Modify `packages/engine/src/executor.ts` / `workflow-graph-executor.ts` (write ordering), `packages/core/src/store.ts` (audit warning on suppressed graph-source updateStep; explicit-index bypass of auto-reinit for graph-owned tasks); tests extend `packages/core/src/__tests__/store.test.ts` step sections, new assertions in `workflow-graph-foreach.test.ts`. +- **Approach**: `updateStep` gains an optional `source: "graph"` arg (additive, default legacy semantics); graph-source writes relax the out-of-order-done guard (`store.ts:7592-7610`) from index order to **dependency order** (done is legal when all `dependsOn` steps are done — KTD-11); projection-first ordering (updateStep before instance row flip) closes the merge-blocker race; merge-blocker, dashboard, TUI, mesh-lease, lost-work paths verified unchanged by existing tests. +- **Test scenarios**: projection-first ordering observable (merge-blocker never sees completed-instance/pending-step inversion); guard-suppressed graph write emits audit warning; legacy updateStep silent-ignore behavior unchanged; auto-reinit bypass only for graph-owned tasks; zero-step task mergeable. +- **Verification**: store + task-merge suites green; characterization suites green. + +### U7 — Built-in stepwise workflow + parity & invariant coverage + +- **Goal**: Ship the demonstration workflow; prove trajectory parity and invariant preservation (R9, R11, R14, R10 toggle tests). +- **Files**: New `packages/core/src/builtin-stepwise-coding-workflow-ir.ts` (+ registration alongside the existing builtin); tests new `packages/engine/src/__tests__/stepwise-workflow-parity.test.ts`, extend flag-toggle/crash coverage in `restart.integration.test.ts`. +- **Approach**: IR per KTD-9; parity via `workflow-parity.ts` observation comparison of the `updateStep` trajectory + merge-blocker windows against the **legacy step-session path** (`runStepsInNewSessions` ON — the deterministic per-step oracle; the agent-paced monolithic path is not deterministically comparable and stays covered by the existing default-workflow characterization suites). **Test-file ownership is explicit**: `stepwise-workflow-parity.test.ts` owns updateStep-trajectory + merge-blocker-window comparisons (scripted runs, legacy vs stepwise); the existing `workflow-graph-executor-parity.test.ts` stays focused on default-workflow byte-identity — a header comment in each file declares its parity subject. Explicit tests for flag pinned-at-dispatch, toggle-mid-flight deferred to next dispatch, flag-OFF rollback converging via git reconcile; five invariants + lost-work trio exercised on the stepwise path. +- **Test scenarios**: identical updateStep sequence legacy-step-session vs stepwise for a 3-step approve-all run; revise-then-approve trajectory parity; RETHINK trajectory parity (incl. git state, and captured-baseline == agent-equivalent baseline assertion per KTD-2); RETHINK blast-radius guard refuses when a later instance row is completed; FN-5147 terminal-until-merged on stepwise; hard-cancel mid-instance; file-scope guard fires inside step-execute; toggle mid-run does not switch paths; OFF-rollback then legacy resume completes the task. +- **Verification**: parity suite green; full default-workflow characterization suites green unmodified. + +### U8 — Node editor authoring + flow mapping + +- **Goal**: Author/round-trip foreach (group node), step-review, rework edges; save-time validation surfacing (R12). +- **Files**: Modify `packages/dashboard/app/components/WorkflowNodeEditor.tsx`, `packages/dashboard/app/components/workflow-flow-mapping.ts`, `packages/dashboard/app/components/WorkflowNodeEditor.css` (or sibling component CSS per the extraction convention); tests extend `packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx`, `workflow-flow-mapping.test.ts`; register any new component test in `packages/dashboard/vitest.config.ts` `qualityAppComponentTests`. +- **Approach** (design decisions committed here, not deferred): + - **Template authoring is inline**: template nodes are always-visible React Flow children of the foreach group node (`parentId` set to the group), no drill-in canvas mode. `flowToIr` partitions nodes by `parentId` — children of a foreach group reassemble into that node's `config.template`; everything else stays top-level. Empty foreach groups render an empty-state hint ("drag a step-execute node here"). + - **Palette**: add `foreach` (preset `source:"task-steps"`, auto-populating one `step-execute` child so the group is never confusingly empty), `step-review`, `parse-steps` (preset artifact `PROMPT.md`, parser `step-headings`; inspector renders an artifact select over the workflow's declared artifacts and a parser select sourced from the live registry incl. plugin parsers), and `code` (inspector: monospace source textarea with save-time esbuild error surfacing + timeout input) entries to the PALETTE array (`WorkflowNodeEditor.tsx:87`). + - **Inspector fields**: `foreach` branch renders a `mode` select (sequential|parallel), an `isolation` select (shared|worktree — shared disabled when mode is parallel, matching the validator), a numeric `concurrency` input shown only in parallel mode (min 1, max 8, placeholder 2), and a numeric `maxReworkCycles` input (min 1, max 10, placeholder 3 — mirroring the `maxRetries` input pattern at `WorkflowNodeEditor.tsx:711`); `step-review` branch renders a `type` select (plan|code) and the existing `CustomModelDropdown` (optional, like the gate node's model field). + - **Edge authoring**: selecting an edge whose source is a `step-review` node shows an edge inspector with a condition dropdown (approve/revise/rethink/unavailable — stored as `outcome:` conditions, displayed as short labels) and a "rework" toggle (sets `kind:"rework"`). Rework edges render dashed in the accent color with a loop indicator. + - Validation errors from `parseWorkflowIr` surface inline at save (existing pattern); all strings `t("key","Default")`; i18n keys added across the 6 locales via the deep-merge convention (do not let the sync prune dynamic keys — prior incident). + - **Per-instance metadata display** (KTD-10): a per-step row group in the existing `WorkflowResultsTab` — step name, verdict chip per review pass, rework count badge when > 0, `rework-exhausted` rendered as a warning state. +- **Test scenarios**: round-trip IR→flow→IR stability with foreach template (children partitioned by parentId); save with unrouted approve edge shows validation error; rework edge create/delete via edge inspector; foreach/step-review inspector field editing; palette auto-populates step-execute child; i18n keys present. +- **Verification**: dashboard component shards green locally (`qualityAppComponentTests` batch); editor save produces v2 IR accepted by core parser. + +### U10 — Parallel step execution: per-instance worktrees, dependency scheduler, ordered integration + +- **Goal**: KTD-11 end to end on top of sequential foreach (R15). **Execution note: test-first** for the integration/conflict state machine. +- **Files**: Modify `packages/engine/src/workflow-graph-executor.ts` (dependency-aware instance scheduler inside foreach), `packages/engine/src/executor.ts` / `packages/engine/src/worktree-pool.ts` (per-instance worktree/branch allocation + release), new `packages/engine/src/step-integration.ts` (ordered rebase/cherry-pick integration stage + conflict detection); tests new `packages/engine/src/__tests__/workflow-step-parallel.test.ts`, extend `restart.integration.test.ts`. +- **Approach**: Two orthogonal switches from KTD-3 config: worktree isolation (per-instance branch + ordered integration — also usable in sequential mode) and parallel scheduling (runnable set = instances whose `dependsOn` are all integrated; schedule up to `min(concurrency, free semaphore slots)` concurrently). Each isolated instance runs as its own step-session in its own worktree branched from the integration base; completion enqueues `awaiting-integration`; the integration stage lands branches strictly in step order, flipping the projection (`updateStep done`, graph source) only on success; conflict discards the branch and routes `outcome:integration-conflict` (rework on updated base, budget-counted). Branch-scoped RETHINK. Worktrees released on integration/discard (pool hygiene). +- **Patterns to follow**: `worktree-pool.ts` allocation/canonical naming; `runSplitJoin` concurrency + abort wiring (`workflow-graph-branches.ts`); merger rebase/conflict handling for the integration mechanics (`merger.ts` — reuse its conflict-classification helpers, do not reimplement). +- **Test scenarios**: diamond dep graph (1 ← 2,3 ← 4) runs 2∥3 then 4; sequential+worktree runs one at a time with per-step branches and ordered integration; unannotated plan stays fully sequential at concurrency 4; conflict between parallel steps → loser reworks on updated base and succeeds; conflict rework exhaustion routes rework-exhausted; integration order is step order even when completion order inverts; crash with one branch un-integrated resumes into the integration queue; branch missing on resume re-runs the instance; semaphore starvation degrades to sequential without deadlock; dependency cycle at expansion fails audited; RETHINK resets only the instance branch; merge-blocker stays blocked until last integration (projection rule). +- **Verification**: parallel suite green; sequential foreach suites (U3) untouched green; file-scope guard + lost-work trio exercised on parallel paths. + +### U11 — Custom task fields: IR schema, storage, write authority + +- **Goal**: KTD-13 core half (R17). **Execution note: test-first** for the validation authority. +- **Files**: Modify `packages/core/src/workflow-ir-types.ts` / `workflow-ir.ts` (`fields` declaration + validation), `packages/core/src/types.ts` (`Task.customFields?: Record`, `WorkflowFieldDefinition`), `packages/core/src/db.ts` (customFields column rides the U4 v108 migration), `packages/core/src/store.ts` (`updateTaskCustomFields` authority + `updateTask` integration + orphan handling on workflow edit/switch); new `packages/core/src/task-fields.ts` (validation: type check, enum membership, render whitelist); tests new `packages/core/src/__tests__/task-fields.test.ts`, workflow-ir tests extended. +- **Approach**: per KTD-13 — single write authority with typed rejections (TransitionRejection style); field removal orphans values; incompatible type change rejected unless `coerce` named (reuses the `rehome_to` conflict-resolution pattern from workflow updates); workflow-switch keeps same-id values. +- **Patterns to follow**: `workflow-ir.ts` column validation; `plugin-gate-verdict.ts` typed-shape style; #1409 reconciliation posture. +- **Test scenarios**: each type validates/rejects correctly; enum membership enforced; multi-enum subsets; unknown field id rejected; required default applied at task create under the workflow; field removed → value orphaned not deleted; type change without coerce rejected, with coerce honored; workflow switch keeps same-id values and orphans the rest; zero-fields workflow → writes to custom_fields rejected cleanly; JSON round-trip. +- **Verification**: core suite green; v108 migration test covers the column. + +### U12 — `parse-steps` node, workflow artifacts & parser registry + +- **Goal**: KTD-12 (R16) — the `parse-steps` node handler, artifact declarations, and the parser registry. **Execution note: characterization-first** — pin `parseStepsFromPrompt` behavior before extraction. +- **Files**: Modify `packages/core/src/workflow-ir-types.ts` / `workflow-ir.ts` (`artifacts` declaration — parse-steps node-kind validation itself lands in U1), new `packages/core/src/step-parsers.ts` (registry + `step-headings` extraction + `json-steps`), `packages/core/src/store.ts` (`parseStepsFromPrompt` delegates to the registry), `packages/engine/src/workflow-node-handlers.ts` (parse-steps handler: read artifact → run parser → write step list via the graph-source projection path → outcome mapping), `packages/engine/src/plugin-parser-adapter.ts` (new — plugin parser registration through the plugin runtime, mirroring `plugin-trait-adapter.ts`; timeout + fail-closed mapping to parse-error), `packages/engine/src/executor.ts` (reconcile + planning-prompt artifact contract reads the workflow declaration; pin-protection check for parse-steps after expansion); tests new `packages/core/src/__tests__/step-parsers.test.ts`, new `packages/engine/src/__tests__/workflow-parse-steps.test.ts`, executor reconcile tests extended. +- **Approach**: per KTD-12 — the node is the only graph-side step-list writer; registry is built-in-only; foreach `source: "task-steps"` reads the projection; the default workflow keeps its legacy in-execute step init (untouched code path) while legacy `parseStepsFromPrompt` callers delegate to the same extracted parser (parity by construction); planning seam surfaces `producedBy: "planning"` artifact keys in its prompt. +- **Patterns to follow**: trait registry (`trait-registry.ts`) for the registry shape; `createGateHandler` for handler wiring; task-documents machinery for artifact backing. +- **Test scenarios**: step-headings extraction byte-identical on existing fixtures (incl. depends annotations); json-steps parses `[{name, depends}]`; parse-steps writes steps through the graph-source projection path; `outcome:no-steps` on clean-empty parse routes (defaults success); `outcome:parse-error` on malformed artifact routes (defaults failure), not crash; missing artifact → parse-error; parse-steps after foreach expansion → pin-protection audited failure; default workflow parity (direct call vs registry resolution identical); reconcile reads through the workflow's parse-steps declaration; plugin parser happy path through the runner; plugin parser timeout/throw → parse-error (fail-closed, audited); IR referencing an unregistered plugin parser rejected at save. +- **Verification**: characterization tests green pre/post extraction; core + engine suites green. + +### U13 — Dynamic task UI: field rendering + Fields panel + +- **Goal**: KTD-14 (R18). +- **Files**: Modify `packages/dashboard/app/components/TaskDetailModal.tsx` (schema-driven fields section), `packages/dashboard/app/components/TaskCard.tsx` (card-placed badges/chips), new `packages/dashboard/app/components/TaskFieldsSection.tsx` + `TaskFieldsSection.css` + `WorkflowFieldsPanel.tsx` (editor Fields panel, sibling to the column panel), `packages/dashboard/src/routes/` (field-values PATCH endpoint → store authority, 400 with field path), `packages/dashboard/src/routes/board-workflows.ts` (field defs in payload), CLI TUI task detail (read-only chips); tests new `packages/dashboard/app/components/__tests__/TaskFieldsSection.test.tsx` + `WorkflowFieldsPanel.test.tsx` (BOTH registered in `qualityAppComponentTests`), TaskCard/TaskDetailModal tests extended. +- **Approach**: per KTD-14 — widget per type/render hint; max-3 card fields with overflow indicator; enum colors from options; orphaned-fields disclosure in detail; live badge preview in the Fields panel; zero custom fields renders exactly today's UI (snapshot-guarded); all strings `t()`-wrapped, 6-locale deep-merge. +- **Test scenarios**: each widget type renders + edits + validation error inline; card badge placement honors max-3 overflow; enum color applied; orphaned values shown in disclosure, absent from card; Fields panel CRUD + option color editor + save-time IR validation surfaced; zero-fields snapshot identical; SSE workflow:updated refreshes field defs. +- **Verification**: dashboard component shards green; `qualityAppComponentTests` registration verified; TUI render test. + +### U14 — `code` node: compile, sandbox runner, harness contract + +- **Goal**: KTD-15 (R19). **Execution note: test-first** for the harness contract and failure modes. +- **Files**: Modify `packages/core/src/workflow-ir-types.ts` / `workflow-ir.ts` (`code` node kind + save-time esbuild syntax validation + source/timeout clamps), new `packages/engine/src/code-node-runner.ts` (esbuild in-memory compile + child-process execution + harness I/O + output caps), `packages/engine/src/workflow-node-handlers.ts` (handler wiring: ctx assembly incl. `foreach:active`, result mapping to outcome/contextPatch/customFields-via-U11-authority); tests new `packages/engine/src/__tests__/code-node.test.ts`, IR validation tests extended. +- **Approach**: per KTD-15 — esbuild is already in the toolchain (vitest); child process gets cwd = worktree, a minimal env, and the serialized ctx; no store handle crosses the boundary; returned customFields patch goes through `updateTaskCustomFields`; stdout/stderr captured (capped) into the node result for the audit log. +- **Patterns to follow**: script-step execution for process spawning + capture posture; `createPromptLikeHandler` for handler shape; U11 authority for field writes. +- **Test scenarios**: happy path returns value + routes success; `outcome: "foo"` routes `outcome:foo` edge; contextPatch merges; customFields patch validated (invalid → node failure with typed rejection surfaced); syntax error rejected at IR save; runtime throw → failure audited; timeout kills the child and fails; output cap enforced; source size cap; inside a foreach template receives `instance`; no steps-write path exists (attempting one is not expressible via the contract). +- **Verification**: code-node suite green; validator round-trip green. + +### U9 — Agent tools, plugin SDK, docs, changeset + +- **Goal**: Agent-native parity and documentation (R13, R16/R17 agent halves). +- **Files**: Modify `packages/engine/src/agent-tools.ts` (`fn_workflow_create/update` accept `fields`/`artifacts`; `fn_task_update` accepts `custom_fields` patch through the U11 authority; planning-prompt guidance for `depends:` annotations; `fn_trait_list` untouched), `packages/cli/skill/fusion/references/engine-tools.md` (tool table — the skill-sync test enforces this), `packages/plugin-sdk/src/index.ts` (TYPE-ONLY re-exports of new IR types — runtime exports break the standalone-artifact test), `docs/workflow-steps.md`, `docs/architecture.md` §9 extension, `CONCEPTS.md`; new `.changeset/step-inversion-workflow-modelable-steps.md` (with rollback note, mirroring the columns changeset). +- **Test scenarios**: fn_workflow_create with a foreach IR + fields validates and persists; invalid template/field rejected with the IR error surfaced; fn_task_update custom_fields validated through the authority; SDK type-only export keeps standalone-artifact test green; skill-sync test green. +- **Verification**: agent-tools tests green; plugin-sdk test green; docs render. + +### Dependencies & sequencing + +U1 → (U2 ∥ U4-core-half) → U3 → U5 → U6 → U10 → U7; U9 last. U2, U3, U5, U6, U10 are **strictly serial** (all touch `executor.ts`/graph executor). U4's core-only half (db.ts migration + store CRUD) parallelizes with U2; U4's executor wiring (`buildStepInstancePersistence`) waits for U3. U10 (parallel/isolated execution) builds on sequential foreach (U3) + projection discipline (U6) and lands before U7 so the parity/invariant suite covers all modes. U8 may start IR-type authoring, palette, inspector, and CSS work in parallel with U3–U6 (the `foreach:active` context decision is now committed in U3's approach, so no mid-stream rewrite risk); its round-trip tests land after U1 is merged. + +The task-shape track is largely independent of the step-execution track: **U11 (fields core) parallelizes with U2–U6** (core-only, no executor surface beyond the U4 migration it shares); **U12 (artifacts/parsers) follows U1** (shares IR validation) and its executor touches (reconcile/planning contract) slot between U6 and U10 in the serial executor chain; **U13 (dynamic UI) follows U11** and parallelizes with U8 (different dashboard surfaces; both register component tests). U7's parity scope includes U12's default-workflow declaration. **U14 (code node) follows U1** (node kind) and **U11** (field-write authority); its engine half is independent of the U2–U10 executor chain except the handler-registration touch, which slots anywhere after U3. + +--- + +## Risks & Mitigations + +- **RETHINK convergence (highest risk)**: git reset + session rewind + projection + instance rows must converge. Mitigation: substrate owns the whole reset atomically (KTD-2); baseline/checkpoint persisted (KTD-6); rework traversal is the single trigger point (U5); trajectory parity test for RETHINK specifically (U7). +- **Cycle-support regression in the executor**: loosening the cycle detector could mask authored-graph bugs. Mitigation: exemption is narrowly scoped (rework kind + same active instance), non-rework cycles still throw (U3 test). +- **Dual-writer on flag transition**: closed by pin-at-dispatch (KTD-8) + OFF-rollback convergence tests (U7). +- **`executor.ts` merge conflicts with main**: extraction in a ~3k-line function while main moves (see `docs/solutions/best-practices/merge-conflict-extraction-vs-semantics-and-parallel-bootstrap.md`). Mitigation: extraction commits kept mechanical and early; rebase before U5+. +- **Projection guard masking**: `updateStep` silently ignores invalid transitions, which would hide projection bugs. Mitigation: audit-loud for graph-source writes (U6). +- **Schema-version literal sweep**: v107 bump missed satellite test files last round (4 CI rounds). Mitigation: explicit grep sweep in U4's verification. +- **Integration-conflict churn (parallel mode)**: heavily-overlapping steps marked independent would loop integrate→conflict→rework until budget exhaustion. Mitigation: optimistic conflicts are budget-counted with a routed exhaustion outcome; unannotated steps default to sequential dependence, so parallelism only exists where the planner asserted it; the planning prompt guidance (U9 docs) tells planners to annotate `depends:` conservatively. +- **Worktree pool pressure**: per-instance worktrees multiply pool usage. Mitigation: concurrency cap 8, semaphore clamp, and release-on-integration hygiene in U10; pool reuse machinery already exists for fan-out branches. +- **Parser extraction parity (KTD-12)**: `parseStepsFromPrompt` has subtle behaviors (auto-reinit interplay, regex edge cases) that a registry indirection could perturb. Mitigation: characterization-first in U12; default workflow resolves to the same extracted function, asserted identical on existing fixtures. +- **Dynamic UI regression surface (KTD-14)**: schema-driven rendering touches TaskCard/TaskDetailModal, the two highest-traffic components. Mitigation: zero-fields snapshot guard (today's UI byte-identical when no fields are defined); new rendering isolated in `TaskFieldsSection`; card overflow bounded at 3. +- **Code-node misuse surface (KTD-15)**: arbitrary TS in workflows can do anything the user can. Mitigation: explicit trust framing (same tier as existing script steps), no store handle across the boundary, field writes only through the validated patch, steps never writable, clamped timeout + output caps, save-time compile rejection, full stdout/stderr audit capture. +- **Plugin parser availability drift (KTD-12)**: a workflow saved against a plugin parser can outlive the plugin. Mitigation: save-time registry check; at runtime fail-closed to `parse-error` (routable), never a crash; audit names the missing parser id. +- **Field-schema drift vs stored values**: workflow edits can strand values. Mitigation: orphan-not-delete posture (KTD-13), `coerce` confirmation on incompatible type changes, orphaned-fields disclosure in detail UI. + +## System-Wide Impact + +- `Task.steps[]` consumers: none change (KTD-7) — verified, not assumed, by existing suites in U6/U7. +- `workflow_run_branches` pattern reused, not modified; `workflow_run_step_instances` is net-new. +- Legacy in-session step path: untouched code paths; only extraction-level refactor (U2) with characterization proof. +- The columns-track flag (`workflowColumns`) and its graduation report are untouched; this track rides `workflowGraphExecutor`. + +## Deferred to Implementation + +- Whether `runTaskStep` needs a distinct module (`step-runner.ts`) or stays an executor method — decide by extraction size in U2. +- Whether the foreach instance sub-walk reuses `runSplitJoin`'s AbortController/semaphore wiring for read-only split fan-out inside templates, or a separate path — decide in U3 (split-inside-sequential-loop is untested territory in `workflow-graph-branches.ts`). +- Tool-message wording for PROMPT.md-edited-after-expansion notice (U6). +- Display-label strings for `outcome:` edge conditions in the editor (U8 renders short labels; exact i18n copy at implementation). + +## References + +- Predecessors: `docs/plans/2026-06-03-003-feat-workflow-custom-columns-traits-plan.md` (completed), `docs/plans/2026-06-03-002-feat-workflow-interpreter-cutover-plan.md` (superseded; its executor characterization findings remain accurate), `docs/plans/2026-06-03-001-feat-executable-custom-workflows-node-editor-plan.md` (completed). +- Contracts: `docs/architecture.md` §9 (substrate/policy framing); `docs/rfcs/FN-5719-decouple-executor-merger.md` (lifecycle seam + invariant bar). +- Key code anchors: `packages/core/src/store.ts:7546` (updateStep), `packages/core/src/task-merge.ts:202` (merge-blocker), `packages/engine/src/executor.ts:3444` (runImplementationPhase), `:7300-7505` (fn_review_step/RETHINK), `:11067` (reconcile), `packages/engine/src/workflow-graph-executor.ts:145` (cycle detector), `:244-280` (retries), `packages/engine/src/workflow-graph-branches.ts` (region walk + persistence pattern), `packages/engine/src/reviewer.ts:306` (reviewStep), `packages/core/src/workflow-ir.ts` (validators). diff --git a/docs/residual-review-findings/gsxdsm-custom-columns.md b/docs/residual-review-findings/gsxdsm-custom-columns.md new file mode 100644 index 0000000000..9076e776ee --- /dev/null +++ b/docs/residual-review-findings/gsxdsm-custom-columns.md @@ -0,0 +1,32 @@ +# Residual Review Findings — `gsxdsm/custom-columns` + +Source: `ce-code-review mode:autofix` run `20260604-021808-25e15199` (13 reviewers + 9 validators) against merge-base `962b97ce8`, plan `docs/plans/2026-06-03-003-feat-workflow-custom-columns-traits-plan.md`. 14 safe fixes were applied and committed (`30e2fd7b0`); the findings below were filed as tracked issues and have ALL been fixed on this branch (commits 571768f03..afc72b6bb); the issues close when PR #1418 merges (Fixes references in the PR body). + +## Residual Review Findings + +- [P1] `packages/core/src/transition-pending.ts:39` — Implement transitionPending recovery sweep (markers leak capacity slots after crash) — [#1401](https://github.com/Runfusion/Fusion/issues/1401) +- [P1] `packages/engine/src/hold-release.ts:98` — Consolidate 4 copies of the workflow-IR resolution rule — [#1402](https://github.com/Runfusion/Fusion/issues/1402) +- [P1] `packages/core/src/store.ts:5697` — Widen moveTaskInternal/Task.column to ColumnId (type-honesty refactor) — [#1403](https://github.com/Runfusion/Fusion/issues/1403) +- [P1] `packages/dashboard/src/routes/register-task-workflow-routes.ts:1414` — Add route-level tests for POST /tasks/:id/promote — [#1404](https://github.com/Runfusion/Fusion/issues/1404) +- [P1] `packages/core/src/workflow-ir.ts:245` — WorkflowIr v2 persistence breaks rollback to pre-v2 binaries — [#1405](https://github.com/Runfusion/Fusion/issues/1405) +- [P1] `packages/dashboard/src/routes/register-workflow-routes.ts` — Emit workflow:updated SSE event; invalidate boardWorkflows on workflow edits — [#1406](https://github.com/Runfusion/Fusion/issues/1406) +- [P1] `packages/engine/src/workflow-graph-task-runner.ts` — Wire SQLite branch persistence for fan-out runs in production — [#1407](https://github.com/Runfusion/Fusion/issues/1407) +- [P1] `packages/engine/src/agent-tools.ts` — Agent-native parity: fn_task_promote, reconcile-aware fn_workflow_select, workflow CRUD, trait catalog — [#1408](https://github.com/Runfusion/Fusion/issues/1408) +- [P1] `packages/core/src/store.ts` — Define flag ON→OFF evacuation policy for cards in custom columns — [#1409](https://github.com/Runfusion/Fusion/issues/1409) +- [P2] `packages/dashboard/app/components/Column.tsx:193` — Clear inline capacity feedback when column tasks change via SSE — [#1410](https://github.com/Runfusion/Fusion/issues/1410) +- [P2] `packages/engine/src/self-healing.ts` — Recovery moves on custom workflows can be rejected by order-derived adjacency (use `recoveryRehome`) — [#1411](https://github.com/Runfusion/Fusion/issues/1411) +- [P2] `packages/core/src/db.ts:581` — Prune workflow_run_branches per run (unbounded growth) — [#1412](https://github.com/Runfusion/Fusion/issues/1412) +- [P2] `packages/core/src/store.ts:5039` — Filter branch-progress JOIN to the latest run in SQL — [#1413](https://github.com/Runfusion/Fusion/issues/1413) +- [P2] `packages/dashboard/src/routes/register-task-workflow-routes.ts:816` — Add HTTP integration test for GET /tasks/board-workflows — [#1414](https://github.com/Runfusion/Fusion/issues/1414) +- [P2] `packages/engine/src/hold-release.ts` — Add concurrent-sweep safety test (overlapping runHoldReleaseSweep) — [#1415](https://github.com/Runfusion/Fusion/issues/1415) +- [P2] `packages/dashboard/app/components/Board.tsx:354` — Test canDropTask pre-check branches at Board level — [#1416](https://github.com/Runfusion/Fusion/issues/1416) +- [P3] `packages/core/src/__tests__/db-migrate.test.ts` — Add v105→106/107 forward-path migration tests — [#1417](https://github.com/Runfusion/Fusion/issues/1417) + +## Advisory (report-only, no ticket) + +- [P2] `packages/core/src/store.ts` — store.ts grew ~1k lines; extract the flag-OFF legacy effects block for symmetry (maintainability) +- [P2] `packages/core/src/db.ts:4237` — applyMigration is non-transactional; the ALTER TABLE justification comment doesn't apply to DDL-only migration 107 (data-migration) +- [P2] `packages/core/src/store.ts:12192` — integrity pass silently re-homes; add a WARNING log or dry-run preview (data-migration) +- [P3] `packages/engine/src/hold-release.ts:304` — sweep countPending pre-check branch is dead (Task lacks transitionPending); authoritative in-txn count unaffected (correctness) +- [P3] `packages/core/src/store.ts:5771` — getSettingsFast awaited per move; consider a short-TTL settings cache (performance) +- Note: blocking *plugin* gate verdicts currently always reject (fail-closed) — `recordPluginGateVerdict` has no production caller yet; wire it when plugin gates ship end-to-end (security) diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 33f5614942..4818e04dd3 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -57,6 +57,10 @@ Current reconciliation in v1: FN-5769 evaluated whether those conventions required a `1.1.0` schema bump and recorded the answer as **no**: the current `prompt` + `config` and canonical `edge.condition` token conventions are sufficient for the parity-critical interpreter rollout, so they remain the canonical v1 contract until a future consumer needs stronger schema-level validation or discoverability. +### Workflow IR v2 — columns, traits, hold & split/join nodes + +The `workflowColumns` track introduces **IR v2** (`version: "v2"`), where a workflow additionally defines its own **columns** (`{ id, name, traits: [{ trait, config }] }`), places nodes in columns (`node.column`), and gains `hold`, `split`, and `join` node kinds. Columns become first-class, workflow-defined task state carrying composable **traits** (declarative flags + lifecycle hooks); this generalizes the fixed pipeline + the `gateMode` semantics documented below into per-column trait configuration. v1 graphs still parse and upgrade by synthesizing default-workflow columns. The column/trait model — the trait vocabulary, the substrate/policy line, the transition authority, and the graduation gate — is documented in **`docs/architecture.md` § 9 "Workflow-defined columns & traits"** and the **Concepts** glossary (column, trait, lane, hold node, split/join, default workflow, `transitionPending`). The whole v2 model is gated behind `experimentalFeatures.workflowColumns`; with the flag off, the v1 IR and the quality-gate `WorkflowStep` model below are unchanged. + ## What They Are A workflow step is a reusable check (AI prompt or script) that can be enabled on tasks. diff --git a/eslint.config.mjs b/eslint.config.mjs index e6de615985..2b706159d2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -103,6 +103,8 @@ export default tseslint.config( // Vitest temporary workspace resolution directories ".tmp-fn-*/**", ".claude/**", + // Generated i18n resource typings (emitted by i18n:types) + "packages/i18n/src/resources.d.ts", // Lock files "*.lock", "pnpm-lock.yaml", diff --git a/packages/cli/skill/fusion/references/engine-tools.md b/packages/cli/skill/fusion/references/engine-tools.md index 49e4cfe492..bf1ce2eddc 100644 --- a/packages/cli/skill/fusion/references/engine-tools.md +++ b/packages/cli/skill/fusion/references/engine-tools.md @@ -17,6 +17,11 @@ These tools are **not** part of the user-invokable extension surface. They are i | `fn_task_document_read` | triage, executor, heartbeat | Read one task document or list all | `key?` (string) | | `fn_workflow_list` | executor | List the project's custom workflows (read-only built-ins plus user definitions) | none | | `fn_workflow_select` | executor | Assign a custom workflow to a task (defaults to the current task) | `workflow_id` (string), `task_id?` (string) | +| `fn_workflow_create` | executor | Create a custom workflow definition from a graph IR (validated server-side) | `name` (string), `description?` (string), `ir` (object), `layout?` (object) | +| `fn_workflow_update` | executor | Update a custom workflow definition's name/description/ir/layout (built-ins cannot be edited) | `workflow_id` (string), `name?` (string), `description?` (string), `ir?` (object), `layout?` (object), `rehome_to?` (string) | +| `fn_workflow_delete` | executor | Delete a custom workflow definition (built-ins cannot be deleted); selecting tasks are re-homed to the default workflow's entry column | `workflow_id` (string) | +| `fn_task_promote` | executor | Promote a held task out of a manual-release hold column (defaults to the current task) | `task_id?` (string) | +| `fn_trait_list` | executor | List the registered column trait catalog (built-in and plugin traits) | none | | `fn_memory_search` | triage, executor, heartbeat | Search project memory plus per-agent layered memory snippets | `query` (string), `limit?` (number) | | `fn_memory_get` | triage, executor, heartbeat | Read a bounded memory file window (including bounded per-agent layered paths) | `path` (string), `startLine?` (number), `lineCount?` (number) | | `fn_memory_append` | executor, heartbeat (when writable backend enabled) | Append memory notes with explicit scope: `scope="agent"` for private operating context, `scope="project"` for workspace-wide durable knowledge | `scope?` (`project` \| `agent`), `layer` (`long-term` \| `daily`), `content` (string) | diff --git a/packages/cli/src/__tests__/task-plan.test.ts b/packages/cli/src/__tests__/task-plan.test.ts index 82380561c8..a8ae3ebd3b 100644 --- a/packages/cli/src/__tests__/task-plan.test.ts +++ b/packages/cli/src/__tests__/task-plan.test.ts @@ -6,7 +6,8 @@ vi.mock("node:readline/promises", () => ({ })); // Mock @fusion/core before importing -vi.mock("@fusion/core", () => ({ +vi.mock("@fusion/core", async (importOriginal) => ({ + ...(await importOriginal()), TaskStore: vi.fn(), COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"], COLUMN_LABELS: { diff --git a/packages/cli/src/__tests__/task-steer.test.ts b/packages/cli/src/__tests__/task-steer.test.ts index 7cfa7f143a..bec5070b4d 100644 --- a/packages/cli/src/__tests__/task-steer.test.ts +++ b/packages/cli/src/__tests__/task-steer.test.ts @@ -6,7 +6,8 @@ vi.mock("node:readline/promises", () => ({ })); // Mock @fusion/core before importing -vi.mock("@fusion/core", () => ({ +vi.mock("@fusion/core", async (importOriginal) => ({ + ...(await importOriginal()), TaskStore: vi.fn(), COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"], COLUMN_LABELS: { diff --git a/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx b/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx index afd9a1c615..be868d0d4d 100644 --- a/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx +++ b/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx @@ -640,6 +640,89 @@ describe("Board view", () => { }); }); +describe("Board view — U11 custom-column graceful degradation (R18)", () => { + function renderBoardWithTasks(tasks: TaskItem[], cols = 200, rows = 50) { + const controller = newController(); + controller.setSystemInfo(makeSystemInfo()); + controller.setInteractiveData(makeInteractiveData({ + projects: [{ id: "p1", name: "my-project", path: "/tmp/p" }], + tasks, + })); + controller.setMode("interactive"); + controller.setInteractiveView("board"); + const rendered = render(renderDashboardAppNode(controller)); + setTerminalSize(rendered, cols, rows); + rendered.rerender(renderDashboardAppNode(controller)); + return rendered; + } + + it("renders an 'Other (custom)' bucket between in-review and done", async () => { + const { lastFrame, unmount } = renderBoardWithTasks([ + { id: "t1", title: "Legacy", description: "", column: "todo" }, + ]); + await waitForFrameContains(lastFrame, "OTHER (CUSTOM)"); + const frame = lastFrame() ?? ""; + const headerLine = frame.split("\n").find((l) => l.includes("OTHER (CUSTOM)")) ?? ""; + // Column headers share a row; verify in-review precedes other precedes done. + expect(headerLine.indexOf("IN REVIEW")).toBeLessThan(headerLine.indexOf("OTHER (CUSTOM)")); + expect(headerLine.indexOf("OTHER (CUSTOM)")).toBeLessThan(headerLine.indexOf("DONE")); + unmount(); + }); + + it("never drops a card: a task in an unknown column with no flags surfaces in 'Other (custom)' with its column name", async () => { + const { lastFrame, unmount } = renderBoardWithTasks([ + { id: "drop1", title: "Should Not Vanish", description: "", column: "staging", columnName: "Staging" }, + ]); + await waitForFrameContains(lastFrame, "Should Not Vanish"); + const frame = lastFrame() ?? ""; + // The card is visible AND shows its real column name as a secondary label. + expect(frame).toContain("Should Not Vanish"); + expect(frame).toContain("Staging"); + // Other bucket header count reflects the card. + expect(frame).toContain("OTHER (CUSTOM) (1)"); + unmount(); + }); + + it("maps trait-flagged custom columns into legacy buckets (intake→todo, mergeBlocker→in-review, complete→done, wip→in-progress)", async () => { + const { lastFrame, unmount } = renderBoardWithTasks([ + { id: "i", title: "IntakeCard", description: "", column: "triage", columnName: "Triage", columnFlags: { intake: true } }, + { id: "r", title: "ReviewCard", description: "", column: "gate", columnName: "Gate", columnFlags: { mergeBlocker: true } }, + { id: "d", title: "DoneCard", description: "", column: "shipped", columnName: "Shipped", columnFlags: { complete: true } }, + { id: "w", title: "WipCard", description: "", column: "building", columnName: "Building", columnFlags: { countsTowardWip: true } }, + ]); + await waitForFrameContains(lastFrame, "IntakeCard"); + const frame = lastFrame() ?? ""; + // All four mapped to legacy buckets; none in the "other" bucket. + expect(frame).toContain("TODO (1)"); + expect(frame).toContain("IN PROGRESS (1)"); + expect(frame).toContain("IN REVIEW (1)"); + expect(frame).toContain("DONE (1)"); + expect(frame).toContain("OTHER (CUSTOM) (0)"); + expect(frame).toContain("IntakeCard"); + expect(frame).toContain("ReviewCard"); + expect(frame).toContain("DoneCard"); + expect(frame).toContain("WipCard"); + unmount(); + }); + + it("shows a read-only move-disabled hint when the focused column is the 'other' bucket", async () => { + const { lastFrame, stdin, unmount } = renderBoardWithTasks([ + { id: "o1", title: "CustomCard", description: "", column: "staging", columnName: "Staging" }, + ]); + await waitForFrameContains(lastFrame, "CustomCard"); + // Buckets render left→right: todo, in-progress, in-review, other, done. + // Move focus right 3 times to land on the "other" bucket. + stdin.write(""); + await waitForFrameUpdateAfterInput(); + stdin.write(""); + await waitForFrameUpdateAfterInput(); + stdin.write(""); + await waitForFrameUpdateAfterInput(); + await waitForFrameContains(lastFrame, "move disabled here"); + unmount(); + }); +}); + describe("LogsPanel indicator", () => { it("renders the selection arrow on the highlighted log row", async () => { const controller = newController(); diff --git a/packages/cli/src/commands/dashboard-tui/__tests__/bucket-mapping.test.ts b/packages/cli/src/commands/dashboard-tui/__tests__/bucket-mapping.test.ts new file mode 100644 index 0000000000..b6b70d067e --- /dev/null +++ b/packages/cli/src/commands/dashboard-tui/__tests__/bucket-mapping.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from "vitest"; +import type { TraitFlags } from "@fusion/core"; +import { + bucketForTask, + groupTasksByBucket, + otherBucketSecondaryLabel, + TUI_BUCKETS, + OTHER_BUCKET, + isOtherBucket, +} from "../bucket-mapping.js"; +import type { TaskItem } from "../state.js"; + +function task(partial: Partial & { id: string; column: string }): TaskItem { + return { description: "", ...partial }; +} + +describe("bucketForTask (U11 / R18 graceful degradation)", () => { + it("keeps legacy column ids in their own bucket verbatim", () => { + expect(bucketForTask(task({ id: "a", column: "todo" }))).toBe("todo"); + expect(bucketForTask(task({ id: "b", column: "in-progress" }))).toBe("in-progress"); + expect(bucketForTask(task({ id: "c", column: "in-review" }))).toBe("in-review"); + expect(bucketForTask(task({ id: "d", column: "done" }))).toBe("done"); + }); + + it("flag-OFF (no columnFlags) sends a non-legacy column to the read-only 'other' bucket — never dropped", () => { + expect(bucketForTask(task({ id: "x", column: "staging" }))).toBe(OTHER_BUCKET); + expect(bucketForTask(task({ id: "y", column: "qa-review" }))).toBe(OTHER_BUCKET); + }); + + it("maps each built-in trait flag to the correct legacy bucket", () => { + const cases: Array<[TraitFlags, string]> = [ + [{ complete: true }, "done"], + [{ humanReview: true }, "in-review"], + [{ mergeBlocker: true }, "in-review"], + [{ countsTowardWip: true }, "in-progress"], + [{ hold: true }, "todo"], + [{ intake: true }, "todo"], + ]; + for (const [flags, expected] of cases) { + expect( + bucketForTask(task({ id: "t", column: "custom-col", columnFlags: flags })), + ).toBe(expected); + } + }); + + it("applies flag precedence: complete > review > wip > todo-like", () => { + expect( + bucketForTask(task({ id: "t", column: "c", columnFlags: { complete: true, countsTowardWip: false, humanReview: true } })), + ).toBe("done"); + expect( + bucketForTask(task({ id: "t", column: "c", columnFlags: { humanReview: true, countsTowardWip: true } })), + ).toBe("in-review"); + expect( + bucketForTask(task({ id: "t", column: "c", columnFlags: { countsTowardWip: true, hold: true } })), + ).toBe("in-progress"); + }); + + it("flagged column with no mapping-relevant flags lands in 'other'", () => { + expect( + bucketForTask(task({ id: "t", column: "weird", columnFlags: { notify: true, timing: true } })), + ).toBe(OTHER_BUCKET); + }); +}); + +describe("groupTasksByBucket", () => { + it("never drops a card: every task lands in exactly one of the five buckets", () => { + const tasks: TaskItem[] = [ + task({ id: "1", column: "todo" }), + task({ id: "2", column: "in-progress" }), + task({ id: "3", column: "in-review" }), + task({ id: "4", column: "done" }), + task({ id: "5", column: "triage", columnFlags: { intake: true } }), + task({ id: "6", column: "staging" }), // unmapped → other + task({ id: "7", column: "deploying", columnFlags: { notify: true } }), // unmapped → other + task({ id: "8", column: "blocked", columnFlags: { mergeBlocker: true } }), + ]; + const grouped = groupTasksByBucket(tasks); + const total = TUI_BUCKETS.reduce((n, b) => n + grouped[b].length, 0); + expect(total).toBe(tasks.length); + + const allIds = TUI_BUCKETS.flatMap((b) => grouped[b].map((t) => t.id)).sort(); + expect(allIds).toEqual(["1", "2", "3", "4", "5", "6", "7", "8"]); + + expect(grouped.todo.map((t) => t.id)).toEqual(["1", "5"]); // intake → todo + expect(grouped["in-review"].map((t) => t.id)).toEqual(["3", "8"]); // mergeBlocker → in-review + expect(grouped[OTHER_BUCKET].map((t) => t.id)).toEqual(["6", "7"]); + }); +}); + +describe("bucket ordering + labels", () => { + it("orders 'other' between in-review and done", () => { + expect([...TUI_BUCKETS]).toEqual(["todo", "in-progress", "in-review", OTHER_BUCKET, "done"]); + const reviewIdx = TUI_BUCKETS.indexOf("in-review"); + const otherIdx = TUI_BUCKETS.indexOf(OTHER_BUCKET); + const doneIdx = TUI_BUCKETS.indexOf("done"); + expect(otherIdx).toBeGreaterThan(reviewIdx); + expect(otherIdx).toBeLessThan(doneIdx); + }); + + it("isOtherBucket flags only the catch-all", () => { + expect(isOtherBucket(OTHER_BUCKET)).toBe(true); + expect(isOtherBucket("done")).toBe(false); + }); + + it("secondary label uses the real column name, falling back to the id", () => { + expect(otherBucketSecondaryLabel(task({ id: "a", column: "staging", columnName: "Staging area" }))).toBe("Staging area"); + expect(otherBucketSecondaryLabel(task({ id: "b", column: "qa-gate" }))).toBe("qa-gate"); + }); +}); diff --git a/packages/cli/src/commands/dashboard-tui/app.tsx b/packages/cli/src/commands/dashboard-tui/app.tsx index ebb51aceb0..913e77b9a9 100644 --- a/packages/cli/src/commands/dashboard-tui/app.tsx +++ b/packages/cli/src/commands/dashboard-tui/app.tsx @@ -70,6 +70,14 @@ import type { LogEntry } from "./log-ring-buffer.js"; import { FUSION_LOGO_LINES, FUSION_LOGO_LARGE_LINES, FUSION_TAGLINE, FUSION_URL, FUSION_VERSION } from "./logo.js"; import { useProjects, useTasks } from "./hooks/use-projects.js"; import { copyToClipboard } from "./utils.js"; +import { + TUI_BUCKETS, + OTHER_BUCKET, + isOtherBucket, + groupTasksByBucket, + otherBucketSecondaryLabel, + type TuiBucket, +} from "./bucket-mapping.js"; // ── Format helpers ──────────────────────────────────────────────────────────── @@ -1133,17 +1141,23 @@ function MainHeader({ state }: { state: DashboardState }) { // ── Kanban board ────────────────────────────────────────────────────────────── -const KANBAN_COLUMNS = ["todo", "in-progress", "in-review", "done"] as const; -type KanbanColumn = typeof KANBAN_COLUMNS[number]; +// U11 (R18): the TUI renders five buckets — its four legacy kanban columns plus +// a read-only "Other (custom)" catch-all wedged between in-review and done for +// workflow columns it can't express. `KANBAN_COLUMNS` is the bucket render +// order; see ./bucket-mapping.ts for how tasks land in each. +const KANBAN_COLUMNS = TUI_BUCKETS; +type KanbanColumn = TuiBucket; -const COLUMN_COLORS: Record = { +const COLUMN_COLORS: Record = { todo: "yellow", "in-progress": "cyanBright", "in-review": "cyan", + [OTHER_BUCKET]: "magenta", done: "green", }; function columnLabel(col: string): string { + if (col === OTHER_BUCKET) return "Other (custom)"; return col.replace(/-/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); } @@ -1151,10 +1165,14 @@ function TaskCard({ task, selected, width, + secondaryLabel, }: { task: TaskItem; selected: boolean; width: number; + // U11: real column name shown under cards in the read-only "other" bucket so + // the user keeps the true position despite the TUI not modeling that column. + secondaryLabel?: string; }) { const { t } = useTranslation("cli"); const accent = COLUMN_COLORS[task.column] ?? "white"; @@ -1180,6 +1198,9 @@ function TaskCard({ {title} + {secondaryLabel && ( + ↳ {secondaryLabel} + )} ); } @@ -1201,6 +1222,7 @@ function KanbanColumnView({ }) { const accent = COLUMN_COLORS[column]; const headerColor = isFocused ? "whiteBright" : accent; + const isOther = isOtherBucket(column); const cardWidth = Math.max(16, width - 2); const innerHeaderWidth = Math.max(8, width - 2); const label = `${columnLabel(column).toUpperCase()} (${tasks.length})`; @@ -1250,6 +1272,7 @@ function KanbanColumnView({ task={task} selected={isFocused && (windowStart + i) === selectedIndex} width={cardWidth} + secondaryLabel={isOther ? otherBucketSecondaryLabel(task) : undefined} /> ))} {hiddenBelow > 0 && ( @@ -1695,21 +1718,10 @@ function clamp(n: number, min: number, max: number): number { return Math.max(min, Math.min(max, n)); } -function groupTasksByColumn(tasks: TaskItem[]): Record { - const out: Record = { - todo: [], - "in-progress": [], - "in-review": [], - done: [], - }; - for (const task of tasks) { - const col = (KANBAN_COLUMNS as readonly string[]).includes(task.column) - ? (task.column as KanbanColumn) - : "todo"; - out[col].push(task); - } - return out; -} +// U11 (R18): bucket tasks into the five TUI buckets via trait-flag mapping so +// cards in workflow columns the TUI can't express land in a legacy bucket or +// the read-only "other" bucket — never dropped. Delegates to the shared helper. +const groupTasksByColumn = groupTasksByBucket; function BoardView({ state, controller }: { state: DashboardState; controller: DashboardTUI }) { const { t } = useTranslation("cli"); @@ -1734,6 +1746,7 @@ function BoardView({ state, controller }: { state: DashboardState; controller: D todo: 0, "in-progress": 0, "in-review": 0, + [OTHER_BUCKET]: 0, done: 0, }); const [pickerOriginal, setPickerOriginal] = useState(0); @@ -1859,13 +1872,22 @@ function BoardView({ state, controller }: { state: DashboardState; controller: D const narrowColumnIndicator = isNarrow ? ` · ${colIndex + 1}/${KANBAN_COLUMNS.length} ${columnLabel(focusedColumn).toUpperCase()} (${focusedTasks.length})` : ""; + // U11 (R18): the "other" bucket is a read-only view of custom workflow + // columns the TUI can't model — moving cards into/out of it from here would + // mean expressing a column the TUI has no name for, so it's disabled with a + // hint. (The TUI has no in-place move action yet; this keeps the affordance + // honest for when one lands.) + const focusedIsReadOnly = isOtherBucket(focusedColumn); + const boardHint = focusedIsReadOnly + ? `←→ column · ↑↓ task · Enter open · ${t("tui.boardOtherReadOnlyHint", "custom column — move disabled here")}` + : `←→ column · ↑↓ task · Enter open · n new · p project`; const hintText = subView === "picker" ? "↑↓ pick · Enter confirm · Esc cancel" : subView === "detail" ? "Esc back · q quit" : subView === "create" ? "type a task title · Enter create · Esc cancel" - : `←→ column · ↑↓ task · Enter open · n new · p project${narrowColumnIndicator}`; + : `${boardHint}${narrowColumnIndicator}`; const submitNewTask = async () => { const title = newTaskTitle.trim(); diff --git a/packages/cli/src/commands/dashboard-tui/bucket-mapping.ts b/packages/cli/src/commands/dashboard-tui/bucket-mapping.ts new file mode 100644 index 0000000000..7f4f92afa6 --- /dev/null +++ b/packages/cli/src/commands/dashboard-tui/bucket-mapping.ts @@ -0,0 +1,84 @@ +import type { TraitFlags } from "@fusion/core"; +import type { TaskItem } from "./state.js"; + +// ── TUI bucket model (U11, R18) ────────────────────────────────────────────── +// +// The TUI renders a fixed set of buckets. The first four are the legacy kanban +// columns it has always shown. The fifth, "other", is a read-only catch-all for +// cards whose resolved workflow column the TUI cannot express as one of its +// legacy buckets — it sits between "in-review" and "done" so a custom column +// roughly "after review, before done" reads in a sensible place, and each card +// in it keeps its real column name as a secondary label so the user never loses +// the true position. The cardinal rule (R18): a card is NEVER dropped. + +export const LEGACY_KANBAN_COLUMNS = ["todo", "in-progress", "in-review", "done"] as const; +export type LegacyKanbanColumn = (typeof LEGACY_KANBAN_COLUMNS)[number]; + +/** The "other (custom)" catch-all bucket id. Read-only. */ +export const OTHER_BUCKET = "other" as const; + +/** All TUI buckets in render order: legacy columns with "other" wedged between + * in-review and done (U11 ordering requirement). */ +export const TUI_BUCKETS = ["todo", "in-progress", "in-review", OTHER_BUCKET, "done"] as const; +export type TuiBucket = (typeof TUI_BUCKETS)[number]; + +/** True when the bucket is the read-only custom catch-all. */ +export function isOtherBucket(bucket: TuiBucket): bucket is typeof OTHER_BUCKET { + return bucket === OTHER_BUCKET; +} + +/** + * Map a task to its TUI bucket (U11, R18). + * + * 1. If the task sits in one of the legacy kanban column ids, keep it there + * verbatim — flag-OFF and all-legacy boards behave exactly as before. + * 2. Otherwise, if the task carries resolved column flags (flag-ON payload), + * map by trait flags into the nearest legacy bucket: + * - complete → done + * - humanReview || mergeBlocker → in-review + * - countsTowardWip → in-progress + * - hold || intake → todo + * 3. Anything still unmapped lands in the read-only "other" bucket. The card is + * never dropped. + * + * Precedence note: `complete` wins over the others (a terminal column is shown + * as done even if it carried other advisory flags); review beats wip; wip beats + * the todo-like flags. This mirrors the lane priority the dashboard board uses. + */ +export function bucketForTask(task: TaskItem): TuiBucket { + if ((LEGACY_KANBAN_COLUMNS as readonly string[]).includes(task.column)) { + return task.column as LegacyKanbanColumn; + } + + const flags: TraitFlags | undefined = task.columnFlags; + if (flags) { + if (flags.complete) return "done"; + if (flags.humanReview || flags.mergeBlocker) return "in-review"; + if (flags.countsTowardWip) return "in-progress"; + if (flags.hold || flags.intake) return "todo"; + } + + return OTHER_BUCKET; +} + +/** Group tasks into the five TUI buckets, preserving input order within each. + * Every task lands in exactly one bucket; none are dropped. */ +export function groupTasksByBucket(tasks: TaskItem[]): Record { + const out: Record = { + todo: [], + "in-progress": [], + "in-review": [], + [OTHER_BUCKET]: [], + done: [], + }; + for (const task of tasks) { + out[bucketForTask(task)].push(task); + } + return out; +} + +/** Secondary label shown under a card in the "other" bucket: its real column + * name (falling back to the column id) so the user keeps the true position. */ +export function otherBucketSecondaryLabel(task: TaskItem): string { + return task.columnName ?? task.column; +} diff --git a/packages/cli/src/commands/dashboard-tui/state.ts b/packages/cli/src/commands/dashboard-tui/state.ts index 2749048e37..b5bd86f976 100644 --- a/packages/cli/src/commands/dashboard-tui/state.ts +++ b/packages/cli/src/commands/dashboard-tui/state.ts @@ -1,3 +1,4 @@ +import type { TraitFlags } from "@fusion/core"; import type { LogEntry } from "./log-ring-buffer.js"; // ── Public types shared across the whole dashboard-tui module ───────────────── @@ -131,6 +132,15 @@ export interface TaskItem { description: string; column: string; agentState?: string; + /** Display name of the task's resolved workflow column (U11, flag-ON only). + * Used as the secondary label when a card lands in the "Other (custom)" + * bucket so the user keeps the real position. Absent on flag-OFF / legacy + * boards. */ + columnName?: string; + /** Merged trait flags of the task's resolved workflow column (U11, flag-ON + * only). Drives graceful-degradation bucket mapping for non-legacy columns. + * Absent on flag-OFF / legacy boards, where bucketing is by column id. */ + columnFlags?: TraitFlags; } // Slim agent shape for Agents view list diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index cb7fa0248f..8df4be7de2 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -16,6 +16,11 @@ import { GlobalSettingsStore, resolveGlobalDir, DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS, + isWorkflowColumnsEnabled, + resolveColumnFlags, + BUILTIN_CODING_WORKFLOW_IR, + type WorkflowIrColumn, + type TraitFlags, } from "@fusion/core"; import { createServer, @@ -936,6 +941,50 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: return projectStore; } + // ── U11: resolve per-task workflow column flags for the TUI (flag-ON only) ── + // + // The CLI TUI degrades gracefully (R18): cards in workflow columns it can't + // express must map by trait flags into its buckets or a read-only "other" + // bucket, never silently disappear. The TUI is flag-blind, so when + // `workflowColumns` is ON we enrich each slim task with its resolved column's + // display name + merged trait flags. Self-contained: derives everything from + // already-exposed store methods (workflow selection + definition) + the core + // `resolveColumnFlags` export — no dependency on concurrent U9 server work. + // Flag-OFF: returns undefineds and the TUI renders exactly as before. + type ResolvedColumnInfo = { columnName?: string; columnFlags?: TraitFlags }; + async function resolveTaskColumnInfo( + projectStore: TaskStore, + flagOn: boolean, + workflowIrCache: Map, + task: { id: string; column: string }, + ): Promise { + if (!flagOn) return {}; + try { + const selection = projectStore.getTaskWorkflowSelection(task.id); + const workflowId = selection?.workflowId; + let columns = workflowIrCache.get(workflowId); + if (columns === undefined) { + // Resolve the governing workflow IR. No selection → built-in default + // (KTD-1), matching the store's own resolution order. + const def = workflowId + ? await projectStore.getWorkflowDefinition(workflowId) + : undefined; + const ir = def?.ir ?? BUILTIN_CODING_WORKFLOW_IR; + columns = ir.version === "v2" ? ir.columns : []; + workflowIrCache.set(workflowId, columns); + } + if (!columns) return {}; + // `task.column` is the IR column id (the store stores the column id). + const column = columns.find((c) => c.id === task.column); + if (!column) return {}; + return { columnName: column.name, columnFlags: resolveColumnFlags(column) }; + } catch { + // Degrade silently: an unresolvable workflow must never drop a card, just + // fall back to legacy column-id bucketing in the TUI. + return {}; + } + } + /** * Debounced refresh of TUI stats - batches rapid task updates. * If the BoardView has a scoped project path set on the controller, @@ -2378,13 +2427,28 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: listTasks: async (projectPath: string) => { const projectStore = await getProjectStore(projectPath); const tasks = await projectStore.listTasks({ slim: true, includeArchived: false }); - return tasks.map((t) => ({ - id: t.id, - title: t.title, - description: t.description ?? "", - column: t.column, - agentState: (t as { agentState?: string }).agentState, - })); + // U11 (R18): when the workflow-columns flag is ON, enrich each task + // with its resolved column display name + trait flags so the + // flag-blind TUI can map non-legacy columns into its buckets (or the + // read-only "other" bucket) instead of silently dropping them. The + // IR cache keeps this O(workflows) rather than O(tasks) DB reads. + const settings = await projectStore.getSettings(); + const flagOn = isWorkflowColumnsEnabled(settings); + const workflowIrCache = new Map(); + return Promise.all( + tasks.map(async (t) => { + const info = await resolveTaskColumnInfo(projectStore, flagOn, workflowIrCache, t); + return { + id: t.id, + title: t.title, + description: t.description ?? "", + column: t.column, + agentState: (t as { agentState?: string }).agentState, + ...(info.columnName !== undefined ? { columnName: info.columnName } : {}), + ...(info.columnFlags !== undefined ? { columnFlags: info.columnFlags } : {}), + }; + }), + ); }, createTask: async (projectPath: string, input: { title: string; description?: string }) => { const projectStore = await getProjectStore(projectPath); diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 79db647e37..5528028e59 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -1,4 +1,4 @@ -import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core"; +import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core"; import { aiMergeTask } from "@fusion/engine"; import { createInterface } from "node:readline/promises"; import type { PlanningQuestion, PlanningSummary } from "@fusion/core"; @@ -19,6 +19,12 @@ import { findNodeByNameOrId } from "./node.js"; const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"]; +/** #1403: display a column's label, falling back to the raw id for + * workflow-defined custom columns that have no legacy label. */ +function columnLabel(column: ColumnId): string { + return (COLUMN_LABELS as Record)[column] ?? column; +} + // Register GitHub tracking hook so CLI task creation paths (add, duplicate, // refine, import, delegate) trigger tracking issue creation. try { @@ -806,7 +812,7 @@ export async function runTaskShow(id: string, projectName?: string) { console.log(); console.log(` ${task.id}: ${task.title || task.description}`); - console.log(` Column: ${COLUMN_LABELS[task.column]}${task.size ? ` · Size: ${task.size}` : ""}${task.reviewLevel !== undefined ? ` · Review: ${task.reviewLevel}` : ""}`); + console.log(` Column: ${columnLabel(task.column)}${task.size ? ` · Size: ${task.size}` : ""}${task.reviewLevel !== undefined ? ` · Review: ${task.reviewLevel}` : ""}`); if (task.dependencies.length) { console.log(` Dependencies: ${task.dependencies.join(", ")}`); } @@ -959,7 +965,7 @@ export async function runTaskMove(id: string, column: string, projectName?: stri const task = await store.moveTask(id, column as Column); console.log(); - console.log(` ✓ Moved ${task.id} → ${COLUMN_LABELS[task.column as Column]}`); + console.log(` ✓ Moved ${task.id} → ${columnLabel(task.column)}`); console.log(); } @@ -1010,7 +1016,7 @@ export async function runTaskArchive(id: string, projectName?: string) { const task = await store.archiveTask(id); console.log(); - console.log(` ✓ Archived ${task.id} → ${COLUMN_LABELS[task.column]}`); + console.log(` ✓ Archived ${task.id} → ${columnLabel(task.column)}`); console.log(); } @@ -1019,7 +1025,7 @@ export async function runTaskUnarchive(id: string, projectName?: string) { const task = await store.unarchiveTask(id); console.log(); - console.log(` ✓ Unarchived ${task.id} → ${COLUMN_LABELS[task.column]}`); + console.log(` ✓ Unarchived ${task.id} → ${columnLabel(task.column)}`); console.log(); } diff --git a/packages/cli/src/extension.ts b/packages/cli/src/extension.ts index 7383485347..69c3111791 100644 --- a/packages/cli/src/extension.ts +++ b/packages/cli/src/extension.ts @@ -9,6 +9,7 @@ import { buildManualRetryResetPatch, validateNodeOverrideChange, type Task, + type ColumnId, type InsightCategory, type TaskPriority, type InsightStatus, @@ -57,6 +58,12 @@ import { spawn, type ChildProcess } from "node:child_process"; // ── Helpers ──────────────────────────────────────────────────────── +/** #1403: display a column's label, falling back to the raw id for + * workflow-defined custom columns that have no legacy label. */ +function columnLabel(column: ColumnId): string { + return (COLUMN_LABELS as Record)[column] ?? column; +} + const MIME_TYPES: Record = { ".png": "image/png", ".jpg": "image/jpeg", @@ -782,7 +789,7 @@ export default function kbExtension(pi: ExtensionAPI) { const lines: string[] = []; lines.push(`${task.id}: ${task.title || task.description}`); lines.push( - `Column: ${COLUMN_LABELS[task.column]}` + + `Column: ${columnLabel(task.column)}` + (task.size ? ` · Size: ${task.size}` : "") + (task.reviewLevel !== undefined ? ` · Review: ${task.reviewLevel}` : ""), ); @@ -1145,7 +1152,7 @@ export default function kbExtension(pi: ExtensionAPI) { const task = await store.archiveTask(params.id); return { - content: [{ type: "text", text: `Archived ${task.id} → ${COLUMN_LABELS[task.column]}` }], + content: [{ type: "text", text: `Archived ${task.id} → ${columnLabel(task.column)}` }], details: { taskId: task.id, column: task.column }, }; }, @@ -1173,7 +1180,7 @@ export default function kbExtension(pi: ExtensionAPI) { const task = await store.unarchiveTask(params.id); return { - content: [{ type: "text", text: `Unarchived ${task.id} → ${COLUMN_LABELS[task.column]}` }], + content: [{ type: "text", text: `Unarchived ${task.id} → ${columnLabel(task.column)}` }], details: { taskId: task.id, column: task.column }, }; }, diff --git a/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts b/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts index 110f4905c8..3edf7b5f8d 100644 --- a/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts +++ b/packages/core/src/__tests__/builtin-coding-workflow-ir.test.ts @@ -1,12 +1,18 @@ import { describe, expect, it } from "vitest"; -import { BUILTIN_CODING_WORKFLOW_IR, parseWorkflowIr, serializeWorkflowIr } from "../index.js"; +import { + BUILTIN_CODING_WORKFLOW_IR, + DEFAULT_WORKFLOW_COLUMN_IDS, + parseWorkflowIr, + serializeWorkflowIr, +} from "../index.js"; describe("builtin coding workflow ir", () => { it("parses and round-trips", () => { const parsed = parseWorkflowIr(BUILTIN_CODING_WORKFLOW_IR); const reparsed = parseWorkflowIr(serializeWorkflowIr(parsed)); expect(reparsed).toEqual(parsed); - expect(parsed.version).toBe("v1"); + // The built-in default workflow is now a v2 graph (columns + placement). + expect(parsed.version).toBe("v2"); }); it("contains exactly one start and one end node", () => { @@ -22,4 +28,34 @@ describe("builtin coding workflow ir", () => { expect(seams).toEqual(expect.arrayContaining(["execute", "review", "merge"])); expect(seams).not.toContain("triage"); }); + + it("defines the six legacy columns in legacy order (KTD-1)", () => { + expect(BUILTIN_CODING_WORKFLOW_IR.version).toBe("v2"); + if (BUILTIN_CODING_WORKFLOW_IR.version !== "v2") throw new Error("expected v2"); + const ids = BUILTIN_CODING_WORKFLOW_IR.columns.map((c) => c.id); + expect(ids).toEqual([...DEFAULT_WORKFLOW_COLUMN_IDS]); + expect(ids).toEqual(["triage", "todo", "in-progress", "in-review", "done", "archived"]); + }); + + it("maps default-workflow traits to columns verbatim (R12)", () => { + if (BUILTIN_CODING_WORKFLOW_IR.version !== "v2") throw new Error("expected v2"); + const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.columns.map((c) => [c.id, c])); + const traitsFor = (id: string) => byId.get(id)!.traits.map((t) => t.trait); + expect(traitsFor("triage")).toEqual(["intake"]); + expect(traitsFor("todo")).toEqual(["hold", "reset-on-entry"]); + expect(traitsFor("in-progress")).toEqual(["wip", "abort-on-exit", "timing"]); + expect(traitsFor("in-review")).toEqual(["merge-blocker", "stall-detection", "merge"]); + expect(traitsFor("done")).toEqual(["complete"]); + expect(traitsFor("archived")).toEqual(["archived"]); + // todo's hold is capacity-released (legacy "pull from todo when a slot frees"). + const hold = byId.get("todo")!.traits.find((t) => t.trait === "hold"); + expect(hold?.config?.release).toBe("capacity"); + }); + + it("places seam nodes in their columns", () => { + const byId = new Map(BUILTIN_CODING_WORKFLOW_IR.nodes.map((n) => [n.id, n])); + expect(byId.get("execute")?.column).toBe("in-progress"); + expect(byId.get("review")?.column).toBe("in-review"); + expect(byId.get("merge")?.column).toBe("in-review"); + }); }); diff --git a/packages/core/src/__tests__/builtin-traits.test.ts b/packages/core/src/__tests__/builtin-traits.test.ts new file mode 100644 index 0000000000..10f0e76527 --- /dev/null +++ b/packages/core/src/__tests__/builtin-traits.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { + BUILTIN_TRAIT_DEFINITIONS, + BUILTIN_TRAIT_IDS, + registerBuiltinTraits, +} from "../builtin-traits.js"; +import { TraitRegistry } from "../trait-registry.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; +import type { WorkflowIrV2 } from "../workflow-ir-types.js"; + +function freshRegistry(): TraitRegistry { + const r = new TraitRegistry(); + registerBuiltinTraits(r); + return r; +} + +describe("built-in traits", () => { + it("ships exactly the 14 vocabulary traits", () => { + expect(BUILTIN_TRAIT_IDS).toHaveLength(14); + expect(BUILTIN_TRAIT_DEFINITIONS.map((d) => d.id).sort()).toEqual([...BUILTIN_TRAIT_IDS].sort()); + }); + + it("all built-ins are flagged builtin: true and register cleanly", () => { + const r = freshRegistry(); + for (const id of BUILTIN_TRAIT_IDS) { + const def = r.getTrait(id); + expect(def, `missing built-in trait ${id}`).toBeDefined(); + expect(def?.builtin).toBe(true); + } + expect(r.listTraits()).toHaveLength(14); + }); + + it("only built-in traits carry restricted capabilities", () => { + const r = freshRegistry(); + expect(r.getTrait("complete")?.flags.complete).toBe(true); + expect(r.getTrait("archived")?.flags.archived).toBe(true); + // Sync guards live only on built-ins (merge-blocker, human-review). + expect(r.getTrait("merge-blocker")?.hooks?.guard).toBe(true); + expect(r.getTrait("human-review")?.hooks?.guard).toBe(true); + // The plugin-facing gate trait uses the async gate hook, not a sync guard. + expect(r.getTrait("gate")?.hooks?.guard).toBeUndefined(); + expect(r.getTrait("gate")?.hooks?.gate).toBe(true); + }); + + it("merge trait config schema matches the U7 policy fields", () => { + const r = freshRegistry(); + const fields = r.getTrait("merge")?.configSchema?.fields ?? []; + const keys = fields.map((f) => f.key).sort(); + // U7 tightened the schema: strategy enum, fileScope enum (incl. custom), + // custom-rules array, squash posture, conflictStrategy. + expect(keys).toEqual(["conflictStrategy", "fileScope", "rules", "squash", "strategy"]); + expect(r.getTrait("merge")?.flags.mergeOrchestration).toBe(true); + + const strategy = fields.find((f) => f.key === "strategy"); + expect(strategy?.enumValues).toEqual(["always-squash", "auto", "always-rebase", "pr-only"]); + const fileScope = fields.find((f) => f.key === "fileScope"); + expect(fileScope?.enumValues).toEqual(["strict", "warn", "off", "custom"]); + }); + + it("hold trait's release config matches WorkflowHoldRelease kinds", () => { + const r = freshRegistry(); + const release = r.getTrait("hold")?.configSchema?.fields.find((f) => f.key === "release"); + expect(release?.enumValues).toEqual([ + "manual", + "timer", + "capacity", + "dependency", + "external-event", + ]); + }); + + it("registering built-ins twice into the same registry is idempotent", () => { + const r = freshRegistry(); + expect(() => registerBuiltinTraits(r)).not.toThrow(); + expect(r.listTraits()).toHaveLength(14); + }); +}); + +describe("default workflow columns validate cleanly", () => { + it("BUILTIN_CODING_WORKFLOW_IR columns pass the composition validator", () => { + const r = freshRegistry(); + const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2; + const violations = r.validateColumnTraits(ir.columns, "save"); + expect(violations).toEqual([]); + }); + + it("the default workflow has exactly one intake column (triage)", () => { + const r = freshRegistry(); + const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2; + const intakeCols = ir.columns.filter((c) => r.resolveColumnFlags(c).intake); + expect(intakeCols.map((c) => c.id)).toEqual(["triage"]); + }); + + it("the default workflow's done column resolves the complete flag", () => { + const r = freshRegistry(); + const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2; + const done = ir.columns.find((c) => c.id === "done")!; + expect(r.resolveColumnFlags(done).complete).toBe(true); + }); + + it("the default workflow's in-progress column resolves wip+abort+timing flags", () => { + const r = freshRegistry(); + const ir = BUILTIN_CODING_WORKFLOW_IR as WorkflowIrV2; + const inProgress = ir.columns.find((c) => c.id === "in-progress")!; + const flags = r.resolveColumnFlags(inProgress); + expect(flags.countsTowardWip).toBe(true); + expect(flags.abortOnExit).toBe(true); + expect(flags.timing).toBe(true); + }); +}); diff --git a/packages/core/src/__tests__/builtin-workflows.test.ts b/packages/core/src/__tests__/builtin-workflows.test.ts index b6e54b247e..e605ee50f0 100644 --- a/packages/core/src/__tests__/builtin-workflows.test.ts +++ b/packages/core/src/__tests__/builtin-workflows.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { BUILTIN_WORKFLOWS, getBuiltinWorkflow, isBuiltinWorkflowId } from "../builtin-workflows.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; import { compileWorkflowToSteps } from "../workflow-compiler.js"; -import { parseWorkflowIr } from "../workflow-ir.js"; +import { DEFAULT_WORKFLOW_COLUMN_IDS, parseWorkflowIr } from "../workflow-ir.js"; import { createTaskStoreTestHarness } from "./store-test-helpers.js"; describe("built-in workflows", () => { @@ -15,6 +16,14 @@ describe("built-in workflows", () => { } }); + it("default workflow column ids equal the legacy enum values, in legacy order (KTD-1)", () => { + expect(BUILTIN_CODING_WORKFLOW_IR.version).toBe("v2"); + if (BUILTIN_CODING_WORKFLOW_IR.version !== "v2") throw new Error("expected v2"); + expect(BUILTIN_CODING_WORKFLOW_IR.columns.map((c) => c.id)).toEqual([ + ...DEFAULT_WORKFLOW_COLUMN_IDS, + ]); + }); + it("includes a coding and a compound-engineering workflow", () => { expect(getBuiltinWorkflow("builtin:coding")).toBeDefined(); expect(getBuiltinWorkflow("builtin:compound-engineering")).toBeDefined(); diff --git a/packages/core/src/__tests__/db-migrate.test.ts b/packages/core/src/__tests__/db-migrate.test.ts index d4fec7f76a..8a989400d8 100644 --- a/packages/core/src/__tests__/db-migrate.test.ts +++ b/packages/core/src/__tests__/db-migrate.test.ts @@ -715,7 +715,7 @@ describe("schema migration", () => { const row = db.prepare("SELECT deletedAt FROM tasks WHERE id = 'FN-legacy'").get() as { deletedAt: string | null }; expect(row.deletedAt).toBeNull(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); db.close(); }); @@ -748,7 +748,7 @@ describe("schema migration", () => { { id: "WS-001", mode: "prompt", gateMode: "advisory" }, { id: "WS-002", mode: "script", gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); db.close(); }); @@ -798,7 +798,7 @@ describe("schema migration", () => { reviewerContextRetryCount: 0, reviewerFallbackRetryCount: 0, }); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); db.close(); }); @@ -827,7 +827,7 @@ describe("schema migration", () => { const columns = db.prepare("PRAGMA table_info(milestones)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("acceptanceCriteria"); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); db.close(); }); @@ -868,7 +868,7 @@ describe("schema migration", () => { const missionColumns = db.prepare("PRAGMA table_info(missions)").all() as Array<{ name: string }>; expect(missionColumns.map((column) => column.name)).toContain("autoMerge"); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); db.close(); }); @@ -902,7 +902,7 @@ describe("schema migration", () => { { id: "WS-002", mode: "script", enabled: 1, gateMode: "advisory" }, { id: "WS-003", mode: "prompt", enabled: 0, gateMode: "advisory" }, ]); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); db.close(); }); @@ -939,7 +939,7 @@ describe("schema migration", () => { const indexes = db.prepare("PRAGMA index_list(mission_goals)").all() as Array<{ name: string }>; expect(indexes.some((index) => index.name === "idxMissionGoalsGoalId")).toBe(true); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); db.close(); }); diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index 4ad168b50b..7ef8601b31 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -334,7 +334,7 @@ describe("Database", () => { }); it("seeds schema version", () => { - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); }); it("includes tokenUsageCacheWriteTokens on freshly initialized tasks table", () => { @@ -393,7 +393,7 @@ describe("Database", () => { it("is idempotent - calling init() twice does not fail", () => { expect(() => db.init()).not.toThrow(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); }); it("does not overwrite existing config on re-init", () => { // Update the config @@ -1463,7 +1463,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 (includes v1→v2 through v26→v29) - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -1488,11 +1488,11 @@ describe("schema migrations", () => { const db = new Database(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); // Re-init should not fail db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); db.close(); }); @@ -1527,7 +1527,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("priority"); @@ -1568,7 +1568,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1640,7 +1640,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const colNames = cols.map((col) => col.name); @@ -1880,7 +1880,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>; expect(cols.map((col) => col.name)).toContain("attachments"); @@ -1954,7 +1954,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "agentRatings" }]); @@ -1978,7 +1978,7 @@ describe("schema migrations", () => { db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>; expect(tables).toEqual([{ name: "mission_events" }]); @@ -2082,7 +2082,7 @@ describe("schema migrations", () => { db.init(); // Verify version bumped to 29 - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); // Verify new columns exist and existing data is intact const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; @@ -2301,7 +2301,7 @@ describe("schema migrations", () => { localDb.init(); - expect(localDb.getSchemaVersion()).toBe(105); + expect(localDb.getSchemaVersion()).toBe(107); const columns = localDb.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; expect(columns.map((column) => column.name)).toContain("tokenUsageCacheWriteTokens"); @@ -2612,7 +2612,7 @@ describe("createDatabase factory", () => { const db = createDatabase(fusionDir); db.init(); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); expect(db.getLastModified()).toBeGreaterThan(0); db.close(); @@ -2766,7 +2766,7 @@ describe("migration v77 task token budget columns", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(105); + expect(migrated.getSchemaVersion()).toBe(107); const rows = migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const names = new Set(rows.map((row) => row.name)); expect(names.has("tokenBudgetSoftAlertedAt")).toBe(true); @@ -2790,6 +2790,120 @@ describe("migration v77 task token budget columns", () => { }); }); +describe("migration v106 adds tasks.transitionPending (FN-1417)", () => { + it("includes the transitionPending column on fresh init", () => { + const temp = makeTmpDir(); + const fusion = join(temp, ".fusion"); + const fresh = new Database(fusion); + try { + fresh.init(); + expect(fresh.getSchemaVersion()).toBe(107); + const names = new Set( + (fresh.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), + ); + expect(names.has("transitionPending")).toBe(true); + } finally { + try { fresh.close(); } catch { /* already closed */ } + removeTrackedTmpDirSync(temp); + } + }); + + it("from v105 → init() adds transitionPending; existing rows keep it NULL and survive", () => { + const temp = makeTmpDir(); + const fusion = join(temp, ".fusion"); + const localDb = new Database(fusion); + let migrated: Database | undefined; + try { + localDb.init(); + localDb + .prepare('INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)') + .run("FN-V105", "pre-106 row", "todo", "2026-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z"); + // Roll back to v105 and drop the column the v106 migration adds. + localDb.exec("ALTER TABLE tasks DROP COLUMN transitionPending"); + localDb.prepare("UPDATE __meta SET value = '105' WHERE key = 'schemaVersion'").run(); + localDb.close(); + + migrated = new Database(fusion); + migrated.init(); + expect(migrated.getSchemaVersion()).toBe(107); + const names = new Set( + (migrated.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>).map((r) => r.name), + ); + expect(names.has("transitionPending")).toBe(true); + const row = migrated + .prepare("SELECT id, transitionPending FROM tasks WHERE id = ?") + .get("FN-V105") as { id: string; transitionPending: string | null } | undefined; + expect(row?.id).toBe("FN-V105"); + // Additive, nullable, no backfill — the pre-existing row stays NULL. + expect(row?.transitionPending).toBeNull(); + } finally { + try { migrated?.close(); } catch { /* already closed */ } + try { localDb.close(); } catch { /* already closed */ } + removeTrackedTmpDirSync(temp); + } + }); +}); + +describe("migration v107 adds workflow_run_branches + index (FN-1417)", () => { + it("creates the workflow_run_branches table and its index on fresh init", () => { + const temp = makeTmpDir(); + const fusion = join(temp, ".fusion"); + const fresh = new Database(fusion); + try { + fresh.init(); + expect(fresh.getSchemaVersion()).toBe(107); + const table = fresh + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") + .get() as { name: string } | undefined; + expect(table?.name).toBe("workflow_run_branches"); + const index = fresh + .prepare("SELECT name FROM sqlite_master WHERE type='index' AND name = 'idx_workflow_run_branches_task_run'") + .get() as { name: string } | undefined; + expect(index?.name).toBe("idx_workflow_run_branches_task_run"); + } finally { + try { fresh.close(); } catch { /* already closed */ } + removeTrackedTmpDirSync(temp); + } + }); + + it("from v106 → init() adds workflow_run_branches + index without dropping existing rows", () => { + const temp = makeTmpDir(); + const fusion = join(temp, ".fusion"); + const localDb = new Database(fusion); + let migrated: Database | undefined; + try { + localDb.init(); + localDb + .prepare('INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)') + .run("FN-V106", "pre-107 row", "todo", "2026-01-01T00:00:00.000Z", "2026-01-01T00:00:00.000Z"); + // Roll back to v106 and drop the table the v107 migration creates. (v106 + // schema already has tasks.transitionPending, so we leave it in place.) + localDb.exec("DROP INDEX IF EXISTS idx_workflow_run_branches_task_run"); + localDb.exec("DROP TABLE IF EXISTS workflow_run_branches"); + localDb.prepare("UPDATE __meta SET value = '106' WHERE key = 'schemaVersion'").run(); + localDb.close(); + + migrated = new Database(fusion); + migrated.init(); + expect(migrated.getSchemaVersion()).toBe(107); + const table = migrated + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'workflow_run_branches'") + .get() as { name: string } | undefined; + expect(table?.name).toBe("workflow_run_branches"); + const index = migrated + .prepare("SELECT name FROM sqlite_master WHERE type='index' AND name = 'idx_workflow_run_branches_task_run'") + .get() as { name: string } | undefined; + expect(index?.name).toBe("idx_workflow_run_branches_task_run"); + const task = migrated.prepare("SELECT id FROM tasks WHERE id = ?").get("FN-V106") as { id: string } | undefined; + expect(task?.id).toBe("FN-V106"); + } finally { + try { migrated?.close(); } catch { /* already closed */ } + try { localDb.close(); } catch { /* already closed */ } + removeTrackedTmpDirSync(temp); + } + }); +}); + describe("migration v67 drops orphan project auth tables", () => { it("drops project_auth_* tables left over from the removed pluggable auth feature", () => { const temp = makeTmpDir(); @@ -2812,7 +2926,7 @@ describe("migration v67 drops orphan project auth tables", () => { migrated = new Database(fusion); migrated.init(); - expect(migrated.getSchemaVersion()).toBe(105); + expect(migrated.getSchemaVersion()).toBe(107); const tables = migrated .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; @@ -2839,7 +2953,7 @@ describe("migration v67 drops orphan project auth tables", () => { try { fresh.init(); - expect(fresh.getSchemaVersion()).toBe(105); + expect(fresh.getSchemaVersion()).toBe(107); const tables = fresh .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'") .all() as Array<{ name: string }>; diff --git a/packages/core/src/__tests__/default-workflow-hooks.test.ts b/packages/core/src/__tests__/default-workflow-hooks.test.ts new file mode 100644 index 0000000000..c650a89a78 --- /dev/null +++ b/packages/core/src/__tests__/default-workflow-hooks.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment node +// +// U4: the default-workflow side effects are resolved THROUGH the trait registry +// (the DI seam, KTD-2/U2). This pins: +// - registerDefaultWorkflowHooks() wires the impls so resolution finds them +// (no missing-hook-impl warning on the happy path); +// - a missing registration degrades to a no-op + audit warning (not a crash); +// - applyDefaultWorkflowMoveEffects mutates the task per the legacy contract. + +import { describe, it, expect, beforeEach } from "vitest"; +import { + __resetTraitRegistryForTests, + getTraitRegistry, +} from "../trait-registry.js"; +import { registerBuiltinTraits } from "../builtin-traits.js"; +import { + __resetDefaultWorkflowHooksForTests, + applyDefaultWorkflowMoveEffects, + registerDefaultWorkflowHooks, + type DefaultWorkflowMoveContext, +} from "../default-workflow-hooks.js"; +import type { Task } from "../types.js"; + +function makeCtx(overrides: Partial = {}): DefaultWorkflowMoveContext { + const task = { + id: "FN-1", + column: "in-progress", + columnMovedAt: new Date().toISOString(), + steps: [], + dependencies: [], + } as unknown as Task; + return { + task, + fromColumn: "todo", + toColumn: "in-progress", + moveSource: "user", + bypassGuards: false, + movedAt: new Date().toISOString(), + settings: undefined, + options: {}, + resetSteps: () => {}, + ...overrides, + }; +} + +describe("default-workflow-hooks registry wiring", () => { + beforeEach(() => { + __resetTraitRegistryForTests(); + __resetDefaultWorkflowHooksForTests(); + registerBuiltinTraits(); + }); + + it("resolves all default-workflow hooks without a missing-impl warning once registered", () => { + registerDefaultWorkflowHooks(); + const ctx = makeCtx({ fromColumn: "todo", toColumn: "in-progress" }); + const { warnings } = applyDefaultWorkflowMoveEffects(ctx); + expect(warnings).toHaveLength(0); + // timing.onEnter stamped cumulativeActiveMs on entry to in-progress. + expect(ctx.task.cumulativeActiveMs).toBe(0); + }); + + it("degrades to a no-op + audit warning when a hook impl is not registered", () => { + // Built-in DEFINITIONS are registered (so the trait declares the hook) but + // we deliberately do NOT call registerDefaultWorkflowHooks() — no impls. + const registry = getTraitRegistry(); + // sanity: the trait declares the hook descriptor + expect(registry.getTrait("timing")?.hooks?.onEnter).toBe(true); + const ctx = makeCtx({ fromColumn: "todo", toColumn: "in-progress" }); + const { warnings } = applyDefaultWorkflowMoveEffects(ctx); + // Every declared hook with no impl yields a degraded-no-op warning. + expect(warnings.length).toBeGreaterThan(0); + expect(warnings.every((w) => w.kind === "missing-hook-impl")).toBe(true); + // No crash; task unmutated by the (no-op) hooks. + expect(ctx.task.cumulativeActiveMs).toBeUndefined(); + }); + + it("applies userPaused only for user-source reopen to todo", () => { + registerDefaultWorkflowHooks(); + const userCtx = makeCtx({ fromColumn: "in-progress", toColumn: "todo", moveSource: "user" }); + applyDefaultWorkflowMoveEffects(userCtx); + expect(userCtx.task.userPaused).toBe(true); + + const engineCtx = makeCtx({ fromColumn: "in-progress", toColumn: "todo", moveSource: "engine" }); + applyDefaultWorkflowMoveEffects(engineCtx); + expect(engineCtx.task.userPaused).toBeUndefined(); + }); +}); diff --git a/packages/core/src/__tests__/goals-schema.test.ts b/packages/core/src/__tests__/goals-schema.test.ts index ad15bac419..1e449be32b 100644 --- a/packages/core/src/__tests__/goals-schema.test.ts +++ b/packages/core/src/__tests__/goals-schema.test.ts @@ -91,6 +91,6 @@ describe("goals schema", () => { }); it("reports schema version 101", () => { - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); }); }); diff --git a/packages/core/src/__tests__/insight-store.test.ts b/packages/core/src/__tests__/insight-store.test.ts index 192f454f17..447411ad52 100644 --- a/packages/core/src/__tests__/insight-store.test.ts +++ b/packages/core/src/__tests__/insight-store.test.ts @@ -1000,7 +1000,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh database at v33 (runs all migrations up to 33) const db1 = createDatabase(legacyDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(105); + expect(db1.getSchemaVersion()).toBe(107); db1.close(); // Step 2: Manually downgrade to version 32 and drop insight tables @@ -1035,7 +1035,7 @@ describe("Migration: pre-33 DB upgrade", () => { expect(tableNamesBefore).not.toContain("project_insight_runs"); // Now run init — this triggers the v32→v33 migration db3.init(); - expect(db3.getSchemaVersion()).toBe(105); + expect(db3.getSchemaVersion()).toBe(107); // Step 4: Verify insight tables exist after migration const tablesAfter = db3.prepare( @@ -1066,12 +1066,12 @@ describe("Migration: pre-33 DB upgrade", () => { try { const db1 = createDatabase(testDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(105); + expect(db1.getSchemaVersion()).toBe(107); db1.close(); const db2 = createDatabase(testDir); expect(() => db2.init()).not.toThrow(); - expect(db2.getSchemaVersion()).toBe(105); + expect(db2.getSchemaVersion()).toBe(107); db2.close(); } finally { rmSync(testDir, { recursive: true, force: true }); @@ -1085,7 +1085,7 @@ describe("Migration: pre-33 DB upgrade", () => { // Step 1: Create a fresh DB and run migrations const db1 = createDatabase(compatDir); db1.init(); - expect(db1.getSchemaVersion()).toBe(105); + expect(db1.getSchemaVersion()).toBe(107); // Step 2: Strip lifecycle and cancelledAt columns by recreating the // table without them. This simulates a DB that was created before the diff --git a/packages/core/src/__tests__/merge-request-record.test.ts b/packages/core/src/__tests__/merge-request-record.test.ts index ca23e17058..fd508a69be 100644 --- a/packages/core/src/__tests__/merge-request-record.test.ts +++ b/packages/core/src/__tests__/merge-request-record.test.ts @@ -38,7 +38,7 @@ describe("TaskStore merge request record + completion handoff marker", () => { .all() as Array<{ name: string }>; expect(tableRows).toEqual([{ name: "completion_handoff_markers" }, { name: "merge_requests" }]); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); }); it("upserts merge request records", async () => { diff --git a/packages/core/src/__tests__/migration-workflow-columns.test.ts b/packages/core/src/__tests__/migration-workflow-columns.test.ts new file mode 100644 index 0000000000..84669edf16 --- /dev/null +++ b/packages/core/src/__tests__/migration-workflow-columns.test.ts @@ -0,0 +1,439 @@ +// @vitest-environment node +// +// U12: workflow-columns migration / integrity / graduation + rollback safety. +// +// Proves the U12 plan scenarios: +// - Migration rewrites ZERO task rows (KTD-1): fresh DB and an aged fixture DB +// (tasks in every legacy column, some with workflow selections) resolve every +// task to a valid (workflow, column) pair. +// - The integrity pass re-homes a task whose stored column is invalid in its +// resolved workflow, and is IDEMPOTENT (a second run is a no-op). +// - done/archived (terminal) cards are left untouched by the integrity pass. +// - Flag OFF after running flag-ON: legacy board + engine behavior intact. +// - Deliberate parity-drift injection (altered default-workflow adjacency) is +// CAUGHT by the graduation report's transition-parity gate. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; +import type { WorkflowIr } from "../workflow-ir-types.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; +import { workflowHasColumn } from "../workflow-transitions.js"; +import { + checkTransitionParity, + computeWorkflowColumnsGraduationReport, + countDualAcceptDisagreements, +} from "../workflow-parity.js"; +import type { Column } from "../types.js"; + +function customIr(name: string, cols: string[], entryId: string): WorkflowIr { + return { + version: "v2", + name, + columns: cols.map((id) => ({ + id, + name: id, + traits: id === entryId ? [{ trait: "intake" }] : [], + })), + nodes: [ + { id: "start", kind: "start", column: entryId }, + { id: "work", kind: "prompt", column: cols[1] ?? entryId, config: { prompt: "do" } }, + { id: "end", kind: "end", column: cols[cols.length - 1] }, + ], + edges: [ + { from: "start", to: "work", condition: "success" }, + { from: "work", to: "end", condition: "success" }, + ], + }; +} + +describe("U12 migration — zero task-row rewrites (KTD-1)", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + async function seedInColumn(column: Column): Promise { + const task = await store.createTask({ description: `seed-${column}` }); + const u = { moveSource: "user" as const }; + if (column === "triage") return task.id; + await store.moveTask(task.id, "todo", u); + if (column === "todo") return task.id; + await store.moveTask(task.id, "in-progress", u); + if (column === "in-progress") return task.id; + await store.moveTask(task.id, "in-review", { ...u, allowDirectInReviewMove: true }); + if (column === "in-review") return task.id; + await store.moveTask(task.id, "done", { moveSource: "engine", skipMergeBlocker: true }); + if (column === "done") return task.id; + await store.moveTask(task.id, "archived", u); + return task.id; + } + + it("fresh DB: a default-workflow task resolves to a valid (workflow, column) pair", async () => { + const id = await seedInColumn("todo"); + const task = await store.getTask(id); + expect(workflowHasColumn(BUILTIN_CODING_WORKFLOW_IR, task.column)).toBe(true); + }); + + it("aged fixture: tasks in every legacy column all resolve to a valid column; integrity pass touches none", async () => { + const ids: string[] = []; + for (const col of ["triage", "todo", "in-progress", "in-review", "done", "archived"] as Column[]) { + ids.push(await seedInColumn(col)); + } + // A task with a custom-workflow selection whose column IS valid in it. + const wf = await store.createWorkflowDefinition({ + name: "valid-custom", + ir: customIr("valid-custom", ["todo", "build", "done"], "todo"), + }); + const customTask = await store.createTask({ description: "custom" }); + await store.moveTask(customTask.id, "todo", { moveSource: "user" }); + await store.selectTaskWorkflowAndReconcile(customTask.id, wf.id); + + const before = await Promise.all(ids.map((id) => store.getTask(id))); + const result = await store.runWorkflowColumnsIntegrityPass(); + // No row was invalid → nothing re-homed. + expect(result.rehomed).toBe(0); + + const after = await Promise.all(ids.map((id) => store.getTask(id))); + for (let i = 0; i < ids.length; i += 1) { + expect(after[i].column).toBe(before[i].column); + } + }); +}); + +describe("U12 integrity pass — invalid column re-home + idempotency + terminal-untouched", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + function rawDb(): { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } { + return (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db; + } + + it("re-homes a task whose stored column is invalid in its resolved workflow, and is idempotent", async () => { + // Select a custom workflow that defines [stage-a, stage-b, finished], then + // force the stored column to one that workflow never defines. + const wf = await store.createWorkflowDefinition({ + name: "drifted", + ir: customIr("drifted", ["stage-a", "stage-b", "finished"], "stage-a"), + }); + const task = await store.createTask({ description: "drifter" }); + await store.selectTaskWorkflowAndReconcile(task.id, wf.id); + // Out-of-band corruption: stored column not in the workflow. + rawDb().prepare(`UPDATE tasks SET "column" = ? WHERE id = ?`).run("ghost-column", task.id); + + const first = await store.runWorkflowColumnsIntegrityPass(); + expect(first.rehomed).toBe(1); + const afterFirst = await store.getTask(task.id); + expect(afterFirst.column).toBe("stage-a"); // entry (intake) column + + // Idempotent: a second run finds nothing out of place. + const second = await store.runWorkflowColumnsIntegrityPass(); + expect(second.rehomed).toBe(0); + expect((await store.getTask(task.id)).column).toBe("stage-a"); + }); + + it("leaves done/archived (terminal) cards untouched even if their column were invalid", async () => { + // A task selecting a custom workflow that lacks "done" but the task sits in + // "done" — terminal cards are never re-homed. + const wf = await store.createWorkflowDefinition({ + name: "no-done", + ir: customIr("no-done", ["start-col", "mid-col", "fin-col"], "start-col"), + }); + const task = await store.createTask({ description: "terminal" }); + await store.selectTaskWorkflowAndReconcile(task.id, wf.id); + rawDb().prepare(`UPDATE tasks SET "column" = ? WHERE id = ?`).run("done", task.id); + + const result = await store.runWorkflowColumnsIntegrityPass(); + expect(result.skippedTerminal).toBeGreaterThanOrEqual(1); + expect((await store.getTask(task.id)).column).toBe("done"); + }); +}); + +describe("U12 rollback safety — flag OFF after flag ON keeps legacy behavior", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + it("a board built under flag-ON resolves identically and moves legacy-style under flag-OFF", async () => { + // Build a board under flag-ON. + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + const t = await store.createTask({ description: "rollback" }); + await store.moveTask(t.id, "todo", { moveSource: "user" }); + await store.moveTask(t.id, "in-progress", { moveSource: "user" }); + + // Flip the flag OFF. + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: false } }); + + // Legacy board intact: the task is still in in-progress. + expect((await store.getTask(t.id)).column).toBe("in-progress"); + + // Legacy engine behavior: an illegal move throws the legacy string (not a + // typed rejection), and a legal move works exactly as before. + const archived = await store.createTask({ description: "legacy" }); + await store.moveTask(archived.id, "todo", { moveSource: "user" }); + await store.moveTask(archived.id, "in-progress", { moveSource: "user" }); + await store.moveTask(archived.id, "in-review", { moveSource: "user", allowDirectInReviewMove: true }); + await store.moveTask(archived.id, "done", { moveSource: "engine", skipMergeBlocker: true }); + await store.moveTask(archived.id, "archived", { moveSource: "user" }); + let caught: unknown; + try { + await store.moveTask(archived.id, "todo", { moveSource: "user" }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).toMatch(/Invalid transition/); + }); + + it("a card stranded in a custom column when the flag is toggled OFF degrades to a clean Invalid-transition error (no TypeError) and listTasks stays healthy", async () => { + // Flag ON: select a custom workflow whose entry column is custom, so the + // card is re-homed into a column that VALID_TRANSITIONS never keys. + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + const wf = await store.createWorkflowDefinition({ + name: "stranded", + ir: customIr("stranded", ["intake", "build", "ship"], "intake"), + }); + const task = await store.createTask({ description: "stranded card" }); + await store.selectTaskWorkflowAndReconcile(task.id, wf.id); + expect((await store.getTask(task.id)).column).toBe("intake"); + + // Toggle the flag OFF — #1409: the ON→OFF evacuation re-homes the card from + // the custom "intake" column to the nearest legacy column (the default + // workflow's entry column, triage) so it is not stranded on the legacy path. + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: false } }); + expect((await store.getTask(task.id)).column).toBe("triage"); + + // listTasks stays healthy. + await expect(store.listTasks()).resolves.toBeDefined(); + + // The evacuated card now moves legacy-style: triage → todo works. + await store.moveTask(task.id, "todo", { moveSource: "user" }); + expect((await store.getTask(task.id)).column).toBe("todo"); + }); +}); + +describe("Residual B: getBranchProgressByTask reads workflow_run_branches", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + function db(): { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } { + return (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db; + } + + it("returns an empty map when the table is empty (cheap short-circuit)", async () => { + const t = await store.createTask({ description: "x" }); + expect(store.getBranchProgressByTask([t.id]).size).toBe(0); + }); + + it("returns the latest run's branches for a task with rows", async () => { + const t = await store.createTask({ description: "fanout" }); + const ins = `INSERT INTO workflow_run_branches (taskId, runId, branchId, currentNodeId, status, updatedAt) VALUES (?, ?, ?, ?, ?, ?)`; + // Older run (should be ignored). + db().prepare(ins).run(t.id, "run-1", "b1", "n1", "completed", "2026-06-01T00:00:00.000Z"); + // Latest run with two branches. + db().prepare(ins).run(t.id, "run-2", "b1", "n2", "running", "2026-06-03T00:00:00.000Z"); + db().prepare(ins).run(t.id, "run-2", "b2", "n3", "completed", "2026-06-03T00:00:01.000Z"); + + const byTask = store.getBranchProgressByTask([t.id]); + const entries = byTask.get(t.id) ?? []; + expect(entries.length).toBe(2); + expect(entries.map((e) => e.branchId).sort()).toEqual(["b1", "b2"]); + expect(entries.find((e) => e.branchId === "b2")?.status).toBe("completed"); + }); +}); + +describe("#1407/#1412/#1413: workflow_run_branches persistence + latest-run JOIN + prune", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + type BranchStore = { + saveWorkflowRunBranch(state: { + taskId: string; runId: string; branchId: string; currentNodeId: string; status: string; + }): void; + loadWorkflowRunBranches(taskId: string, runId: string): Array<{ + taskId: string; runId: string; branchId: string; currentNodeId: string; status: string; + }>; + clearWorkflowRunBranches(taskId: string, keepRunId: string): void; + }; + const bs = (): BranchStore => store as unknown as BranchStore; + + function rawCount(taskId: string): number { + const db = (store as unknown as { db: { prepare: (s: string) => { get: (...a: unknown[]) => unknown } } }).db; + const row = db + .prepare("SELECT COUNT(*) AS c FROM workflow_run_branches WHERE taskId = ?") + .get(taskId) as { c: number }; + return row.c; + } + + it("saveWorkflowRunBranch upserts one row per (taskId, runId, branchId) keyed by currentNodeId", async () => { + const t = await store.createTask({ description: "fanout" }); + bs().saveWorkflowRunBranch({ taskId: t.id, runId: "r1", branchId: "b1", currentNodeId: "n1", status: "running" }); + bs().saveWorkflowRunBranch({ taskId: t.id, runId: "r1", branchId: "b1", currentNodeId: "n2", status: "completed" }); + bs().saveWorkflowRunBranch({ taskId: t.id, runId: "r1", branchId: "b2", currentNodeId: "n3", status: "running" }); + + // b1 overwrote in place (still one row), b2 added — 2 rows total. + expect(rawCount(t.id)).toBe(2); + const loaded = bs().loadWorkflowRunBranches(t.id, "r1"); + const b1 = loaded.find((s) => s.branchId === "b1"); + expect(b1?.currentNodeId).toBe("n2"); + expect(b1?.status).toBe("completed"); + }); + + it("loadWorkflowRunBranches returns only the requested run", async () => { + const t = await store.createTask({ description: "fanout" }); + bs().saveWorkflowRunBranch({ taskId: t.id, runId: "r1", branchId: "b1", currentNodeId: "n1", status: "completed" }); + bs().saveWorkflowRunBranch({ taskId: t.id, runId: "r2", branchId: "b1", currentNodeId: "n9", status: "running" }); + expect(bs().loadWorkflowRunBranches(t.id, "r1").length).toBe(1); + expect(bs().loadWorkflowRunBranches(t.id, "r1")[0]?.currentNodeId).toBe("n1"); + }); + + it("clearWorkflowRunBranches prunes all runs except the kept one (#1412)", async () => { + const t = await store.createTask({ description: "fanout" }); + bs().saveWorkflowRunBranch({ taskId: t.id, runId: "old-1", branchId: "b1", currentNodeId: "n1", status: "completed" }); + bs().saveWorkflowRunBranch({ taskId: t.id, runId: "old-2", branchId: "b1", currentNodeId: "n1", status: "completed" }); + bs().saveWorkflowRunBranch({ taskId: t.id, runId: "keep", branchId: "b1", currentNodeId: "n5", status: "running" }); + expect(rawCount(t.id)).toBe(3); + + bs().clearWorkflowRunBranches(t.id, "keep"); + expect(rawCount(t.id)).toBe(1); + expect(bs().loadWorkflowRunBranches(t.id, "keep").length).toBe(1); + }); + + it("getBranchProgressByTask returns only the latest run's branches across multiple runs (#1413)", async () => { + const t = await store.createTask({ description: "fanout" }); + const ins = `INSERT INTO workflow_run_branches (taskId, runId, branchId, currentNodeId, status, updatedAt) VALUES (?, ?, ?, ?, ?, ?)`; + const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db; + // Older run. + db.prepare(ins).run(t.id, "run-1", "b1", "n1", "completed", "2026-06-01T00:00:00.000Z"); + db.prepare(ins).run(t.id, "run-1", "b2", "n2", "completed", "2026-06-01T00:00:01.000Z"); + // Latest run, two branches with staggered updatedAt (both must be returned). + db.prepare(ins).run(t.id, "run-2", "b1", "n3", "running", "2026-06-03T00:00:00.000Z"); + db.prepare(ins).run(t.id, "run-2", "b2", "n4", "completed", "2026-06-03T00:00:01.000Z"); + + const entries = store.getBranchProgressByTask([t.id]).get(t.id) ?? []; + expect(entries.length).toBe(2); + expect(entries.map((e) => e.nodeId).sort()).toEqual(["n3", "n4"]); + }); + + it("getBranchProgressByTask breaks updatedAt ties deterministically by runId (#1413)", async () => { + const t = await store.createTask({ description: "fanout" }); + const ins = `INSERT INTO workflow_run_branches (taskId, runId, branchId, currentNodeId, status, updatedAt) VALUES (?, ?, ?, ?, ?, ?)`; + const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db; + const ts = "2026-06-03T00:00:00.000Z"; + // Two runs with identical updatedAt — runId DESC ("run-b" > "run-a") wins. + db.prepare(ins).run(t.id, "run-a", "b1", "nA", "running", ts); + db.prepare(ins).run(t.id, "run-b", "b1", "nB", "running", ts); + + const entries = store.getBranchProgressByTask([t.id]).get(t.id) ?? []; + expect(entries.length).toBe(1); + expect(entries[0]?.nodeId).toBe("nB"); + }); +}); + +describe("U12 graduation report — parity drift is caught", () => { + it("transition-parity holds for the unmodified default workflow", () => { + expect(checkTransitionParity(BUILTIN_CODING_WORKFLOW_IR).agree).toBe(true); + }); + + it("a deliberately drifted default-workflow adjacency is caught by transition parity", () => { + // Clone the default IR and remove a legal edge target from in-progress's + // adjacency by dropping the "todo" backward column from its outgoing edges. + const drifted = JSON.parse(JSON.stringify(BUILTIN_CODING_WORKFLOW_IR)) as WorkflowIr & { + edges: Array<{ from: string; to: string }>; + columns: Array<{ id: string }>; + }; + // Remove ALL columns named "archived" so the column set itself diverges — + // a coarse but unambiguous drift the gate must catch. + drifted.columns = drifted.columns.filter((c) => c.id !== "archived"); + const report = checkTransitionParity(drifted as unknown as WorkflowIr); + expect(report.agree).toBe(false); + expect(report.diffs.some((d) => d.from === "archived" || d.from === "done")).toBe(true); + }); + + it("graduation report is NOT ready with zero observations and is gated by every signal", () => { + const report = computeWorkflowColumnsGraduationReport({ + parity: { observed: 0, agreed: 0, drift: 0, agreeRate: 0, driftFieldCounts: {}, recentDrift: [] }, + defaultWorkflowIr: BUILTIN_CODING_WORKFLOW_IR, + dualAcceptEvents: [], + }); + expect(report.ready).toBe(false); + expect(report.blockers.some((b) => /observation window empty/.test(b))).toBe(true); + }); + + it("graduation report is ready only when parity clean, transitions match, and zero dual-accept disagreement", () => { + const report = computeWorkflowColumnsGraduationReport({ + parity: { observed: 100, agreed: 100, drift: 0, agreeRate: 1, driftFieldCounts: {}, recentDrift: [] }, + defaultWorkflowIr: BUILTIN_CODING_WORKFLOW_IR, + dualAcceptEvents: [], + }); + expect(report.transitionParity.agree).toBe(true); + expect(report.dualAccept.total).toBe(0); + expect(report.ready).toBe(true); + expect(report.blockers).toEqual([]); + }); + + it("dual-accept disagreements above zero block graduation", () => { + const events = [ + { + domain: "database", + mutationType: "merge:dependency-parity-diff", + target: "FN-1", + timestamp: "2026-06-03T00:00:00.000Z", + }, + { + domain: "database", + mutationType: "merge:lease-parity-diff", + target: "FN-2", + timestamp: "2026-06-03T00:00:01.000Z", + }, + ] as unknown as Parameters[0]; + const counted = countDualAcceptDisagreements(events); + expect(counted.total).toBe(2); + + const report = computeWorkflowColumnsGraduationReport({ + parity: { observed: 50, agreed: 50, drift: 0, agreeRate: 1, driftFieldCounts: {}, recentDrift: [] }, + defaultWorkflowIr: BUILTIN_CODING_WORKFLOW_IR, + dualAcceptEvents: events, + }); + expect(report.ready).toBe(false); + expect(report.blockers.some((b) => /dual-accept/.test(b))).toBe(true); + }); +}); diff --git a/packages/core/src/__tests__/mission-store.test.ts b/packages/core/src/__tests__/mission-store.test.ts index 8909b33c5f..fbff57d141 100644 --- a/packages/core/src/__tests__/mission-store.test.ts +++ b/packages/core/src/__tests__/mission-store.test.ts @@ -3746,7 +3746,7 @@ describe("MissionStore", () => { describe("Loop State & Validator Run Schema (v31)", () => { it("schema version is 101 after migration", () => { - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); }); it("mission_features table has loop state columns", () => { diff --git a/packages/core/src/__tests__/move-task-characterization.test.ts b/packages/core/src/__tests__/move-task-characterization.test.ts new file mode 100644 index 0000000000..66c4f4901a --- /dev/null +++ b/packages/core/src/__tests__/move-task-characterization.test.ts @@ -0,0 +1,205 @@ +// @vitest-environment node +// +// CHARACTERIZATION SUITE (U4 Execution Note — written FIRST, before any change +// to `moveTaskInternal`). +// +// This suite pins the CURRENT behavior of `moveTaskInternal` for every (from, +// to) pair in VALID_TRANSITIONS' domain and both moveSource values, plus the +// key column side effects: +// - merge-blocker on in-review → done (user source) +// - userPaused set only for user-source in-progress → todo +// - reopen field/step resets on in-review/done → todo|triage +// - autoMerge stamping on → in-review +// - timing fields (cumulativeActiveMs / executionStartedAt) on in-progress +// +// It runs GREEN against the unmodified store first, then runs forever against +// BOTH flag states (workflowColumns OFF and ON) — see the `flagStates` loop. +// Any divergence between the two flag states is a U4 parity FAILURE. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { VALID_TRANSITIONS } from "../types.js"; +import type { Column, Task } from "../types.js"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; + +const ALL_COLUMNS: Column[] = ["triage", "todo", "in-progress", "in-review", "done", "archived"]; + +// Flag states the characterization runs against. OFF is the legacy path; ON is +// the workflow-resolved path. The default workflow MUST reproduce identical +// outcomes for both, so the same expectations apply. +const flagStates: Array<{ label: string; workflowColumns: boolean }> = [ + { label: "flag OFF (legacy path)", workflowColumns: false }, + { label: "flag ON (workflow-resolved default workflow)", workflowColumns: true }, +]; + +for (const flag of flagStates) { + describe(`moveTaskInternal characterization — ${flag.label}`, () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + if (flag.workflowColumns) { + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + } + }); + + afterEach(async () => { + await harness.afterEach(); + }); + + /** + * Drive a freshly-created task (starts in `triage`) into `column` using only + * legal, side-effect-tolerant moves. Returns the task. + */ + async function seedInColumn(column: Column): Promise { + const task = await store.createTask({ description: `seed-${column}` }); + switch (column) { + case "triage": + return task; + case "todo": + return store.moveTask(task.id, "todo", { moveSource: "user" }); + case "in-progress": + await store.moveTask(task.id, "todo", { moveSource: "user" }); + return store.moveTask(task.id, "in-progress", { moveSource: "user" }); + case "in-review": + await store.moveTask(task.id, "todo", { moveSource: "user" }); + await store.moveTask(task.id, "in-progress", { moveSource: "user" }); + return store.moveTask(task.id, "in-review", { moveSource: "user", allowDirectInReviewMove: true }); + case "done": + await store.moveTask(task.id, "todo", { moveSource: "user" }); + await store.moveTask(task.id, "in-progress", { moveSource: "user" }); + await store.moveTask(task.id, "in-review", { moveSource: "user", allowDirectInReviewMove: true }); + return store.moveTask(task.id, "done", { moveSource: "engine", skipMergeBlocker: true }); + case "archived": + await store.moveTask(task.id, "todo", { moveSource: "user" }); + await store.moveTask(task.id, "in-progress", { moveSource: "user" }); + await store.moveTask(task.id, "in-review", { moveSource: "user", allowDirectInReviewMove: true }); + await store.moveTask(task.id, "done", { moveSource: "engine", skipMergeBlocker: true }); + return store.moveTask(task.id, "archived", { moveSource: "user" }); + default: + throw new Error(`unhandled column ${column}`); + } + } + + describe("transition allow/reject matrix (every from×to×moveSource)", () => { + for (const from of ALL_COLUMNS) { + for (const to of ALL_COLUMNS) { + for (const moveSource of ["user", "engine"] as const) { + const allowed = from === to || VALID_TRANSITIONS[from].includes(to); + const label = `${from} → ${to} [${moveSource}] should ${allowed ? "ALLOW" : "REJECT"}`; + it(label, async () => { + const task = await seedInColumn(from); + // Same-column move is a no-op success in legacy behavior. + if (from === to) { + const result = await store.moveTask(task.id, to, { moveSource }); + expect(result.column).toBe(to); + return; + } + if (allowed) { + // in-review → done with merge-blocker only blocks for user source + // and only when a blocker exists; our seeded task has no blocker. + // Bare in-review targets bypass the handoff invariant via + // allowDirectInReviewMove, matching production drag behavior. + const opts = + to === "in-review" + ? { moveSource, allowDirectInReviewMove: true } + : { moveSource }; + const result = await store.moveTask(task.id, to, opts); + expect(result.column).toBe(to); + } else { + await expect( + store.moveTask(task.id, to, { moveSource }), + ).rejects.toThrow(/Invalid transition/); + } + }); + } + } + } + }); + + describe("merge-blocker side effect (in-review → done)", () => { + it("blocks a user move to done when a merge blocker exists", async () => { + const task = await seedInColumn("in-review"); + // Incomplete steps create a merge blocker (getTaskMergeBlocker). + await store.updateTask(task.id, { + steps: [{ name: "x", status: "pending" }] as Task["steps"], + }); + await expect( + store.moveTask(task.id, "done", { moveSource: "user" }), + ).rejects.toThrow(/Cannot move .* to done/); + }); + + it("skipMergeBlocker bypasses the blocker", async () => { + const task = await seedInColumn("in-review"); + await store.updateTask(task.id, { + steps: [{ name: "x", status: "pending" }] as Task["steps"], + }); + const result = await store.moveTask(task.id, "done", { + moveSource: "engine", + skipMergeBlocker: true, + }); + expect(result.column).toBe("done"); + }); + }); + + describe("userPaused side effect (in-progress → todo)", () => { + it("sets userPaused for a user-source move", async () => { + const task = await seedInColumn("in-progress"); + const result = await store.moveTask(task.id, "todo", { moveSource: "user" }); + expect(result.userPaused).toBe(true); + }); + + it("does NOT set userPaused for an engine-source move", async () => { + const task = await seedInColumn("in-progress"); + const result = await store.moveTask(task.id, "todo", { moveSource: "engine" }); + expect(result.userPaused).toBeUndefined(); + }); + }); + + describe("reopen resets (in-review → todo)", () => { + it("clears branch/summary/baseCommitSha on reopen to todo", async () => { + const task = await seedInColumn("in-review"); + await store.updateTask(task.id, { + branch: "fusion/fn-x", + summary: "did stuff", + baseCommitSha: "abc123", + }); + const result = await store.moveTask(task.id, "todo", { moveSource: "user" }); + expect(result.branch).toBeUndefined(); + expect(result.summary).toBeUndefined(); + expect(result.baseCommitSha).toBeUndefined(); + }); + }); + + describe("autoMerge stamping (→ in-review)", () => { + it("stamps autoMerge from settings when undefined", async () => { + await store.updateSettings({ autoMerge: true }); + const task = await seedInColumn("in-progress"); + const result = await store.moveTask(task.id, "in-review", { + moveSource: "user", + allowDirectInReviewMove: true, + }); + expect(result.autoMerge).toBe(true); + }); + }); + + describe("timing fields (→ in-progress)", () => { + it("sets executionStartedAt and initializes cumulativeActiveMs on entry", async () => { + const task = await seedInColumn("todo"); + const result = await store.moveTask(task.id, "in-progress", { moveSource: "user" }); + expect(result.executionStartedAt).toBeTruthy(); + expect(result.cumulativeActiveMs).toBe(0); + }); + + it("accumulates cumulativeActiveMs on exit from in-progress", async () => { + const task = await seedInColumn("in-progress"); + const result = await store.moveTask(task.id, "in-review", { + moveSource: "user", + allowDirectInReviewMove: true, + }); + expect(result.cumulativeActiveMs).toBeGreaterThanOrEqual(0); + }); + }); + }); +} diff --git a/packages/core/src/__tests__/run-audit.test.ts b/packages/core/src/__tests__/run-audit.test.ts index 855c54b1c2..c64b9a50f3 100644 --- a/packages/core/src/__tests__/run-audit.test.ts +++ b/packages/core/src/__tests__/run-audit.test.ts @@ -584,7 +584,7 @@ describe("Run Audit", () => { }); it("schema version is bumped to 40", () => { - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); }); }); }); diff --git a/packages/core/src/__tests__/store-merge-queue.test.ts b/packages/core/src/__tests__/store-merge-queue.test.ts index 646438bbec..6d2b141a97 100644 --- a/packages/core/src/__tests__/store-merge-queue.test.ts +++ b/packages/core/src/__tests__/store-merge-queue.test.ts @@ -60,7 +60,7 @@ describe("TaskStore merge queue", () => { expect.arrayContaining(["idx_mergeQueue_lease_ready", "idx_mergeQueue_leaseExpiresAt"]), ); - expect(store.getDatabase().getSchemaVersion()).toBe(105); + expect(store.getDatabase().getSchemaVersion()).toBe(107); }); it("migrates a legacy v88 database and preserves task rows", async () => { diff --git a/packages/core/src/__tests__/task-documents.test.ts b/packages/core/src/__tests__/task-documents.test.ts index 6e442a3f1b..ccde60a72e 100644 --- a/packages/core/src/__tests__/task-documents.test.ts +++ b/packages/core/src/__tests__/task-documents.test.ts @@ -51,7 +51,7 @@ describe("TaskStore task documents", () => { expect(tableNames.has("task_documents")).toBe(true); expect(tableNames.has("task_document_revisions")).toBe(true); - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); const index = db .prepare( diff --git a/packages/core/src/__tests__/trait-registry.test.ts b/packages/core/src/__tests__/trait-registry.test.ts new file mode 100644 index 0000000000..ce36ccd15e --- /dev/null +++ b/packages/core/src/__tests__/trait-registry.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "vitest"; +import { + TraitRegistry, + TraitRegistrationError, +} from "../trait-registry.js"; +import type { TraitDefinition } from "../trait-types.js"; +import type { WorkflowIrColumn } from "../workflow-ir-types.js"; + +function col(id: string, traits: string[]): WorkflowIrColumn { + return { id, name: id, traits: traits.map((t) => ({ trait: t })) }; +} + +function builtin(id: string, def: Partial): TraitDefinition { + return { id, name: id, builtin: true, flags: {}, ...def }; +} + +function plugin(id: string, def: Partial): TraitDefinition { + return { id, name: id, flags: {}, ...def }; +} + +/** A registry seeded with a representative built-in set used across tests. */ +function seeded(): TraitRegistry { + const r = new TraitRegistry(); + r.register(builtin("intake", { flags: { intake: true } })); + r.register(builtin("complete", { flags: { complete: true } })); + r.register(builtin("archived", { flags: { archived: true, hiddenFromBoard: true } })); + r.register(builtin("wip", { flags: { countsTowardWip: true } })); + r.register(builtin("wip2", { flags: { countsTowardWip: true } })); + r.register(builtin("merge-blocker", { flags: { mergeBlocker: true }, hooks: { guard: true } })); + r.register(builtin("timing", { flags: { timing: true }, hooks: { onEnter: true, onExit: true } })); + return r; +} + +describe("TraitRegistry — registration", () => { + it("rejects a duplicate trait id", () => { + const r = new TraitRegistry(); + r.register(builtin("intake", { flags: { intake: true } })); + expect(() => r.register(builtin("intake", { flags: { intake: true } }))).toThrowError( + TraitRegistrationError, + ); + try { + r.register(builtin("intake", { flags: { intake: true } })); + } catch (err) { + expect((err as TraitRegistrationError).reason).toBe("duplicate-id"); + } + }); + + it("blocks a non-builtin from overriding a built-in namespace id", () => { + const r = new TraitRegistry(); + r.register(builtin("complete", { flags: { complete: true } })); + let caught: TraitRegistrationError | undefined; + try { + r.register(plugin("complete", { flags: {} })); + } catch (err) { + caught = err as TraitRegistrationError; + } + expect(caught).toBeInstanceOf(TraitRegistrationError); + expect(caught?.reason).toBe("builtin-namespace-protected"); + }); + + it("rejects a non-builtin declaring the restricted `complete` flag", () => { + const r = new TraitRegistry(); + let caught: TraitRegistrationError | undefined; + try { + r.register(plugin("my-plugin:done", { flags: { complete: true } })); + } catch (err) { + caught = err as TraitRegistrationError; + } + expect(caught?.reason).toBe("restricted-flag"); + }); + + it("rejects a non-builtin declaring the restricted `archived` flag", () => { + const r = new TraitRegistry(); + let caught: TraitRegistrationError | undefined; + try { + r.register(plugin("my-plugin:arch", { flags: { archived: true } })); + } catch (err) { + caught = err as TraitRegistrationError; + } + expect(caught?.reason).toBe("restricted-flag"); + }); + + it("rejects a non-builtin declaring a sync `guard` hook (built-in only)", () => { + const r = new TraitRegistry(); + let caught: TraitRegistrationError | undefined; + try { + r.register(plugin("my-plugin:guard", { flags: {}, hooks: { guard: true } })); + } catch (err) { + caught = err as TraitRegistrationError; + } + expect(caught?.reason).toBe("restricted-guard-hook"); + }); + + it("allows a non-builtin declaring async-only hooks", () => { + const r = new TraitRegistry(); + expect(() => + r.register( + plugin("my-plugin:gate", { + flags: { gate: true }, + hooks: { gate: true, onEnter: true, onExit: true, releaseCondition: true }, + }), + ), + ).not.toThrow(); + }); +}); + +describe("TraitRegistry — flag resolution", () => { + it("merges effective flags across a column's traits (OR)", () => { + const r = seeded(); + const flags = r.resolveColumnFlags(col("in-progress", ["wip", "timing"])); + expect(flags.countsTowardWip).toBe(true); + expect(flags.timing).toBe(true); + expect(flags.complete).toBeUndefined(); + }); + + it("ignores unknown trait ids in flag resolution", () => { + const r = seeded(); + const flags = r.resolveColumnFlags(col("x", ["wip", "nope"])); + expect(flags.countsTowardWip).toBe(true); + }); +}); + +describe("TraitRegistry — composition validator", () => { + it("rejects complete + countsTowardWip with its reason code", () => { + const r = seeded(); + const v = r.validateColumnTraits([col("c", ["complete", "wip"])]); + expect(v.find((x) => x.code === "complete-with-wip")?.severity).toBe("error"); + }); + + it("rejects two capacity (wip) traits on one column", () => { + const r = seeded(); + const v = r.validateColumnTraits([col("c", ["wip", "wip2"])]); + const hit = v.find((x) => x.code === "two-capacity-traits"); + expect(hit?.severity).toBe("error"); + expect(hit?.traitIds.sort()).toEqual(["wip", "wip2"]); + }); + + it("rejects complete + intake", () => { + const r = seeded(); + const v = r.validateColumnTraits([col("c", ["complete", "intake"])]); + expect(v.find((x) => x.code === "complete-with-intake")?.severity).toBe("error"); + }); + + it("rejects archived + countsTowardWip", () => { + const r = seeded(); + const v = r.validateColumnTraits([col("c", ["archived", "wip"])]); + expect(v.find((x) => x.code === "archived-with-wip")?.severity).toBe("error"); + }); + + it("rejects more than one intake column per workflow", () => { + const r = seeded(); + const v = r.validateColumnTraits([col("a", ["intake"]), col("b", ["intake"])]); + expect(v.find((x) => x.code === "multiple-intake-columns")?.severity).toBe("error"); + }); + + it("a valid single-intake / clean column set passes", () => { + const r = seeded(); + const v = r.validateColumnTraits([ + col("triage", ["intake"]), + col("in-progress", ["wip", "timing"]), + col("done", ["complete"]), + ]); + expect(v).toEqual([]); + }); + + it("conflicting boolean flags reject at save (validator), not at runtime", () => { + const r = seeded(); + // The conflict is surfaced by the validator (save-time), not on flag merge. + const flags = r.resolveColumnFlags(col("c", ["complete", "wip"])); + expect(flags.complete && flags.countsTowardWip).toBe(true); // merge does not throw + const v = r.validateColumnTraits([col("c", ["complete", "wip"])]); + expect(v.some((x) => x.code === "complete-with-wip" && x.severity === "error")).toBe(true); + }); + + it("unknown trait is a save-blocking error in save mode", () => { + const r = seeded(); + const v = r.validateColumnTraits([col("c", ["totally-unknown"])], "save"); + const hit = v.find((x) => x.code === "unknown-trait"); + expect(hit?.severity).toBe("error"); + }); + + it("load-time re-validation degrades unknown trait to advisory, not error", () => { + const r = seeded(); + const v = r.validateColumnTraits([col("c", ["totally-unknown"])], "load"); + const hit = v.find((x) => x.code === "unknown-trait"); + expect(hit?.severity).toBe("degraded"); + // The definition still "loads" — there is no error-severity violation. + expect(v.some((x) => x.severity === "error")).toBe(false); + }); +}); + +describe("TraitRegistry — hook implementation DI", () => { + it("resolves a declared hook with no registered impl to a no-op + audit warning", () => { + const r = seeded(); + const { impl, warning } = r.resolveTraitHook("merge-blocker", "guard"); + expect(typeof impl).toBe("function"); + expect(impl?.()).toBeUndefined(); // no-op + expect(warning?.kind).toBe("missing-hook-impl"); + expect(warning?.traitId).toBe("merge-blocker"); + expect(warning?.hookKind).toBe("guard"); + }); + + it("resolves a registered impl without a warning", () => { + const r = seeded(); + let called = false; + r.registerTraitHookImpl("merge-blocker", "guard", () => { + called = true; + return "ok"; + }); + const { impl, warning } = r.resolveTraitHook("merge-blocker", "guard"); + expect(warning).toBeUndefined(); + expect(impl?.()).toBe("ok"); + expect(called).toBe(true); + }); + + it("returns no impl and no warning when the trait does not declare the hook", () => { + const r = seeded(); + const { impl, warning } = r.resolveTraitHook("wip", "onEnter"); + expect(impl).toBeUndefined(); + expect(warning).toBeUndefined(); + }); +}); diff --git a/packages/core/src/__tests__/transition-parity.test.ts b/packages/core/src/__tests__/transition-parity.test.ts new file mode 100644 index 0000000000..17bc033994 --- /dev/null +++ b/packages/core/src/__tests__/transition-parity.test.ts @@ -0,0 +1,304 @@ +// @vitest-environment node +// +// TRANSITION-PARITY SUITE (U4). +// +// Proves the flag-ON workflow-resolved transition path reproduces the legacy +// VALID_TRANSITIONS contract for the default workflow, and exercises the U4 +// plan scenarios: +// - VALID_TRANSITIONS parity (allowed AND rejected sets identical) +// - FN-5147 terminal-until-merged (both paths) +// - hard-cancel user vs engine (userPaused + abort-on-exit bypass) +// - handoff bypass + exactly-once enqueue across a simulated crash +// - crash-mid-transition marker recovery (SQLite authoritative) +// - unknown-column rejection +// - guard rejection typed (flag-ON) vs legacy string (flag-OFF) +// - in-txn capacity enforcement (U6; NEVER bypassable — KTD-10) + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { VALID_TRANSITIONS } from "../types.js"; +import type { Column, Task } from "../types.js"; +import { TransitionRejectionError } from "../store.js"; +import { resolveAllowedColumns, workflowHasColumn } from "../workflow-transitions.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; +import { readTransitionPending } from "../transition-pending.js"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; + +const ALL_COLUMNS: Column[] = ["triage", "todo", "in-progress", "in-review", "done", "archived"]; + +describe("transition-parity — default workflow column adjacency == VALID_TRANSITIONS", () => { + it("reproduces VALID_TRANSITIONS exactly for every column (allowed + rejected)", () => { + for (const from of ALL_COLUMNS) { + const legacy = new Set(VALID_TRANSITIONS[from]); + const resolved = new Set(resolveAllowedColumns(BUILTIN_CODING_WORKFLOW_IR, from)); + // Allowed sets identical. + expect([...resolved].sort()).toEqual([...legacy].sort()); + // Rejected sets identical (complement over all columns). + for (const to of ALL_COLUMNS) { + if (from === to) continue; + expect(resolved.has(to)).toBe(legacy.has(to)); + } + } + }); + + it("recognizes exactly the six default columns", () => { + for (const c of ALL_COLUMNS) { + expect(workflowHasColumn(BUILTIN_CODING_WORKFLOW_IR, c)).toBe(true); + } + expect(workflowHasColumn(BUILTIN_CODING_WORKFLOW_IR, "made-up")).toBe(false); + }); +}); + +describe("transition-parity — store flag-ON scenarios", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + async function seedInColumn(column: Column): Promise { + const task = await store.createTask({ description: `seed-${column}` }); + const u = { moveSource: "user" as const }; + if (column === "triage") return task; + await store.moveTask(task.id, "todo", u); + if (column === "todo") return store.getTask(task.id) as Promise; + await store.moveTask(task.id, "in-progress", u); + if (column === "in-progress") return store.getTask(task.id) as Promise; + await store.moveTask(task.id, "in-review", { ...u, allowDirectInReviewMove: true }); + if (column === "in-review") return store.getTask(task.id) as Promise; + await store.moveTask(task.id, "done", { moveSource: "engine", skipMergeBlocker: true }); + if (column === "done") return store.getTask(task.id) as Promise; + await store.moveTask(task.id, "archived", u); + return store.getTask(task.id) as Promise; + } + + it("FN-5147: user move in-review → done blocked by merge-blocker with typed rejection", async () => { + const task = await seedInColumn("in-review"); + await store.updateTask(task.id, { steps: [{ name: "x", status: "pending" }] as Task["steps"] }); + let caught: unknown; + try { + await store.moveTask(task.id, "done", { moveSource: "user" }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(TransitionRejectionError); + expect((caught as TransitionRejectionError).rejection.code).toBe("merge-blocked"); + expect((caught as TransitionRejectionError).rejection.retryable).toBe(true); + }); + + it("FN-5147: engine-sourced move bypasses the merge-blocker guard", async () => { + const task = await seedInColumn("in-review"); + await store.updateTask(task.id, { steps: [{ name: "x", status: "pending" }] as Task["steps"] }); + const moved = await store.moveTask(task.id, "done", { moveSource: "engine" }); + expect(moved.column).toBe("done"); + }); + + it("hard-cancel: user in-progress → todo sets userPaused; engine does not", async () => { + const userTask = await seedInColumn("in-progress"); + const u = await store.moveTask(userTask.id, "todo", { moveSource: "user" }); + expect(u.userPaused).toBe(true); + + const engineTask = await seedInColumn("in-progress"); + const e = await store.moveTask(engineTask.id, "todo", { moveSource: "engine" }); + expect(e.userPaused).toBeUndefined(); + }); + + it("unknown column rejects with typed unknown-column code, card untouched", async () => { + const task = await seedInColumn("todo"); + let caught: unknown; + try { + await store.moveTask(task.id, "made-up" as Column, { moveSource: "user" }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(TransitionRejectionError); + expect((caught as TransitionRejectionError).rejection.code).toBe("unknown-column"); + const after = await store.getTask(task.id); + expect(after?.column).toBe("todo"); + }); + + it("guard/adjacency rejection is typed (not a bare Error string)", async () => { + const task = await seedInColumn("archived"); + // archived → todo is not a legal default-workflow transition. + let caught: unknown; + try { + await store.moveTask(task.id, "todo", { moveSource: "user" }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(TransitionRejectionError); + expect((caught as TransitionRejectionError).rejection.code).toBe("guard-rejected"); + }); + + it("handoffToReview maps skipMergeBlocker onto bypassGuards and enqueues exactly once", async () => { + const task = await seedInColumn("in-progress"); + await store.handoffToReview(task.id, { + ownerAgentId: "agent-1", + evidence: { runId: "run-1", agentId: "agent-1", reason: "complete" }, + } as Parameters[1]); + const after = await store.getTask(task.id); + expect(after?.column).toBe("in-review"); + // Idempotent re-handoff (same-column path) must not double-enqueue. + await store.handoffToReview(task.id, { + ownerAgentId: "agent-1", + evidence: { runId: "run-2", agentId: "agent-1", reason: "complete" }, + } as Parameters[1]); + const queueCount = (store as unknown as { db: { prepare: (s: string) => { get: (...a: unknown[]) => unknown } } }).db + .prepare("SELECT COUNT(*) AS n FROM mergeQueue WHERE taskId = ?") + .get(task.id) as { n: number }; + expect(queueCount.n).toBe(1); + }); + + it("transitionPending marker is written in-txn and cleared post-commit (happy path)", async () => { + const task = await seedInColumn("todo"); + await store.moveTask(task.id, "in-progress", { moveSource: "user" }); + const db = (store as unknown as { db: Parameters[0] }).db; + // Happy path: marker cleared after the post-commit hook runner. + expect(readTransitionPending(db, task.id)).toBeNull(); + }); + + it("crash-mid-transition: a persisted marker is recoverable from SQLite with hooksRemaining intact", async () => { + const task = await seedInColumn("todo"); + await store.moveTask(task.id, "in-progress", { moveSource: "user" }); + // Simulate a crash AFTER commit but BEFORE the marker clear by re-writing a + // marker directly (the in-txn write path is the same helper). Recovery reads + // it back from SQLite (authoritative), not from task.json. + const db = (store as unknown as { db: Parameters[0] }).db; + (db as unknown as { prepare: (s: string) => { run: (...a: unknown[]) => unknown } }) + .prepare("UPDATE tasks SET transitionPending = ? WHERE id = ?") + .run( + JSON.stringify({ toColumn: "in-progress", hooksRemaining: ["default-workflow:postCommit"], startedAt: Date.now() }), + task.id, + ); + const pending = readTransitionPending(db, task.id); + expect(pending).not.toBeNull(); + expect(pending?.toColumn).toBe("in-progress"); + expect(pending?.hooksRemaining).toContain("default-workflow:postCommit"); + }); + + it("worktree ordering: allocateWorktree runs (and is applied) for a flag-ON move into in-progress", async () => { + const task = await seedInColumn("todo"); + let allocatorCalled = false; + const moved = await store.moveTask(task.id, "in-progress", { + moveSource: "user", + allocateWorktree: () => { + allocatorCalled = true; + return "/tmp/wt/seed-todo"; + }, + }); + expect(allocatorCalled).toBe(true); + expect(moved.worktree).toBe("/tmp/wt/seed-todo"); + // Worktree allocation is NOT a hook — it is a substrate capability invoked + // synchronously before the move commits; the committed row carries it. + const after = await store.getTask(task.id); + expect(after?.worktree).toBe("/tmp/wt/seed-todo"); + }); + + it("U6 in-txn capacity: default-workflow in-progress WIP reads through maxConcurrent and rejects the over-limit move", async () => { + // The default workflow's in-progress column has a `wip` trait whose limit + // reads through to settings.maxConcurrent (legacy parity). With limit 1, the + // first move into in-progress commits and a second rejects with the typed + // capacity-exhausted code. + await store.updateSettings({ maxConcurrent: 1 } as Parameters[0]); + const t1 = await seedInColumn("todo"); + const t2 = await seedInColumn("todo"); + const m1 = await store.moveTask(t1.id, "in-progress", { moveSource: "user" }); + expect(m1.column).toBe("in-progress"); + + let caught: unknown; + try { + await store.moveTask(t2.id, "in-progress", { moveSource: "user" }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(TransitionRejectionError); + expect((caught as TransitionRejectionError).rejection.code).toBe("capacity-exhausted"); + // The rejected card is untouched. + expect((await store.getTask(t2.id))?.column).toBe("todo"); + }); + + it("U6 capacity is NEVER bypassable (KTD-10): an engine/bypassGuards move into a full column still rejects", async () => { + await store.updateSettings({ maxConcurrent: 1 } as Parameters[0]); + const t1 = await seedInColumn("todo"); + const t2 = await seedInColumn("todo"); + await store.moveTask(t1.id, "in-progress", { moveSource: "user" }); + + let caught: unknown; + try { + // Engine-sourced + bypassGuards skips trait guards, but capacity is not a + // guard — it must still reject. + await store.moveTask(t2.id, "in-progress", { moveSource: "engine", bypassGuards: true }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(TransitionRejectionError); + expect((caught as TransitionRejectionError).rejection.code).toBe("capacity-exhausted"); + }); + + it("U6 capacity counts cards mid-transitionPending (they hold their slot from commit time)", async () => { + await store.updateSettings({ maxConcurrent: 1 } as Parameters[0]); + const t1 = await seedInColumn("todo"); + const t2 = await seedInColumn("todo"); + await store.moveTask(t1.id, "in-progress", { moveSource: "user" }); + // Simulate a crash before t1's marker clears: it is still mid-transition into + // in-progress, holding its slot. (Its column already equals in-progress, so + // this also independently holds the slot; this asserts the marker path does + // not under-count or double-count.) + const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db; + db.prepare("UPDATE tasks SET transitionPending = ? WHERE id = ?").run( + JSON.stringify({ toColumn: "in-progress", hooksRemaining: ["default-workflow:postCommit"], startedAt: Date.now() }), + t1.id, + ); + let caught: unknown; + try { + await store.moveTask(t2.id, "in-progress", { moveSource: "user" }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(TransitionRejectionError); + expect((caught as TransitionRejectionError).rejection.code).toBe("capacity-exhausted"); + }); +}); + +describe("transition-parity — flag-OFF keeps legacy thrown strings (no behavior change)", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + it("rejects an illegal move with a bare Error containing the legacy message (not TransitionRejectionError)", async () => { + const task = await store.createTask({ description: "legacy reject" }); + await store.moveTask(task.id, "todo", { moveSource: "user" }); + await store.moveTask(task.id, "in-progress", { moveSource: "user" }); + await store.moveTask(task.id, "in-review", { moveSource: "user", allowDirectInReviewMove: true }); + await store.moveTask(task.id, "done", { moveSource: "engine", skipMergeBlocker: true }); + await store.moveTask(task.id, "archived", { moveSource: "user" }); + let caught: unknown; + try { + await store.moveTask(task.id, "todo", { moveSource: "user" }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(Error); + expect(caught).not.toBeInstanceOf(TransitionRejectionError); + expect((caught as Error).message).toMatch(/Invalid transition/); + }); + + it("flag-OFF does NOT write a transitionPending marker", async () => { + const task = await store.createTask({ description: "no marker" }); + await store.moveTask(task.id, "todo", { moveSource: "user" }); + await store.moveTask(task.id, "in-progress", { moveSource: "user" }); + const db = (store as unknown as { db: Parameters[0] }).db; + expect(readTransitionPending(db, task.id)).toBeNull(); + }); +}); diff --git a/packages/core/src/__tests__/transition-pending-recovery.test.ts b/packages/core/src/__tests__/transition-pending-recovery.test.ts new file mode 100644 index 0000000000..064393f800 --- /dev/null +++ b/packages/core/src/__tests__/transition-pending-recovery.test.ts @@ -0,0 +1,195 @@ +// @vitest-environment node +// +// #1401 + #1409: store-level recovery / evacuation passes for the workflow +// columns feature. +// +// #1401 — transitionPending recovery sweep: +// * a crash-simulated stale marker is recovered (cleared) by the sweep, +// * the phantom capacity slot the marker reserved is released so a fresh +// card can re-enter a full (capacity=1) column afterwards, +// * the sweep is idempotent (a second run finds nothing). +// +// #1409 — flag ON→OFF evacuation: +// * toggling workflowColumns OFF with a card in a custom column re-homes it +// to a legacy column, the board stays listable, and legacy moves work. +// * a flag-OFF store init evacuates a card left in a custom column. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; +import type { WorkflowIr } from "../workflow-ir-types.js"; +import { makeTransitionPending, serializeTransitionPending } from "../transition-types.js"; + +/** A custom workflow whose middle column carries a WIP capacity limit of 1. */ +function cappedIr(): WorkflowIr { + return { + version: "v2", + name: "capped", + columns: [ + { id: "intake", name: "intake", traits: [{ trait: "intake" }] }, + { + id: "build", + name: "build", + traits: [{ trait: "wip", config: { limit: 1, countPending: true } }], + }, + { id: "ship", name: "ship", traits: [] }, + ], + nodes: [ + { id: "start", kind: "start", column: "intake" }, + { id: "work", kind: "prompt", column: "build", config: { prompt: "do" } }, + { id: "end", kind: "end", column: "ship" }, + ], + edges: [ + { from: "start", to: "work", condition: "success" }, + { from: "work", to: "end", condition: "success" }, + ], + }; +} + +function simpleCustomIr(): WorkflowIr { + return { + version: "v2", + name: "simple-custom", + columns: [ + { id: "intake", name: "intake", traits: [{ trait: "intake" }] }, + { id: "build", name: "build", traits: [] }, + { id: "ship", name: "ship", traits: [] }, + ], + nodes: [ + { id: "start", kind: "start", column: "intake" }, + { id: "work", kind: "prompt", column: "build", config: { prompt: "do" } }, + { id: "end", kind: "end", column: "ship" }, + ], + edges: [ + { from: "start", to: "work", condition: "success" }, + { from: "work", to: "end", condition: "success" }, + ], + }; +} + +describe("#1401 transitionPending recovery sweep", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + function rawDb(): { + prepare: (s: string) => { run: (...a: unknown[]) => unknown; get: (...a: unknown[]) => unknown }; + } { + return (store as unknown as { db: ReturnType }).db; + } + + function readMarkerColumn(taskId: string): string | null { + const row = rawDb() + .prepare(`SELECT transitionPending FROM tasks WHERE id = ?`) + .get(taskId) as { transitionPending: string | null } | undefined; + return row?.transitionPending ?? null; + } + + it("recovers a crash-simulated stale marker and is idempotent", async () => { + const t = await store.createTask({ description: "stale-marker" }); + // Simulate a crash that left a transitionPending marker set forever. + const marker = serializeTransitionPending( + makeTransitionPending("build", ["default-workflow:postCommit"], Date.now() - 60_000), + ); + rawDb().prepare(`UPDATE tasks SET transitionPending = ? WHERE id = ?`).run(marker, t.id); + expect(readMarkerColumn(t.id)).not.toBeNull(); + + const first = await store.recoverStaleTransitionPending(); + expect(first.scanned).toBeGreaterThanOrEqual(1); + expect(first.recovered).toBe(1); + // Marker cleared → capacity slot released. + expect(readMarkerColumn(t.id)).toBeNull(); + + // Idempotent: nothing left to recover. + const second = await store.recoverStaleTransitionPending(); + expect(second.recovered).toBe(0); + }); + + it("releases the phantom capacity slot a stale marker reserved (count returns to normal)", async () => { + const wf = await store.createWorkflowDefinition({ name: "capped", ir: cappedIr() }); + + // A "ghost" task crashed mid-transition into the capacity-1 "build" column: + // its marker reserves the only slot even though it never committed there. + const ghost = await store.createTask({ description: "ghost" }); + await store.selectTaskWorkflowAndReconcile(ghost.id, wf.id); + const ghostMarker = serializeTransitionPending( + makeTransitionPending("build", ["default-workflow:postCommit"], Date.now() - 60_000), + ); + rawDb().prepare(`UPDATE tasks SET transitionPending = ? WHERE id = ?`).run(ghostMarker, ghost.id); + + // A fresh card in the same workflow cannot enter "build": the phantom marker + // is counted as occupying the single capacity slot. + const fresh = await store.createTask({ description: "fresh" }); + await store.selectTaskWorkflowAndReconcile(fresh.id, wf.id); + expect((await store.getTask(fresh.id)).column).toBe("intake"); + + let blocked: unknown; + try { + await store.moveTask(fresh.id, "build", { moveSource: "user" }); + } catch (e) { + blocked = e; + } + expect(blocked).toBeInstanceOf(Error); + expect((await store.getTask(fresh.id)).column).toBe("intake"); + + // Recovery clears the stale marker, releasing the slot. + const result = await store.recoverStaleTransitionPending(); + expect(result.recovered).toBeGreaterThanOrEqual(1); + + // Now the fresh card can enter the capacity column. + await store.moveTask(fresh.id, "build", { moveSource: "user" }); + expect((await store.getTask(fresh.id)).column).toBe("build"); + }); +}); + +describe("#1409 flag ON→OFF evacuation", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + }); + afterEach(async () => { + await harness.afterEach(); + }); + + it("toggling OFF re-homes a card from a custom column to a legacy column; moves work", async () => { + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + const wf = await store.createWorkflowDefinition({ name: "simple-custom", ir: simpleCustomIr() }); + const task = await store.createTask({ description: "evac" }); + await store.selectTaskWorkflowAndReconcile(task.id, wf.id); + expect((await store.getTask(task.id)).column).toBe("intake"); + + // Toggle OFF — evacuation re-homes the card to the nearest legacy column + // (the default workflow's entry column, triage). + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: false } }); + expect((await store.getTask(task.id)).column).toBe("triage"); + + // Board listable; legacy moves work from the evacuated column. + await expect(store.listTasks()).resolves.toBeDefined(); + await store.moveTask(task.id, "todo", { moveSource: "user" }); + await store.moveTask(task.id, "in-progress", { moveSource: "user" }); + expect((await store.getTask(task.id)).column).toBe("in-progress"); + }); + + it("evacuateCustomColumnsToLegacy is idempotent (a second run is a no-op)", async () => { + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + const wf = await store.createWorkflowDefinition({ name: "simple-custom-2", ir: simpleCustomIr() }); + const task = await store.createTask({ description: "evac2" }); + await store.selectTaskWorkflowAndReconcile(task.id, wf.id); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: false } }); + + // First explicit run already evacuated (via the toggle); a fresh run is a no-op. + const again = await store.evacuateCustomColumnsToLegacy("flag-off-init"); + expect(again.evacuated).toBe(0); + expect((await store.getTask(task.id)).column).toBe("triage"); + }); +}); diff --git a/packages/core/src/__tests__/transition-types.test.ts b/packages/core/src/__tests__/transition-types.test.ts new file mode 100644 index 0000000000..6eaad0cc76 --- /dev/null +++ b/packages/core/src/__tests__/transition-types.test.ts @@ -0,0 +1,281 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { Database, SCHEMA_VERSION } from "../db.js"; +import { + TRANSITION_REJECTION_CODES, + type TransitionRejectionCode, + deserializeTransitionPending, + deserializeTransitionRejection, + makeTransitionPending, + makeTransitionRejection, + serializeTransitionPending, + serializeTransitionRejection, + transitionOk, + transitionRejected, +} from "../transition-types.js"; +import { + clearTransitionPending, + reconcileHooksRemaining, + readTransitionPending, + writeTransitionPending, +} from "../transition-pending.js"; + +function makeTmpDir(): string { + return mkdtempSync(join(tmpdir(), "kb-transition-types-")); +} + +describe("TransitionRejection (de)serialization across the API boundary", () => { + it("round-trips every rejection code", () => { + for (const code of TRANSITION_REJECTION_CODES) { + const rejection = makeTransitionRejection(code, `transition.reject.${code}`, code === "capacity-exhausted"); + const wire = serializeTransitionRejection(rejection); + // Wire form is plain JSON — no class instances survive the boundary. + expect(typeof wire).toBe("string"); + const parsedRaw = JSON.parse(wire) as Record; + expect(parsedRaw.code).toBe(code); + const back = deserializeTransitionRejection(wire); + expect(back).toEqual(rejection); + } + }); + + it("round-trips the optional detail field and omits it when absent", () => { + const withDetail = makeTransitionRejection("guard-rejected", "k", false, "guard X said no"); + expect(deserializeTransitionRejection(serializeTransitionRejection(withDetail))).toEqual(withDetail); + + const withoutDetail = makeTransitionRejection("unknown-column", "k", false); + expect("detail" in withoutDetail).toBe(false); + const wire = serializeTransitionRejection(withoutDetail); + expect(JSON.parse(wire)).not.toHaveProperty("detail"); + expect(deserializeTransitionRejection(wire)).toEqual(withoutDetail); + }); + + it("rejects malformed / structurally invalid payloads with null (never throws)", () => { + expect(deserializeTransitionRejection("not json{{")).toBeNull(); + expect(deserializeTransitionRejection("null")).toBeNull(); + expect(deserializeTransitionRejection("42")).toBeNull(); + expect(deserializeTransitionRejection(JSON.stringify({ code: "not-a-code", messageKey: "k", retryable: true }))).toBeNull(); + expect(deserializeTransitionRejection(JSON.stringify({ code: "guard-rejected", retryable: true }))).toBeNull(); + expect(deserializeTransitionRejection(JSON.stringify({ code: "guard-rejected", messageKey: "k", retryable: "yes" }))).toBeNull(); + expect(deserializeTransitionRejection(JSON.stringify({ code: "guard-rejected", messageKey: "k", retryable: true, detail: 7 }))).toBeNull(); + }); + + it("builds discriminated TransitionResult values", () => { + const ok = transitionOk("in-review"); + expect(ok).toEqual({ ok: true, toColumn: "in-review" }); + + const rejection = makeTransitionRejection("merge-blocked", "transition.merge-blocked", true); + const rejected = transitionRejected(rejection); + expect(rejected).toEqual({ ok: false, rejection }); + }); + + it("exposes the full, exhaustive code set", () => { + const expected: TransitionRejectionCode[] = [ + "guard-rejected", + "capacity-exhausted", + "unknown-column", + "workflow-mismatch", + "merge-blocked", + ]; + expect([...TRANSITION_REJECTION_CODES].sort()).toEqual([...expected].sort()); + }); +}); + +describe("TransitionPending (de)serialization", () => { + it("round-trips a marker including hooksRemaining order and startedAt", () => { + const marker = makeTransitionPending("in-progress", ["timing:onEnter", "abort-on-exit:onExit"], 1_700_000_000_000); + const wire = serializeTransitionPending(marker); + expect(deserializeTransitionPending(wire)).toEqual(marker); + }); + + it("copies hooksRemaining so the marker does not alias caller state", () => { + const hooks = ["a", "b"]; + const marker = makeTransitionPending("todo", hooks); + hooks.push("c"); + expect(marker.hooksRemaining).toEqual(["a", "b"]); + }); + + it("drops non-string hook entries defensively and rejects malformed markers", () => { + expect(deserializeTransitionPending("garbage")).toBeNull(); + expect(deserializeTransitionPending(JSON.stringify({ toColumn: "x", startedAt: 1 }))).toBeNull(); + expect(deserializeTransitionPending(JSON.stringify({ toColumn: "x", hooksRemaining: [], startedAt: "soon" }))).toBeNull(); + const recovered = deserializeTransitionPending( + JSON.stringify({ toColumn: "x", hooksRemaining: ["keep", 5, null, "also"], startedAt: 10 }), + ); + expect(recovered).toEqual({ toColumn: "x", hooksRemaining: ["keep", "also"], startedAt: 10 }); + }); +}); + +describe("reconcileHooksRemaining (missing-plugin-hook, U3-level)", () => { + it("keeps known hooks and drops unknown ones with one audit warning each", () => { + const known = new Set(["builtin:timing", "builtin:abort"]); + const result = reconcileHooksRemaining(["builtin:timing", "plugin:gone", "builtin:abort", "plugin:also-gone"], known); + expect(result.hooksRemaining).toEqual(["builtin:timing", "builtin:abort"]); + expect(result.warnings).toHaveLength(2); + expect(result.warnings[0]).toContain("plugin:gone"); + expect(result.warnings[1]).toContain("plugin:also-gone"); + }); + + it("returns no warnings when every hook is known", () => { + const result = reconcileHooksRemaining(["a"], new Set(["a", "b"])); + expect(result).toEqual({ hooksRemaining: ["a"], warnings: [] }); + }); +}); + +describe("transitionPending marker lifecycle (helper-level, U3)", () => { + let tmpDir: string; + let fusionDir: string; + let db: Database; + + beforeEach(() => { + tmpDir = makeTmpDir(); + fusionDir = join(tmpDir, ".fusion"); + db = new Database(fusionDir); + db.init(); + db.exec( + `INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES ('FN-1', 'task', 'todo', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')`, + ); + }); + + afterEach(async () => { + try { + db.close(); + } catch { + // already closed + } + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("set with a move, then cleared after hooks complete", () => { + expect(readTransitionPending(db, "FN-1")).toBeNull(); + + // Simulate the in-txn write that accompanies a column change (U4 wires this): + // the column change and the marker write land in one transaction. + db.exec("BEGIN"); + db.prepare(`UPDATE tasks SET "column" = ? WHERE id = ?`).run("in-progress", "FN-1"); + writeTransitionPending(db, "FN-1", makeTransitionPending("in-progress", ["timing:onEnter"], 1234)); + db.exec("COMMIT"); + + const after = readTransitionPending(db, "FN-1"); + expect(after).toEqual({ toColumn: "in-progress", hooksRemaining: ["timing:onEnter"], startedAt: 1234 }); + const movedRow = db.prepare(`SELECT "column" AS col FROM tasks WHERE id = ?`).get("FN-1") as { col: string }; + expect(movedRow.col).toBe("in-progress"); + + // Post-commit hooks ran -> clear. + clearTransitionPending(db, "FN-1"); + expect(readTransitionPending(db, "FN-1")).toBeNull(); + }); + + it("survives a simulated crash: marker recoverable with hooksRemaining intact", () => { + writeTransitionPending(db, "FN-1", makeTransitionPending("in-review", ["merge:onEnter", "stall:onEnter"], 999)); + db.close(); + + // Re-open as a fresh handle (the post-commit hook runner never ran -> crash). + const reopened = new Database(fusionDir); + reopened.init(); + const recovered = readTransitionPending(reopened, "FN-1"); + expect(recovered).toEqual({ toColumn: "in-review", hooksRemaining: ["merge:onEnter", "stall:onEnter"], startedAt: 999 }); + reopened.close(); + db = new Database(fusionDir); + db.init(); + }); + + it("reads back exclusively from the SQLite row (authoritative store, ADR-0001)", () => { + // The helper only ever consults the SQLite tasks row; there is no task.json + // read path. Writing the marker and reading it through a brand-new handle + // proves SQLite is the single source of truth. + writeTransitionPending(db, "FN-1", makeTransitionPending("done", ["complete:onEnter"], 5)); + db.close(); + const fresh = new Database(fusionDir); + fresh.init(); + expect(readTransitionPending(fresh, "FN-1")).toEqual({ + toColumn: "done", + hooksRemaining: ["complete:onEnter"], + startedAt: 5, + }); + fresh.close(); + db = new Database(fusionDir); + db.init(); + }); + + it("returns undefined for a missing task and null for a corrupt marker", () => { + expect(readTransitionPending(db, "FN-nonexistent")).toBeUndefined(); + db.prepare(`UPDATE tasks SET transitionPending = ? WHERE id = ?`).run("not json{{", "FN-1"); + expect(readTransitionPending(db, "FN-1")).toBeNull(); + }); +}); + +describe("tasks.transitionPending migration (106)", () => { + let tmpDir: string; + let fusionDir: string; + + beforeEach(() => { + tmpDir = makeTmpDir(); + fusionDir = join(tmpDir, ".fusion"); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it("adds the column when migrating a pre-106 tasks table, leaving existing rows NULL", () => { + const db = new Database(fusionDir); + db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)"); + db.exec(` + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + description TEXT NOT NULL, + "column" TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ) + `); + db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '105')"); + db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')"); + db.exec( + `INSERT INTO tasks (id, description, "column", createdAt, updatedAt) VALUES ('FN-legacy', 'legacy', 'todo', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z')`, + ); + + db.init(); + + const columns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; + expect(columns.map((c) => c.name)).toContain("transitionPending"); + + const row = db.prepare("SELECT transitionPending FROM tasks WHERE id = 'FN-legacy'").get() as { + transitionPending: string | null; + }; + expect(row.transitionPending).toBeNull(); + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + + db.close(); + }); + + it("is idempotent: running init twice does not error and stays at the current version", () => { + const db = new Database(fusionDir); + db.init(); + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + // Second init on the same DB is a no-op (version already current). + expect(() => db.init()).not.toThrow(); + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + const columns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; + expect(columns.filter((c) => c.name === "transitionPending")).toHaveLength(1); + db.close(); + + // Re-open + init a third time on the persisted DB. + const reopened = new Database(fusionDir); + expect(() => reopened.init()).not.toThrow(); + expect(reopened.getSchemaVersion()).toBe(SCHEMA_VERSION); + reopened.close(); + }); + + it("is a no-op on a fresh DB: column present from the base CREATE TABLE", () => { + const db = new Database(fusionDir); + db.init(); + const columns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; + expect(columns.map((c) => c.name)).toContain("transitionPending"); + expect(db.getSchemaVersion()).toBe(SCHEMA_VERSION); + db.close(); + }); +}); diff --git a/packages/core/src/__tests__/workflow-definition-store.test.ts b/packages/core/src/__tests__/workflow-definition-store.test.ts index 27f17e95fa..7d78accce5 100644 --- a/packages/core/src/__tests__/workflow-definition-store.test.ts +++ b/packages/core/src/__tests__/workflow-definition-store.test.ts @@ -66,6 +66,60 @@ describe("TaskStore workflow definitions (U1)", () => { ).rejects.toThrow(/name is required/i); }); + describe("rollback compat — v1/v2 persistence (#1405)", () => { + function rawIr(id: string): { version: string } { + const row = (store as any).db + .prepare("SELECT ir FROM workflows WHERE id = ?") + .get(id) as { ir: string }; + return JSON.parse(row.ir); + } + + // A pure-v1 graph: only v1 node kinds, default columns at default placement. + const pureV1 = (): WorkflowIr => makeIr(); + + // A v2 graph using a custom column (a genuine v2 feature). + const v2Custom = (): WorkflowIr => + ({ + version: "v2", + name: "v2-feature", + columns: [ + { id: "triage", name: "triage", traits: [] }, + { id: "todo", name: "todo", traits: [] }, + { id: "in-progress", name: "in-progress", traits: [] }, + { id: "in-review", name: "in-review", traits: [] }, + { id: "done", name: "done", traits: [] }, + { id: "archived", name: "archived", traits: [] }, + { id: "review-queue", name: "Review Queue", traits: [] }, + ], + nodes: [ + { id: "start", kind: "start", column: "todo" }, + { id: "end", kind: "end", column: "todo" }, + ], + edges: [{ from: "start", to: "end" }], + }) as unknown as WorkflowIr; + + it("flag OFF: a pure-v1 workflow persists in the v1 shape on create and update", async () => { + const created = await store.createWorkflowDefinition({ name: "Pure", ir: pureV1() }); + expect(rawIr(created.id).version).toBe("v1"); + await store.updateWorkflowDefinition(created.id, { description: "edit", ir: pureV1() }); + expect(rawIr(created.id).version).toBe("v1"); + // Read-path still resolves it as the upgraded v2 in-memory shape. + const reloaded = await store.getWorkflowDefinition(created.id); + expect(reloaded?.ir.version).toBe("v2"); + }); + + it("flag OFF: a v2-feature workflow persists as v2 regardless", async () => { + const created = await store.createWorkflowDefinition({ name: "Feat", ir: v2Custom() }); + expect(rawIr(created.id).version).toBe("v2"); + }); + + it("flag ON: a pure-v1 workflow persists as v2", async () => { + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + const created = await store.createWorkflowDefinition({ name: "OnFlag", ir: pureV1() }); + expect(rawIr(created.id).version).toBe("v2"); + }); + }); + it("updates name, description, IR, and layout and advances updatedAt", async () => { const created = await store.createWorkflowDefinition({ name: "V1", ir: makeIr() }); await new Promise((r) => setTimeout(r, 2)); diff --git a/packages/core/src/__tests__/workflow-ir-resolver.test.ts b/packages/core/src/__tests__/workflow-ir-resolver.test.ts new file mode 100644 index 0000000000..e4d96cf635 --- /dev/null +++ b/packages/core/src/__tests__/workflow-ir-resolver.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi } from "vitest"; +import { getBuiltinWorkflow } from "../builtin-workflows.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; +import type { WorkflowIr } from "../workflow-ir-types.js"; +import { + resolveWorkflowIrForTask, + resolveWorkflowIrById, +} from "../workflow-ir-resolver.js"; + +/** A minimal custom IR distinguishable from the built-in default. */ +const CUSTOM_IR: WorkflowIr = { + version: "v2", + name: "custom-flow", + nodes: [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, + ], + edges: [{ from: "start", to: "end" }], + columns: [{ id: "todo", name: "Todo", traits: [] }], +} as unknown as WorkflowIr; + +function makeStore(opts: { + selection?: { workflowId: string; stepIds: string[] }; + selectionThrows?: boolean; + defs?: Record; +}) { + const getWorkflowDefinition = vi.fn(async (id: string) => opts.defs?.[id]); + const getTaskWorkflowSelection = vi.fn((_taskId: string) => { + if (opts.selectionThrows) throw new Error("boom"); + return opts.selection; + }); + return { getWorkflowDefinition, getTaskWorkflowSelection }; +} + +describe("resolveWorkflowIrForTask", () => { + it("resolves a selection pointing at a custom definition", async () => { + const store = makeStore({ + selection: { workflowId: "wf-custom", stepIds: [] }, + defs: { "wf-custom": { ir: CUSTOM_IR } }, + }); + const ir = await resolveWorkflowIrForTask(store, "t1"); + expect(ir).toBe(CUSTOM_IR); + expect(store.getWorkflowDefinition).toHaveBeenCalledWith("wf-custom"); + }); + + it("resolves a built-in workflow id without touching getWorkflowDefinition", async () => { + const store = makeStore({ + selection: { workflowId: "builtin:quick-fix", stepIds: [] }, + }); + const ir = await resolveWorkflowIrForTask(store, "t1"); + expect(ir).toEqual(getBuiltinWorkflow("builtin:quick-fix")!.ir); + expect(store.getWorkflowDefinition).not.toHaveBeenCalled(); + }); + + it("falls back to the built-in default when the definition is missing", async () => { + const store = makeStore({ + selection: { workflowId: "wf-gone", stepIds: [] }, + defs: { "wf-gone": undefined }, + }); + const ir = await resolveWorkflowIrForTask(store, "t1"); + expect(ir).toBe(BUILTIN_CODING_WORKFLOW_IR); + }); + + it("falls back to the default when there is no selection", async () => { + const store = makeStore({ selection: undefined }); + const ir = await resolveWorkflowIrForTask(store, "t1"); + expect(ir).toBe(BUILTIN_CODING_WORKFLOW_IR); + expect(store.getWorkflowDefinition).not.toHaveBeenCalled(); + }); + + it("degrades to the default when the selection lookup throws", async () => { + const store = makeStore({ selectionThrows: true }); + const ir = await resolveWorkflowIrForTask(store, "t1"); + expect(ir).toBe(BUILTIN_CODING_WORKFLOW_IR); + }); + + it("caches by workflowId so the definition is fetched once across calls", async () => { + const store = makeStore({ + selection: { workflowId: "wf-custom", stepIds: [] }, + defs: { "wf-custom": { ir: CUSTOM_IR } }, + }); + const cache = new Map(); + const a = await resolveWorkflowIrForTask(store, "t1", cache); + const b = await resolveWorkflowIrForTask(store, "t2", cache); + expect(a).toBe(CUSTOM_IR); + expect(b).toBe(CUSTOM_IR); + expect(store.getWorkflowDefinition).toHaveBeenCalledTimes(1); + }); +}); + +describe("resolveWorkflowIrById", () => { + it("parses a raw-string IR from the definition", async () => { + const raw = JSON.stringify(CUSTOM_IR); + const store = makeStore({ defs: { "wf-raw": { ir: raw } } }); + const ir = await resolveWorkflowIrById(store, "wf-raw"); + expect(ir.version).toBe("v2"); + expect(ir.name).toBe("custom-flow"); + }); + + it("returns a cache hit without re-fetching the definition", async () => { + const store = makeStore({ defs: { "wf-custom": { ir: CUSTOM_IR } } }); + const cache = new Map(); + await resolveWorkflowIrById(store, "wf-custom", cache); + await resolveWorkflowIrById(store, "wf-custom", cache); + expect(store.getWorkflowDefinition).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/__tests__/workflow-ir.test.ts b/packages/core/src/__tests__/workflow-ir.test.ts new file mode 100644 index 0000000000..017678b613 --- /dev/null +++ b/packages/core/src/__tests__/workflow-ir.test.ts @@ -0,0 +1,446 @@ +import { describe, expect, it } from "vitest"; +import { + parseWorkflowIr, + serializeWorkflowIr, + downgradeIrToV1IfPure, + WorkflowIrError, + DEFAULT_WORKFLOW_COLUMN_IDS, +} from "../workflow-ir.js"; +import type { + WorkflowIr, + WorkflowIrV1, + WorkflowIrV2, + WorkflowIrNode, + WorkflowIrEdge, +} from "../workflow-ir-types.js"; + +function v2( + columns: WorkflowIrV2["columns"], + nodes: WorkflowIrNode[], + edges: WorkflowIrEdge[], +): WorkflowIrV2 { + return { version: "v2", name: "test", columns, nodes, edges }; +} + +const startEnd: WorkflowIrNode[] = [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, +]; + +describe("parseWorkflowIr — v2 columns & placement", () => { + it("parses a v2 graph with columns, placement and a hold node", () => { + const ir = v2( + [ + { id: "intake", name: "Intake", traits: [{ trait: "intake" }] }, + { id: "work", name: "Work", traits: [] }, + ], + [ + { id: "start", kind: "start", column: "intake" }, + { id: "wait", kind: "hold", column: "intake", config: { release: "manual" } }, + { id: "end", kind: "end", column: "work" }, + ], + [ + { from: "start", to: "wait" }, + { from: "wait", to: "end" }, + ], + ); + const parsed = parseWorkflowIr(ir); + expect(parsed.version).toBe("v2"); + expect(parsed).toEqual(ir); + }); + + it("rejects a node referencing an undefined column id", () => { + const ir = v2( + [{ id: "only", name: "Only", traits: [] }], + [ + { id: "start", kind: "start", column: "only" }, + { id: "end", kind: "end", column: "ghost" }, + ], + [{ from: "start", to: "end" }], + ); + expect(() => parseWorkflowIr(ir)).toThrow(WorkflowIrError); + expect(() => parseWorkflowIr(ir)).toThrow(/undefined column 'ghost'/); + }); + + it("rejects duplicate column ids within a workflow", () => { + const ir = v2( + [ + { id: "dup", name: "A", traits: [] }, + { id: "dup", name: "B", traits: [] }, + ], + startEnd, + [{ from: "start", to: "end" }], + ); + expect(() => parseWorkflowIr(ir)).toThrow(/duplicate column id 'dup'/); + }); +}); + +describe("parseWorkflowIr — v1 upgrade", () => { + const v1: WorkflowIrV1 = { + version: "v1", + name: "legacy", + nodes: [ + { id: "start", kind: "start" }, + { id: "execute", kind: "prompt", config: { seam: "execute" } }, + { id: "review", kind: "prompt", config: { seam: "review" } }, + { id: "merge", kind: "prompt", config: { seam: "merge" } }, + { id: "custom", kind: "prompt", config: { name: "Plan" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "execute" }, + { from: "execute", to: "review", condition: "success" }, + { from: "review", to: "merge", condition: "success" }, + { from: "merge", to: "custom", condition: "success" }, + { from: "custom", to: "end" }, + ], + }; + + it("upgrades a v1 graph to v2 with synthesized default columns", () => { + const parsed = parseWorkflowIr(v1); + expect(parsed.version).toBe("v2"); + if (parsed.version !== "v2") throw new Error("expected v2"); + expect(parsed.columns.map((c) => c.id)).toEqual([...DEFAULT_WORKFLOW_COLUMN_IDS]); + }); + + it("places nodes by seam (execute→in-progress, review/merge→in-review, others→todo)", () => { + const parsed = parseWorkflowIr(v1); + if (parsed.version !== "v2") throw new Error("expected v2"); + const byId = new Map(parsed.nodes.map((n) => [n.id, n])); + expect(byId.get("execute")?.column).toBe("in-progress"); + expect(byId.get("review")?.column).toBe("in-review"); + expect(byId.get("merge")?.column).toBe("in-review"); + expect(byId.get("custom")?.column).toBe("todo"); + expect(byId.get("start")?.column).toBe("todo"); + }); + + it("upgrade is idempotent (round-trips through serialize unchanged)", () => { + const once = parseWorkflowIr(v1); + const twice = parseWorkflowIr(serializeWorkflowIr(once)); + expect(twice).toEqual(once); + }); + + it("v1 fixtures still parse (back-compat)", () => { + const minimal: WorkflowIr = { + version: "v1", + name: "min", + nodes: startEnd, + edges: [{ from: "start", to: "end" }], + }; + expect(() => parseWorkflowIr(minimal)).not.toThrow(); + }); +}); + +describe("downgradeIrToV1IfPure — rollback compat (#1405)", () => { + const pureV1: WorkflowIrV1 = { + version: "v1", + name: "legacy", + nodes: [ + { id: "start", kind: "start" }, + { id: "execute", kind: "prompt", config: { seam: "execute" } }, + { id: "review", kind: "prompt", config: { seam: "review" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "execute" }, + { from: "execute", to: "review", condition: "success" }, + { from: "review", to: "end", condition: "success" }, + ], + }; + + it("downgrades an upgraded pure-v1 graph back to the v1 shape", () => { + const upgraded = parseWorkflowIr(pureV1); + expect(upgraded.version).toBe("v2"); + const down = downgradeIrToV1IfPure(upgraded); + expect(down.version).toBe("v1"); + // No synthesized `column` fields leak into the v1 shape. + expect(down.nodes.every((n) => n.column === undefined)).toBe(true); + // Lossless: a v2 binary re-upgrades it to the identical v2 graph. + expect(parseWorkflowIr(serializeWorkflowIr(down))).toEqual(upgraded); + }); + + it("pre-v2 binaries (version-only guard) accept the downgraded shape", () => { + const down = downgradeIrToV1IfPure(parseWorkflowIr(pureV1)); + expect(down.version).toBe("v1"); + // Simulate the pre-v2 hard reject of version !== 'v1'. + expect(() => { + if (down.version !== "v1") throw new WorkflowIrError("unsupported version"); + }).not.toThrow(); + }); + + it("keeps v2 when a v2-only node kind is present", () => { + const ir = v2( + DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id, traits: [] })), + [ + { id: "start", kind: "start", column: "todo" }, + { id: "wait", kind: "hold", column: "todo", config: { release: "manual" } }, + { id: "end", kind: "end", column: "todo" }, + ], + [ + { from: "start", to: "wait" }, + { from: "wait", to: "end" }, + ], + ); + expect(downgradeIrToV1IfPure(parseWorkflowIr(ir)).version).toBe("v2"); + }); + + it("keeps v2 when columns are customized (rename / extra / applied trait)", () => { + const customName = v2( + DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id === "todo" ? "Backlog" : id, traits: [] })), + [ + { id: "start", kind: "start", column: "todo" }, + { id: "end", kind: "end", column: "todo" }, + ], + [{ from: "start", to: "end" }], + ); + expect(downgradeIrToV1IfPure(parseWorkflowIr(customName)).version).toBe("v2"); + + const withTrait = v2( + DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ + id, + name: id, + traits: id === "todo" ? [{ trait: "intake" }] : [], + })), + [ + { id: "start", kind: "start", column: "todo" }, + { id: "end", kind: "end", column: "todo" }, + ], + [{ from: "start", to: "end" }], + ); + expect(downgradeIrToV1IfPure(parseWorkflowIr(withTrait)).version).toBe("v2"); + }); + + it("keeps v2 when a node is placed off its default seam column", () => { + const custom = v2( + DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id, traits: [] })), + [ + { id: "start", kind: "start", column: "todo" }, + // execute seam defaults to in-progress; place it in done instead. + { id: "exec", kind: "prompt", column: "done", config: { seam: "execute" } }, + { id: "end", kind: "end", column: "todo" }, + ], + [ + { from: "start", to: "exec" }, + { from: "exec", to: "end" }, + ], + ); + expect(downgradeIrToV1IfPure(parseWorkflowIr(custom)).version).toBe("v2"); + }); + + it("returns a v1 input unchanged", () => { + expect(downgradeIrToV1IfPure(pureV1)).toBe(pureV1); + }); +}); + +describe("parseWorkflowIr — hold release kinds", () => { + const holdCols = [{ id: "c", name: "C", traits: [] }]; + function holdIr(release: unknown): WorkflowIrV2 { + return v2( + holdCols, + [ + { id: "start", kind: "start", column: "c" }, + { id: "h", kind: "hold", column: "c", config: { release } }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "h" }, + { from: "h", to: "end" }, + ], + ); + } + + it.each(["manual", "timer", "capacity", "dependency", "external-event"])( + "accepts hold release '%s'", + (release) => { + expect(() => parseWorkflowIr(holdIr(release))).not.toThrow(); + }, + ); + + it("rejects an unknown hold release kind", () => { + expect(() => parseWorkflowIr(holdIr("teleport"))).toThrow(/unknown release kind 'teleport'/); + }); + + it("rejects a hold node missing its release config", () => { + expect(() => parseWorkflowIr(holdIr(undefined))).toThrow(/unknown release kind/); + }); +}); + +describe("parseWorkflowIr — split/join parallelism (KTD-11)", () => { + const cols = [{ id: "c", name: "C", traits: [] }]; + + function p(nodes: WorkflowIrNode[], edges: WorkflowIrEdge[]): WorkflowIrV2 { + return v2(cols, nodes, edges); + } + + it("parses a balanced split → two branches → join", () => { + const ir = p( + [ + { id: "start", kind: "start", column: "c" }, + { id: "split", kind: "split", column: "c" }, + { id: "a", kind: "prompt", column: "c" }, + { id: "b", kind: "prompt", column: "c" }, + { id: "join", kind: "join", column: "c", config: { mode: "all" } }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "split" }, + { from: "split", to: "a" }, + { from: "split", to: "b" }, + { from: "a", to: "join" }, + { from: "b", to: "join" }, + { from: "join", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).not.toThrow(); + }); + + it("parses one nested level of split/join", () => { + const ir = p( + [ + { id: "start", kind: "start", column: "c" }, + { id: "s1", kind: "split", column: "c" }, + { id: "a", kind: "prompt", column: "c" }, + { id: "s2", kind: "split", column: "c" }, + { id: "n1", kind: "prompt", column: "c" }, + { id: "n2", kind: "prompt", column: "c" }, + { id: "j2", kind: "join", column: "c", config: { mode: "all" } }, + { id: "j1", kind: "join", column: "c", config: { mode: "all" } }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "s1" }, + { from: "s1", to: "a" }, + { from: "s1", to: "s2" }, + { from: "a", to: "j1" }, + { from: "s2", to: "n1" }, + { from: "s2", to: "n2" }, + { from: "n1", to: "j2" }, + { from: "n2", to: "j2" }, + { from: "j2", to: "j1" }, + { from: "j1", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).not.toThrow(); + }); + + it("rejects a split without a reachable matching join", () => { + const ir = p( + [ + { id: "start", kind: "start", column: "c" }, + { id: "split", kind: "split", column: "c" }, + { id: "a", kind: "prompt", column: "c" }, + { id: "b", kind: "prompt", column: "c" }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "split" }, + { from: "split", to: "a" }, + { from: "split", to: "b" }, + { from: "a", to: "end" }, + { from: "b", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).toThrow(/no reachable matching join/); + }); + + it("rejects an execute seam node inside a branch (seam-in-branch)", () => { + const ir = p( + [ + { id: "start", kind: "start", column: "c" }, + { id: "split", kind: "split", column: "c" }, + { id: "exec", kind: "prompt", column: "c", config: { seam: "execute" } }, + { id: "b", kind: "prompt", column: "c" }, + { id: "join", kind: "join", column: "c", config: { mode: "all" } }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "split" }, + { from: "split", to: "exec" }, + { from: "split", to: "b" }, + { from: "exec", to: "join" }, + { from: "b", to: "join" }, + { from: "join", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).toThrow(/seam 'execute'.*forbidden inside a parallel branch/); + }); + + it("rejects a merge seam node inside a branch (seam-in-branch)", () => { + const ir = p( + [ + { id: "start", kind: "start", column: "c" }, + { id: "split", kind: "split", column: "c" }, + { id: "mg", kind: "prompt", column: "c", config: { seam: "merge" } }, + { id: "b", kind: "prompt", column: "c" }, + { id: "join", kind: "join", column: "c", config: { mode: "all" } }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "split" }, + { from: "split", to: "mg" }, + { from: "split", to: "b" }, + { from: "mg", to: "join" }, + { from: "b", to: "join" }, + { from: "join", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).toThrow(/seam 'merge'.*forbidden inside a parallel branch/); + }); + + it("rejects quorum(n) with n exceeding the branch count", () => { + const ir = p( + [ + { id: "start", kind: "start", column: "c" }, + { id: "split", kind: "split", column: "c" }, + { id: "a", kind: "prompt", column: "c" }, + { id: "b", kind: "prompt", column: "c" }, + { id: "join", kind: "join", column: "c", config: { mode: { quorum: 3 } } }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "split" }, + { from: "split", to: "a" }, + { from: "split", to: "b" }, + { from: "a", to: "join" }, + { from: "b", to: "join" }, + { from: "join", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).toThrow(/quorum\(3\) exceeds the split's 2 branches/); + }); + + it("accepts quorum(n) with n within the branch count", () => { + const ir = p( + [ + { id: "start", kind: "start", column: "c" }, + { id: "split", kind: "split", column: "c" }, + { id: "a", kind: "prompt", column: "c" }, + { id: "b", kind: "prompt", column: "c" }, + { id: "join", kind: "join", column: "c", config: { mode: { quorum: 2 } } }, + { id: "end", kind: "end", column: "c" }, + ], + [ + { from: "start", to: "split" }, + { from: "split", to: "a" }, + { from: "split", to: "b" }, + { from: "a", to: "join" }, + { from: "b", to: "join" }, + { from: "join", to: "end" }, + ], + ); + expect(() => parseWorkflowIr(ir)).not.toThrow(); + }); +}); + +describe("parseWorkflowIr — version & shape guards", () => { + it("rejects an unknown version", () => { + expect(() => parseWorkflowIr({ version: "v3", name: "x", nodes: startEnd, edges: [] } as unknown as WorkflowIr)).toThrow( + /version must be v1 or v2/, + ); + }); + + it("rejects missing start/end nodes", () => { + const ir = v2([{ id: "c", name: "C", traits: [] }], [{ id: "start", kind: "start", column: "c" }], []); + expect(() => parseWorkflowIr(ir)).toThrow(/exactly one start and one end/); + }); +}); diff --git a/packages/core/src/__tests__/workflow-reconciliation.test.ts b/packages/core/src/__tests__/workflow-reconciliation.test.ts new file mode 100644 index 0000000000..a065cc7d45 --- /dev/null +++ b/packages/core/src/__tests__/workflow-reconciliation.test.ts @@ -0,0 +1,296 @@ +// @vitest-environment node +// +// U5: workflow lifecycle reconciliation — switch / edit / delete with live cards +// (R15, R20). Covers every U5 plan scenario: +// - switch with a same-id column preserves position; +// - switch without one re-homes to the new workflow's entry column AND fires +// the injected abort callback; +// - edit removing an occupied column blocks with per-column occupant counts; +// - the rehomeTo option saves + re-homes all occupants, one audit per card; +// - delete with occupants re-homes to the DEFAULT entry, clears selection, +// preserves task fields; +// - property-style invariant: after any switch/edit/delete sequence every +// task's column exists in its resolved workflow; +// - concurrent move-vs-delete under the task lock ends moved-then-re-homed or +// re-homed, never lost/undefined. + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createTaskStoreTestHarness } from "./store-test-helpers.js"; +import type { WorkflowIr } from "../workflow-ir-types.js"; +import { + OccupiedColumnsError, + setReconciliationAbort, + __resetReconciliationAbortForTests, + type ReconciliationAbortContext, +} from "../workflow-reconciliation.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "../builtin-coding-workflow-ir.js"; +import { resolveEntryColumnId } from "../workflow-reconciliation.js"; + +/** A v2 custom workflow with columns whose ids we control. `entryId` carries the + * intake flag; `cols` lists the column ids in order. Linear graph so it + * compiles. */ +function customIr(name: string, cols: string[], entryId: string): WorkflowIr { + return { + version: "v2", + name, + columns: cols.map((id) => ({ + id, + name: id, + traits: id === entryId ? [{ trait: "intake" }] : [], + })), + nodes: [ + { id: "start", kind: "start", column: entryId }, + { id: "work", kind: "prompt", column: cols[1] ?? entryId, config: { prompt: "do" } }, + { id: "end", kind: "end", column: cols[cols.length - 1] }, + ], + edges: [ + { from: "start", to: "work", condition: "success" }, + { from: "work", to: "end", condition: "success" }, + ], + }; +} + +describe("workflow reconciliation (U5)", () => { + const harness = createTaskStoreTestHarness(); + let store: ReturnType; + + beforeEach(async () => { + await harness.beforeEach(); + store = harness.store(); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + __resetReconciliationAbortForTests(); + }); + + afterEach(async () => { + __resetReconciliationAbortForTests(); + await harness.afterEach(); + }); + + /** Move a fresh task (starts in triage) to a default-workflow column. */ + async function seedInColumn(col: "triage" | "todo" | "in-progress"): Promise { + const task = await store.createTask({ description: `seed-${col}` }); + if (col === "triage") return task.id; + await store.moveTask(task.id, "todo", { moveSource: "user" }); + if (col === "todo") return task.id; + await store.moveTask(task.id, "in-progress", { moveSource: "user" }); + return task.id; + } + + it("entry column resolves to the intake-flagged column (default workflow = triage)", () => { + expect(resolveEntryColumnId(BUILTIN_CODING_WORKFLOW_IR)).toBe("triage"); + }); + + describe("(a) workflow switch", () => { + it("preserves position when the new workflow defines the same column id", async () => { + // Custom workflow that ALSO defines "todo" → same-id column, preserved. + const wf = await store.createWorkflowDefinition({ + name: "shares-todo", + ir: customIr("shares-todo", ["todo", "build", "done"], "todo"), + }); + const taskId = await seedInColumn("todo"); + + const result = await store.selectTaskWorkflowAndReconcile(taskId, wf.id); + + expect(result.reconciliation?.preserved).toBe(true); + expect(result.reconciliation?.toColumn).toBe("todo"); + const task = await store.getTask(taskId); + expect(task.column).toBe("todo"); + }); + + it("re-homes to the new workflow's entry column when the current column is absent, aborting first", async () => { + const aborts: ReconciliationAbortContext[] = []; + setReconciliationAbort((ctx) => { + aborts.push(ctx); + }); + // Custom workflow has none of the legacy column ids; entry = "intake". + const wf = await store.createWorkflowDefinition({ + name: "fresh", + ir: customIr("fresh", ["intake", "doing", "finished"], "intake"), + }); + const taskId = await seedInColumn("in-progress"); + + const result = await store.selectTaskWorkflowAndReconcile(taskId, wf.id); + + expect(result.reconciliation?.preserved).toBe(false); + expect(result.reconciliation?.toColumn).toBe("intake"); + const task = await store.getTask(taskId); + expect(task.column).toBe("intake"); + // Abort callback fired for the in-flight column before the re-home move. + expect(aborts).toHaveLength(1); + expect(aborts[0]).toMatchObject({ taskId, fromColumn: "in-progress", reason: "workflow-switch" }); + }); + + it("re-homes via the default no-op abort when no engine abort is wired", async () => { + const wf = await store.createWorkflowDefinition({ + name: "fresh2", + ir: customIr("fresh2", ["intake", "doing", "finished"], "intake"), + }); + const taskId = await seedInColumn("in-progress"); + const result = await store.selectTaskWorkflowAndReconcile(taskId, wf.id); + expect(result.reconciliation?.preserved).toBe(false); + expect((await store.getTask(taskId)).column).toBe("intake"); + }); + }); + + describe("(b) workflow edit removing an occupied column", () => { + it("blocks with per-column occupant counts when no rehomeTo is given", async () => { + const wf = await store.createWorkflowDefinition({ + name: "editable", + ir: customIr("editable", ["intake", "build", "done"], "intake"), + }); + const t1 = await store.createTask({ description: "t1" }); + const t2 = await store.createTask({ description: "t2" }); + await store.selectTaskWorkflowAndReconcile(t1.id, wf.id); // lands in intake + await store.selectTaskWorkflowAndReconcile(t2.id, wf.id); + // Move both into "build" so it's occupied. Custom adjacency is order-derived + // (intake↔build↔done), so intake→build is legal. + await store.moveTask(t1.id, "build", { moveSource: "user" }); + await store.moveTask(t2.id, "build", { moveSource: "user" }); + + // Edit that drops "build". + const nextIr = customIr("editable", ["intake", "done"], "intake"); + await expect(store.updateWorkflowDefinition(wf.id, { ir: nextIr })).rejects.toThrow( + OccupiedColumnsError, + ); + try { + await store.updateWorkflowDefinition(wf.id, { ir: nextIr }); + } catch (err) { + expect(err).toBeInstanceOf(OccupiedColumnsError); + const occ = (err as OccupiedColumnsError).occupancies; + expect(occ).toEqual([{ columnId: "build", count: 2 }]); + } + }); + + it("rehomeTo saves the edit and moves all occupants, emitting one audit per card", async () => { + const wf = await store.createWorkflowDefinition({ + name: "rehomeable", + ir: customIr("rehomeable", ["intake", "build", "done"], "intake"), + }); + const t1 = await store.createTask({ description: "t1" }); + const t2 = await store.createTask({ description: "t2" }); + await store.selectTaskWorkflowAndReconcile(t1.id, wf.id); + await store.selectTaskWorkflowAndReconcile(t2.id, wf.id); + await store.moveTask(t1.id, "build", { moveSource: "user" }); + await store.moveTask(t2.id, "build", { moveSource: "user" }); + + const nextIr = customIr("rehomeable", ["intake", "done"], "intake"); + const saved = await store.updateWorkflowDefinition(wf.id, { ir: nextIr, rehomeTo: "intake" }); + + // Saved IR no longer defines "build". + expect((saved.ir as { columns: { id: string }[] }).columns.map((c) => c.id)).toEqual([ + "intake", + "done", + ]); + expect((await store.getTask(t1.id)).column).toBe("intake"); + expect((await store.getTask(t2.id)).column).toBe("intake"); + }); + + it("does not block when the removed column has no occupants", async () => { + const wf = await store.createWorkflowDefinition({ + name: "empty-col", + ir: customIr("empty-col", ["intake", "build", "done"], "intake"), + }); + const nextIr = customIr("empty-col", ["intake", "done"], "intake"); + await expect(store.updateWorkflowDefinition(wf.id, { ir: nextIr })).resolves.toBeDefined(); + }); + }); + + describe("(c) workflow delete with occupants", () => { + it("re-homes occupants to the default entry, clears selection, preserves fields", async () => { + const wf = await store.createWorkflowDefinition({ + name: "doomed", + ir: customIr("doomed", ["intake", "build", "done"], "intake"), + }); + const t = await store.createTask({ description: "to-rehome" }); + await store.selectTaskWorkflowAndReconcile(t.id, wf.id); + await store.moveTask(t.id, "build", { moveSource: "user" }); + // Stamp a field we expect to survive the re-home (preserveProgress). + await store.updateTask(t.id, { summary: "keep me" }); + + await store.deleteWorkflowDefinition(wf.id); + + const task = await store.getTask(t.id); + // Re-homed to the default workflow's entry column (triage). + expect(task.column).toBe("triage"); + // Selection cleared → resolves to the default workflow now. + expect(store.getTaskWorkflowSelection(t.id)).toBeUndefined(); + // Field preserved. + expect(task.summary).toBe("keep me"); + }); + + it("built-in workflows remain undeletable", async () => { + await expect(store.deleteWorkflowDefinition("builtin:coding")).rejects.toThrow(); + }); + }); + + describe("property-style invariant: no card in an undefined column after any op", () => { + it("every task's column exists in its resolved workflow after switch/edit/delete", async () => { + const wfA = await store.createWorkflowDefinition({ + name: "A", + ir: customIr("A", ["intake", "mid", "out"], "intake"), + }); + const wfB = await store.createWorkflowDefinition({ + name: "B", + ir: customIr("B", ["start-b", "end-b"], "start-b"), + }); + + const ids: string[] = []; + for (let i = 0; i < 4; i++) { + const t = await store.createTask({ description: `prop-${i}` }); + ids.push(t.id); + } + // Switch all to A, scatter into A's columns. + for (const id of ids) await store.selectTaskWorkflowAndReconcile(id, wfA.id); + await store.moveTask(ids[1], "mid", { moveSource: "user" }); + await store.moveTask(ids[2], "mid", { moveSource: "user" }); + await store.moveTask(ids[2], "out", { moveSource: "user" }); + // Switch one to B (different ids → re-home to entry). + await store.selectTaskWorkflowAndReconcile(ids[3], wfB.id); + // Edit A removing "mid" with rehome. + await store.updateWorkflowDefinition(wfA.id, { + ir: customIr("A", ["intake", "out"], "intake"), + rehomeTo: "intake", + }); + // Delete B (re-homes ids[3] to default). + await store.deleteWorkflowDefinition(wfB.id); + + for (const id of ids) { + const task = await store.getTask(id); + const ir = (store as unknown as { resolveTaskWorkflowIrSync: (id: string) => WorkflowIr }) + .resolveTaskWorkflowIrSync(id); + const colIds = (ir as { columns: { id: string }[] }).columns.map((c) => c.id); + expect(colIds).toContain(task.column); + } + }); + }); + + describe("concurrent move-vs-delete under the task lock", () => { + it("ends moved-then-re-homed or re-homed, never lost/undefined", async () => { + const wf = await store.createWorkflowDefinition({ + name: "race", + ir: customIr("race", ["intake", "build", "done"], "intake"), + }); + const t = await store.createTask({ description: "racer" }); + await store.selectTaskWorkflowAndReconcile(t.id, wf.id); + await store.moveTask(t.id, "build", { moveSource: "user" }); + + // Fire a same-workflow move concurrently with the delete. Both serialize + // through the task lock; the task must end in a column defined by its + // resolved workflow (after delete: the default workflow), never undefined. + const movePromise = store + .moveTask(t.id, "done", { moveSource: "user" }) + .catch(() => undefined); + const deletePromise = store.deleteWorkflowDefinition(wf.id); + await Promise.all([movePromise, deletePromise]); + + const task = await store.getTask(t.id); + expect(task.column).toBeTruthy(); + // After delete the task resolves to the default workflow; its column must + // be one the default workflow defines. + const defaultCols = (BUILTIN_CODING_WORKFLOW_IR as { columns: { id: string }[] }).columns.map( + (c) => c.id, + ); + expect(defaultCols).toContain(task.column); + }); + }); +}); diff --git a/packages/core/src/builtin-coding-workflow-ir.ts b/packages/core/src/builtin-coding-workflow-ir.ts index 663dc85a4b..381d78156e 100644 --- a/packages/core/src/builtin-coding-workflow-ir.ts +++ b/packages/core/src/builtin-coding-workflow-ir.ts @@ -1,15 +1,54 @@ import type { WorkflowIr } from "./workflow-ir-types.js"; import { parseWorkflowIr } from "./workflow-ir.js"; +/** + * The built-in default workflow as a v2 IR. Its six columns have ids that are + * EXACTLY the legacy enum values in legacy order (KTD-1), so a task with no + * workflow selection resolves here and its stored `column` value is already a + * valid column id — migration rewrites zero task rows. + * + * Trait ids are plain strings (the trait registry ships in U2); the mapping + * reproduces legacy behavior verbatim (R12): + * triage = intake + * todo = hold(capacity) + reset-on-entry + * in-progress = wip + abort-on-exit + timing + * in-review = merge-blocker + stall-detection + merge + * done = complete + * archived = archived + * + * The seam nodes (execute/review/merge) are placed in their columns; the graph + * walk (edges) is byte-identical to the prior v1 coding pipeline, so the graph + * executor continues to drive execute → review → merge unchanged. + */ const RAW_BUILTIN_CODING_WORKFLOW_IR: WorkflowIr = { - version: "v1", + version: "v2", name: "builtin-coding-workflow", + columns: [ + { id: "triage", name: "Triage", traits: [{ trait: "intake" }] }, + { + id: "todo", + name: "Todo", + traits: [{ trait: "hold", config: { release: "capacity" } }, { trait: "reset-on-entry" }], + }, + { + id: "in-progress", + name: "In progress", + traits: [{ trait: "wip" }, { trait: "abort-on-exit" }, { trait: "timing" }], + }, + { + id: "in-review", + name: "In review", + traits: [{ trait: "merge-blocker" }, { trait: "stall-detection" }, { trait: "merge" }], + }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + { id: "archived", name: "Archived", traits: [{ trait: "archived" }] }, + ], nodes: [ - { id: "start", kind: "start" }, - { id: "execute", kind: "prompt", config: { seam: "execute" } }, - { id: "review", kind: "prompt", config: { seam: "review" } }, - { id: "merge", kind: "prompt", config: { seam: "merge" } }, - { id: "end", kind: "end" }, + { id: "start", kind: "start", column: "triage" }, + { id: "execute", kind: "prompt", column: "in-progress", config: { seam: "execute" } }, + { id: "review", kind: "prompt", column: "in-review", config: { seam: "review" } }, + { id: "merge", kind: "prompt", column: "in-review", config: { seam: "merge" } }, + { id: "end", kind: "end", column: "done" }, ], edges: [ { from: "start", to: "execute" }, diff --git a/packages/core/src/builtin-traits.ts b/packages/core/src/builtin-traits.ts new file mode 100644 index 0000000000..957b6735cc --- /dev/null +++ b/packages/core/src/builtin-traits.ts @@ -0,0 +1,270 @@ +/** + * The 14 built-in traits (U2, R7) from the Trait Vocabulary table. Behavior for + * each trait lands in later units; here we ship the definitions (flags + config + * schema + hook descriptors) and register them into the shared trait registry. + * + * The `merge` trait's config schema is a STUB here (shape only — strategy / + * fileScope / squash / conflictStrategy); its behavior is U7. + * + * Registration is idempotent at module scope (registered once on import). Tests + * that need a clean slate use `__resetTraitRegistryForTests()` + + * `registerBuiltinTraits(registry)`. + */ + +import type { TraitDefinition } from "./trait-types.js"; +import { TraitRegistry, getTraitRegistry } from "./trait-registry.js"; + +/** The ids of the 14 built-in traits, in vocabulary-table order. */ +export const BUILTIN_TRAIT_IDS = [ + "intake", + "complete", + "archived", + "merge-blocker", + "wip", + "hold", + "human-review", + "gate", + "merge", + "abort-on-exit", + "reset-on-entry", + "timing", + "stall-detection", + "notify", +] as const; + +export type BuiltinTraitId = (typeof BUILTIN_TRAIT_IDS)[number]; + +/** The built-in trait definitions. */ +export const BUILTIN_TRAIT_DEFINITIONS: TraitDefinition[] = [ + { + id: "intake", + name: "Intake", + description: "Where new cards land; exactly one per workflow.", + builtin: true, + flags: { intake: true }, + configSchema: { + fields: [{ key: "autoTriage", type: "boolean", description: "Auto-triage new cards" }], + }, + }, + { + id: "complete", + name: "Complete", + description: "Terminal success; satisfies dependencies. Restricted flag.", + builtin: true, + flags: { complete: true }, + }, + { + id: "archived", + name: "Archived", + description: "Hidden from board; global semantics. Restricted flag.", + builtin: true, + flags: { archived: true, hiddenFromBoard: true }, + }, + { + id: "merge-blocker", + name: "Merge blocker", + description: + "Generalized FN-5147: entry to complete-bound columns blocked until the merge-class node completed.", + builtin: true, + flags: { mergeBlocker: true }, + hooks: { guard: true }, + }, + { + id: "wip", + name: "WIP / capacity", + description: "Substrate-enforced in-txn capacity limit; never bypassable (KTD-10).", + builtin: true, + flags: { countsTowardWip: true }, + configSchema: { + fields: [ + { key: "limit", type: "number", required: true, description: "Max concurrent cards" }, + { key: "countPending", type: "boolean", description: "Count mid-transition cards" }, + ], + }, + }, + { + id: "hold", + name: "Hold", + description: "Passive dwell; released by a configured condition.", + builtin: true, + flags: { hold: true }, + hooks: { releaseCondition: true }, + configSchema: { + fields: [ + { + key: "release", + type: "enum", + required: true, + enumValues: ["manual", "timer", "capacity", "dependency", "external-event"], + description: "Release condition kind (matches WorkflowHoldRelease)", + }, + ], + }, + }, + { + id: "human-review", + name: "Human review", + description: + "Card cannot leave until explicit human approval (approval state is a DB read — sync-safe). Not on the default workflow.", + builtin: true, + flags: { humanReview: true }, + hooks: { guard: true }, + configSchema: { + fields: [ + { key: "approvers", type: "array", description: "Allowed approver ids" }, + { key: "checklist", type: "array", description: "Required checklist items" }, + ], + }, + }, + { + id: "gate", + name: "Gate", + description: + "Workflow-step gate semantics generalized to columns; the plugin-facing gate surface. Blocking gates fail closed.", + builtin: true, + flags: { gate: true }, + hooks: { gate: true }, + configSchema: { + fields: [ + { + key: "gateMode", + type: "enum", + required: true, + enumValues: ["blocking", "advisory"], + description: "Blocking gates fail closed; advisory gates record and allow", + }, + { key: "prompt", type: "string", description: "Gate prompt" }, + { key: "script", type: "string", description: "Gate script" }, + ], + }, + }, + { + id: "merge", + name: "Merge", + description: + "Enqueues onto the merge-request queue; configures merge policy (U7). The lost-work guard trio stays capability-level and is unreachable from this config (KTD-6).", + builtin: true, + flags: { mergeOrchestration: true }, + hooks: { onEnter: true, onExit: true }, + configSchema: { + fields: [ + { + key: "strategy", + type: "enum", + // Direct-merge commit strategies (`DirectMergeCommitStrategy`) plus + // `pr-only` (maps onto `mergeStrategy: "pull-request"`). Absent → + // settings read-through (back-compat for the default workflow). + enumValues: ["always-squash", "auto", "always-rebase", "pr-only"], + description: "Merge strategy", + }, + { + key: "fileScope", + type: "enum", + // strict = throw on zero-overlap (today); warn = log + proceed (audit + // carries the violating file list); off = skip the throw + emit one + // per-merge "scope enforcement disabled" audit (per-task scopeOverride + // is a documented no-op here); custom = evaluate `rules` in place of + // the task's File Scope section. + enumValues: ["strict", "warn", "off", "custom"], + description: "File-scope enforcement mode", + }, + { + key: "rules", + type: "array", + description: "Custom file-scope glob/path rules (used when fileScope === 'custom')", + }, + { key: "squash", type: "boolean", description: "Squash posture" }, + { + key: "conflictStrategy", + type: "string", + description: "Conflict resolution strategy", + }, + ], + }, + }, + { + id: "abort-on-exit", + name: "Abort on exit", + description: "Generalized hard-cancel; bypassed by engine-sourced moves (KTD-9).", + builtin: true, + flags: { abortOnExit: true }, + hooks: { onExit: true }, + configSchema: { + fields: [ + { + key: "direction", + type: "enum", + enumValues: ["backward", "any"], + description: "Which exits trigger abort", + }, + { key: "confirm", type: "boolean", description: "Require user confirmation" }, + ], + }, + }, + { + id: "reset-on-entry", + name: "Reset on entry", + description: "Legacy reopen-to-todo field/step resets.", + builtin: true, + flags: { resetOnEntry: true }, + hooks: { onEnter: true }, + configSchema: { + fields: [ + { key: "preserveProgress", type: "boolean", description: "Keep progress fields" }, + ], + }, + }, + { + id: "timing", + name: "Timing", + description: "cumulativeActiveMs accounting generalized.", + builtin: true, + flags: { timing: true }, + hooks: { onEnter: true, onExit: true }, + }, + { + id: "stall-detection", + name: "Stall detection", + description: "In-review stall signals generalized to any column (sweep-evaluated).", + builtin: true, + flags: { stallDetection: true }, + configSchema: { + fields: [ + { key: "timeoutMs", type: "number", required: true, description: "Stall threshold" }, + { + key: "action", + type: "enum", + enumValues: ["annotate", "notify", "move"], + description: "Action on stall", + }, + ], + }, + }, + { + id: "notify", + name: "Notify", + description: "Basic notifications; richer notification traits are the canonical plugin example.", + builtin: true, + flags: { notify: true }, + hooks: { onEnter: true, onExit: true }, + configSchema: { + fields: [ + { key: "events", type: "array", description: "Events to notify on" }, + { key: "channel", type: "string", description: "Notification channel" }, + ], + }, + }, +]; + +/** Register all 14 built-in traits into the given registry (defaults to the + * shared registry). Idempotent guard for the shared instance lives in the + * module-scope registration below. */ +export function registerBuiltinTraits(registry: TraitRegistry = getTraitRegistry()): void { + for (const def of BUILTIN_TRAIT_DEFINITIONS) { + if (registry.has(def.id)) continue; + registry.register(def); + } +} + +// Register into the shared registry on import (idempotent via `has`). +registerBuiltinTraits(); diff --git a/packages/core/src/db.ts b/packages/core/src/db.ts index f9ea699ad4..0b9d5a0dba 100644 --- a/packages/core/src/db.ts +++ b/packages/core/src/db.ts @@ -149,7 +149,7 @@ export function probeFts5(db: DatabaseSync): boolean { // ── Schema Definition ──────────────────────────────────────────────── -const SCHEMA_VERSION = 105; +const SCHEMA_VERSION = 107; export { SCHEMA_VERSION }; @@ -322,7 +322,8 @@ CREATE TABLE IF NOT EXISTS tasks ( checkoutLeaseRenewedAt TEXT, checkoutLeaseEpoch INTEGER DEFAULT 0, deletedAt TEXT, - allowResurrection INTEGER DEFAULT 0 + allowResurrection INTEGER DEFAULT 0, + transitionPending TEXT ); -- Config table (single row with project settings) @@ -574,6 +575,20 @@ CREATE TABLE IF NOT EXISTS completion_handoff_markers ( ); CREATE INDEX IF NOT EXISTS idx_completion_handoff_markers_acceptedAt ON completion_handoff_markers(acceptedAt); +-- Per-branch run state for concurrent workflow fan-out/join (U13, KTD-11/R21). +-- Reconstructible per ADR-0001: a crashed parallel run resumes each branch from +-- its persisted node; completed branches are not re-run. Additive-only. +CREATE TABLE IF NOT EXISTS workflow_run_branches ( + taskId TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + runId TEXT NOT NULL, + branchId TEXT NOT NULL, + currentNodeId TEXT NOT NULL, + status TEXT NOT NULL, + updatedAt TEXT NOT NULL, + PRIMARY KEY (taskId, runId, branchId) +); +CREATE INDEX IF NOT EXISTS idx_workflow_run_branches_task_run ON workflow_run_branches(taskId, runId); + -- Task documents (key-value store per task with revision tracking) CREATE TABLE IF NOT EXISTS task_documents ( id TEXT PRIMARY KEY, @@ -4181,6 +4196,39 @@ export class Database { }); } + // Migration 106: Crash-safe transition marker (workflow-columns U3). Stores + // JSON {toColumn, hooksRemaining, startedAt} written in the same txn as a + // column change; recovery re-runs the remaining idempotent post-commit hooks + // and clears it. Additive-only, nullable, no backfill — existing rows have + // no in-flight transition. + if (version < 106) { + this.applyMigration(106, () => { + this.addColumnIfMissing("tasks", "transitionPending", "TEXT"); + }); + } + + // Migration 107: Per-branch run state for concurrent workflow fan-out/join + // (workflow-columns U13, KTD-11/R21). Stores {taskId, runId, branchId, + // currentNodeId, status} so a crashed parallel run resumes each branch from + // its persisted node without re-running completed branches. Additive-only, + // idempotent (table-exists guard); no backfill. + if (version < 107) { + this.applyMigration(107, () => { + this.db.exec(` + CREATE TABLE IF NOT EXISTS workflow_run_branches ( + taskId TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + runId TEXT NOT NULL, + branchId TEXT NOT NULL, + currentNodeId TEXT NOT NULL, + status TEXT NOT NULL, + updatedAt TEXT NOT NULL, + PRIMARY KEY (taskId, runId, branchId) + ); + CREATE INDEX IF NOT EXISTS idx_workflow_run_branches_task_run ON workflow_run_branches(taskId, runId); + `); + }); + } + } /** diff --git a/packages/core/src/default-workflow-hooks.ts b/packages/core/src/default-workflow-hooks.ts new file mode 100644 index 0000000000..c3dc13495d --- /dev/null +++ b/packages/core/src/default-workflow-hooks.ts @@ -0,0 +1,313 @@ +/** + * Default-workflow trait hook implementations (U4). + * + * The legacy per-column side effects of `moveTaskInternal` — timing / + * `cumulativeActiveMs` accounting, reopen field/step resets, autoMerge stamping + * + merge-queue enqueue, and abort-on-exit (hard-cancel incl. `userPaused` only + * for user-source moves) — become the default workflow's trait hook + * implementations, registered through U2's DI seam (`registerTraitHookImpl`). + * + * IMPORTANT (per U4): this is the FLAG-ON path. The legacy inline code in + * `store.ts` is NOT deleted — it IS the flag-off path. The implementations here + * are a deliberate parallel of that inline logic so the two paths can be parity- + * checked against each other; "moved, not duplicated" applies to the flag-ON + * path only. + * + * Hook classes (KTD-2): + * - guard (sync, in-lock): merge-blocker, human-review. Implemented as the + * `evaluateDefaultWorkflowGuards` reader; pure DB-free reads off the task. + * - onEnter / onExit (mutating, applied in-lock to the in-memory task before + * the commit for field effects; queue effects run in-txn): timing, + * reset-on-entry, abort-on-exit, merge. + * + * Worktree allocation is explicitly NOT a hook (it stays a substrate capability + * invoked before the move; see store.ts) — there is no `allocateWorktree` hook + * here by design. + * + * The hooks are registered into the shared trait registry on `init` via + * `registerDefaultWorkflowHooks()` (idempotent). They are resolved through + * `getTraitRegistry().resolveTraitHook(...)` so a missing registration degrades + * to a no-op + audit warning rather than crashing. + */ + +import { getTraitRegistry } from "./trait-registry.js"; +import type { TraitAuditWarning } from "./trait-registry.js"; +import { getTaskMergeBlocker } from "./task-merge.js"; +import type { Settings, Task } from "./types.js"; + +// ── Guard evaluation (sync, in-lock) ───────────────────────────────────────── + +/** A guard verdict: undefined = allow; a string reason = reject. */ +export type GuardVerdict = string | undefined; + +/** + * Evaluate the default workflow's sync guards for a move. Reproduces the legacy + * `getTaskMergeBlocker` gate on `in-review → done`. (The default workflow does + * not carry the human-review trait — see the Trait Vocabulary note — so there + * is no human-review guard on this workflow.) + * + * `bypassGuards` (engine-sourced moves, KTD-9) skips guards entirely — the + * caller is responsible for honoring that; this function still computes the + * verdict so callers can choose. The store only consults it when not bypassing. + */ +export function evaluateMergeBlockerGuard( + task: Pick, + fromColumn: string, + toColumn: string, +): GuardVerdict { + if (fromColumn === "in-review" && toColumn === "done") { + return getTaskMergeBlocker(task); + } + return undefined; +} + +// ── Move-effect context ─────────────────────────────────────────────────────── + +/** Side-effect callbacks the store provides so the hooks stay engine-free and + * DB-handle-free; the store wires these to its in-txn / post-commit machinery. */ +export interface DefaultWorkflowMoveContext { + task: Task; + fromColumn: string; + toColumn: string; + moveSource: "user" | "engine" | "scheduler"; + /** True when guards + abort-on-exit are bypassed (engine/recovery, KTD-9). */ + bypassGuards: boolean; + movedAt: string; + /** Settings snapshot for autoMerge stamping (only read when entering review). */ + settings: Pick | undefined; + /** Move options that influence reopen/timing semantics. */ + options: { + preserveStatus?: boolean; + preserveResumeState?: boolean; + preserveProgress?: boolean; + preserveWorktree?: boolean; + }; + /** Reset all steps to pending + currentStep 0 (store owns the impl). */ + resetSteps: () => void; +} + +// ── Field-mutation effects (applied in-lock, before commit) ─────────────────── +// +// These mirror the inline flag-off mutations in store.ts exactly. They run as +// the resolved onEnter/onExit hook bodies for the default workflow's traits. + +/** `timing` trait (in-progress): accumulate active ms on exit, stamp timing on + * entry. */ +export function applyTimingEffects(ctx: DefaultWorkflowMoveContext): void { + const { task, fromColumn, toColumn } = ctx; + if (fromColumn === "in-progress" && toColumn !== "in-progress") { + const segmentStartMs = Date.parse(task.executionStartedAt ?? task.columnMovedAt ?? ctx.movedAt); + const segmentEndMs = Date.parse(task.columnMovedAt ?? ctx.movedAt); + const segmentDeltaMs = + Number.isFinite(segmentStartMs) && Number.isFinite(segmentEndMs) + ? Math.max(0, segmentEndMs - segmentStartMs) + : 0; + task.cumulativeActiveMs = Math.max(0, task.cumulativeActiveMs ?? 0) + segmentDeltaMs; + } + if (toColumn === "in-progress") { + task.cumulativeActiveMs ??= 0; + if (!task.firstExecutionAt) task.firstExecutionAt = task.columnMovedAt; + if (!task.executionStartedAt) task.executionStartedAt = task.columnMovedAt; + task.userPaused = undefined; + } +} + +/** Stamp `executionCompletedAt` on entry to a completion column. */ +export function applyCompletionTimingEffects(ctx: DefaultWorkflowMoveContext): void { + const { task, toColumn } = ctx; + if (toColumn === "done" && !task.executionCompletedAt) { + task.executionCompletedAt = task.columnMovedAt; + } +} + +/** `reset-on-entry` trait (todo/triage reopen) + `abort-on-exit` userPaused + * semantics. Reproduces the legacy reopen block. */ +export function applyResetOnEntryEffects(ctx: DefaultWorkflowMoveContext): void { + const { task, fromColumn, toColumn, moveSource, options } = ctx; + const isReopenToTodoOrTriage = + (fromColumn === "in-progress" || fromColumn === "done" || fromColumn === "in-review") && + (toColumn === "todo" || toColumn === "triage"); + if (!isReopenToTodoOrTriage) return; + + if (!options.preserveStatus) { + task.status = undefined; + task.error = undefined; + task.pausedReason = undefined; + } + task.blockedBy = undefined; + task.overlapBlockedBy = undefined; + task.paused = undefined; + task.pausedByAgentId = undefined; + // abort-on-exit userPaused: only for user-source moves to todo (KTD-9). + if (moveSource === "user" && toColumn === "todo") { + task.userPaused = true; + } else { + task.userPaused = undefined; + } + + const hasNonPendingStepProgress = task.steps.some((step) => step.status !== "pending"); + const preserveStepProgress = + options.preserveResumeState || (options.preserveProgress === true && hasNonPendingStepProgress); + + if (!options.preserveWorktree) { + task.worktree = undefined; + } + if (!options.preserveResumeState) { + task.executionStartedAt = undefined; + task.executionCompletedAt = undefined; + } else { + task.executionCompletedAt = undefined; + } + if (!preserveStepProgress) { + ctx.resetSteps(); + // Prompt-checkbox reset is a filesystem effect; the store performs it + // post-hook (it owns the task dir). Not modeled here. + } +} + +/** `merge` trait onEnter (in-review): autoMerge stamping + scheduler-state + * clearing. The queue enqueue itself is in-txn and store-owned (handoff path); + * the field effects mirror the legacy in-review block. */ +export function applyInReviewEnterEffects(ctx: DefaultWorkflowMoveContext): void { + const { task, toColumn, settings } = ctx; + if (toColumn !== "in-review") return; + if (task.autoMerge === undefined && settings) { + task.autoMerge = settings.autoMerge; + } + task.recoveryRetryCount = undefined; + task.nextRecoveryAt = undefined; + if (task.status === "queued") { + task.status = undefined; + } + task.blockedBy = undefined; + task.overlapBlockedBy = undefined; +} + +/** Reopen-from-review/done field clears (branch/summary/workflowStepResults). */ +export function applyReopenFieldClears(ctx: DefaultWorkflowMoveContext): void { + const { task, fromColumn, toColumn } = ctx; + if ( + (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "in-progress" || toColumn === "triage")) || + (fromColumn === "done" && (toColumn === "todo" || toColumn === "triage")) + ) { + task.workflowStepResults = undefined; + } + if (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "triage")) { + task.branch = undefined; + task.executionStartBranch = undefined; + task.baseCommitSha = undefined; + task.summary = undefined; + task.recoveryRetryCount = undefined; + task.nextRecoveryAt = undefined; + } +} + +/** + * Apply ALL default-workflow field-mutation move effects (the parallel of the + * legacy inline block) in the legacy order. Pure in-memory mutation of + * `ctx.task`; queue/filesystem/post-commit effects remain store-owned. + * + * This is the entry point the flag-ON store path calls. It resolves each + * trait's hook through the registry first (so a missing registration degrades to + * a no-op + audit warning, satisfying the "invokes through the registry" + * contract and the degraded-hook path); resolution warnings are collected and + * returned for the store to forward to audit. + */ +export function applyDefaultWorkflowMoveEffects( + ctx: DefaultWorkflowMoveContext, +): { warnings: TraitAuditWarning[] } { + const registry = getTraitRegistry(); + const warnings: TraitAuditWarning[] = []; + + // Resolve the hooks through the registry. The resolved impls are the closures + // registered by registerDefaultWorkflowHooks(); resolution surfaces a warning + // (and a no-op) if a registration is missing. + const toRun: Array<{ traitId: string; hookKind: "onEnter" | "onExit" }> = [ + { traitId: "timing", hookKind: "onExit" }, + { traitId: "timing", hookKind: "onEnter" }, + { traitId: "reset-on-entry", hookKind: "onEnter" }, + { traitId: "abort-on-exit", hookKind: "onExit" }, + { traitId: "merge", hookKind: "onEnter" }, + ]; + for (const { traitId, hookKind } of toRun) { + const { impl, warning } = registry.resolveTraitHook(traitId, hookKind); + if (warning) warnings.push(warning); + if (impl) impl(ctx); + } + + return { warnings }; +} + +// ── Registration into the trait registry (DI seam) ─────────────────────────── + +let registered = false; + +/** + * Register the default-workflow hook implementations into the shared trait + * registry. Idempotent. Called at store init (the store is the engine-adjacent + * owner of the move lifecycle). Each registration is a thin adapter that runs + * the corresponding field-effect function over the move context. + * + * The legacy effects map onto traits as: + * timing.onExit / timing.onEnter → applyTimingEffects + completion stamp + * reset-on-entry.onEnter → applyResetOnEntryEffects + reopen clears + * abort-on-exit.onExit → (userPaused handled in reset-on-entry; + * session abort is an engine effect U6/U7) + * merge.onEnter → applyInReviewEnterEffects + */ +export function registerDefaultWorkflowHooks(): void { + if (registered) return; + const registry = getTraitRegistry(); + + const cast = (fn: (ctx: DefaultWorkflowMoveContext) => void) => + ((...args: unknown[]) => fn(args[0] as DefaultWorkflowMoveContext)) as ( + ...args: unknown[] + ) => unknown; + + registry.registerTraitHookImpl( + "timing", + "onExit", + cast((ctx) => { + applyTimingEffects(ctx); + }), + ); + registry.registerTraitHookImpl( + "timing", + "onEnter", + cast((ctx) => { + applyCompletionTimingEffects(ctx); + }), + ); + registry.registerTraitHookImpl( + "reset-on-entry", + "onEnter", + cast((ctx) => { + applyResetOnEntryEffects(ctx); + applyReopenFieldClears(ctx); + }), + ); + registry.registerTraitHookImpl( + "abort-on-exit", + "onExit", + cast(() => { + // userPaused is set in applyResetOnEntryEffects (the legacy ordering keeps + // it with the reopen block). Session-abort wiring is an engine effect that + // lands with U6/U7; here it is intentionally a no-op so the resolved hook + // exists (not a missing-impl warning) while carrying no field mutation. + }), + ); + registry.registerTraitHookImpl( + "merge", + "onEnter", + cast((ctx) => { + applyInReviewEnterEffects(ctx); + }), + ); + + registered = true; +} + +/** Test-only: allow re-registration after a registry reset. */ +export function __resetDefaultWorkflowHooksForTests(): void { + registered = false; +} diff --git a/packages/core/src/duplicate-detection.ts b/packages/core/src/duplicate-detection.ts index 9dd541b368..e4abd70874 100644 --- a/packages/core/src/duplicate-detection.ts +++ b/packages/core/src/duplicate-detection.ts @@ -1,12 +1,12 @@ import { createHash } from "node:crypto"; -import type { Column } from "./types.js"; +import type { Column, ColumnId } from "./types.js"; export interface DuplicateMatch { id: string; title: string; description: string; - column: Column; + column: ColumnId; score: number; } @@ -19,7 +19,7 @@ export interface DuplicateCandidate { id: string; title: string; description: string; - column: Column; + column: ColumnId; } export interface ContentFingerprintInput { @@ -138,7 +138,7 @@ export function findDuplicateMatches( const threshold = opts?.threshold ?? DEFAULT_THRESHOLD; const limit = opts?.limit ?? DEFAULT_LIMIT; - const excludedColumns = new Set(opts?.excludeColumns ?? DEFAULT_EXCLUDE_COLUMNS); + const excludedColumns = new Set(opts?.excludeColumns ?? DEFAULT_EXCLUDE_COLUMNS); const sourceText = `${input.title ?? ""} ${description}`.trim(); const sourceTokens = new Set(tokenize(sourceText)); const sourceTitle = input.title ?? ""; diff --git a/packages/core/src/duplicate-intake.ts b/packages/core/src/duplicate-intake.ts index e7a8994022..59ed39d583 100644 --- a/packages/core/src/duplicate-intake.ts +++ b/packages/core/src/duplicate-intake.ts @@ -1,5 +1,5 @@ import { findDuplicateMatches } from "./duplicate-detection.js"; -import type { Column } from "./types.js"; +import type { ColumnId } from "./types.js"; import type { TaskStore } from "./store.js"; export interface SameAgentDuplicateInput { @@ -17,7 +17,7 @@ export interface SameAgentDuplicateCandidate { id: string; title: string; description: string; - column: Column; + column: ColumnId; createdAt: number; sourceAgentId: string | null; sourceParentTaskId?: string | null; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7d89c6fa08..b276e56693 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, isMergeRequestContractShadowEnabled, resolvePersistAgentThinkingLog, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, SUPPORTED_LOCALES, DEFAULT_LOCALE, isLocale, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, SANDBOX_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeIntegrationWorktreeMode, normalizeMergeAdvanceAutoSyncMode, MERGE_ADVANCE_AUTO_SYNC_MODES, normalizeMergeConflictStrategy, normalizeMergeStrategyOverlapBehavior, normalizePostMergeAuditMode, POST_MERGE_AUDIT_MODES, normalizeMergeAuditAutoRecovery, MERGE_AUDIT_AUTO_RECOVERY_MODES, normalizeMergerMode, MERGER_MODES, normalizeAutoRecovery, AUTO_RECOVERY_MODES, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js"; -export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext } from "./types.js"; +export type { Column, ColumnId, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrConflictState, PrConflictDiagnostics, PrCheckState, PrCheckStatus, PrStatus, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, BranchGroupPrState, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, RetrySummary, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeIntegrationWorktreeMode, MergeAdvanceAutoSyncMode, MergeConflictStrategy, CanonicalMergeConflictStrategy, MergeStrategyOverlapBehavior, PostMergeAuditMode, MergeAuditAutoRecoveryMode, MergerMode, MergerSettings, AutoRecoveryMode, AutoRecoveryFailureClass, AutoRecoverySettings, DirectMergeCommitStrategy, Settings, GlobalSettings, ProjectSettings, SecretsEnvConfig, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SandboxBackendName, SandboxFailureMode, SandboxPolicy, SandboxProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, Locale, ExecutionMode, TaskPriority, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, MergeRequestState, MergeRequestRecord, CompletionHandoffMarker, HandoffEvidence, HandoffToReviewOptions, UnavailableNodePolicy, OwningNodeHandoffPolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, GithubIssueAction, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepGateMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, SandboxProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, CheckoutClaimPrecondition, TaskClaimRow, CentralClaimStore, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, AgentMemoryInclusionMode, HeartbeatPromptTemplate, HeartbeatScopeDisciplineMode, WorktrunkSettings, WorktrunkOnFailure, TaskBranchContext } from "./types.js"; export { AGENT_VALID_TRANSITIONS, DUPLICATE_OF_METADATA_KEY } from "./types.js"; export { resolveEntryPointBranchAssignment, @@ -49,14 +49,128 @@ export { parseWorkflowIr, serializeWorkflowIr, WorkflowIrError, + DEFAULT_WORKFLOW_COLUMN_IDS, } from "./workflow-ir.js"; export type { WorkflowIr, + WorkflowIrV1, + WorkflowIrV2, WorkflowIrNode, WorkflowIrEdge, WorkflowIrNodeKind, + WorkflowIrColumn, + WorkflowIrColumnTrait, + WorkflowHoldRelease, + WorkflowJoinMode, + WorkflowJoinBranchFailure, } from "./workflow-ir-types.js"; export { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js"; + +// ── Trait model (U2) ───────────────────────────────────────────────── +export type { + TraitDefinition, + TraitFlags, + TraitConfigSchema, + TraitConfigField, + TraitHookDescriptors, + TraitHookKind, + TraitHookImpl, + RestrictedTraitFlag, +} from "./trait-types.js"; +export { RESTRICTED_TRAIT_FLAGS, traitHookKey } from "./trait-types.js"; +export { + TraitRegistry, + TraitRegistrationError, + getTraitRegistry, + getTrait, + listTraits, + resolveColumnFlags, + validateColumnTraits, + assertColumnTraitsValid, + ColumnTraitValidationError, + registerTraitHookImpl, + __resetTraitRegistryForTests, +} from "./trait-registry.js"; +export type { + TraitRegistrationReason, + TraitViolation, + TraitViolationCode, + TraitViolationSeverity, + TraitAuditWarning, +} from "./trait-registry.js"; +export { + BUILTIN_TRAIT_IDS, + BUILTIN_TRAIT_DEFINITIONS, + registerBuiltinTraits, +} from "./builtin-traits.js"; +export type { BuiltinTraitId } from "./builtin-traits.js"; +export { + registerDefaultWorkflowHooks, + __resetDefaultWorkflowHooksForTests, +} from "./default-workflow-hooks.js"; +// ── Typed transition contract + crash-safe marker (U3) ─────────────── +export type { + TransitionRejection, + TransitionRejectionCode, + TransitionResult, + TransitionPending, +} from "./transition-types.js"; +export { + TRANSITION_REJECTION_CODES, + makeTransitionRejection, + makeTransitionPending, + transitionOk, + transitionRejected, + serializeTransitionRejection, + deserializeTransitionRejection, + serializeTransitionPending, + deserializeTransitionPending, +} from "./transition-types.js"; +export type { + TransitionPendingDbHandle, + ReconcileHooksResult, +} from "./transition-pending.js"; +// ── U4: workflow-resolved transition adjacency + flag accessor ─────────────── +export { + resolveColumnAdjacency, + resolveAllowedColumns, + workflowHasColumn, +} from "./workflow-transitions.js"; +export type { ColumnAdjacency } from "./workflow-transitions.js"; +export { isWorkflowColumnsEnabled } from "./workflow-columns-settings.js"; +// ── U8: pre-evaluated plugin gate verdicts (KTD-2) ─────────────────────────── +export { + findWorkflowColumn, + resolveColumnPluginGates, +} from "./plugin-gate-verdict.js"; +export type { PluginGateVerdict, ColumnPluginGate } from "./plugin-gate-verdict.js"; +// ── U6: workflow capacity (WIP) resolution shared by store + sweep ─────────── +export { resolveColumnCapacity, DEFAULT_WORKFLOW_POOL_ID } from "./workflow-capacity.js"; +export type { ColumnCapacity } from "./workflow-capacity.js"; +// ── U5: workflow lifecycle reconciliation (switch / edit / delete) ─────────── +export { + OccupiedColumnsError, + InvalidRehomeTargetError, + resolveEntryColumnId, + resolveSwitchReconciliation, + computeRemovedOccupiedColumns, + assertRehomeTargetValid, + setReconciliationAbort, + runReconciliationAbort, + __resetReconciliationAbortForTests, +} from "./workflow-reconciliation.js"; +export type { + SwitchReconciliation, + ColumnOccupancy, + ReconciliationAbort, + ReconciliationAbortContext, +} from "./workflow-reconciliation.js"; +export { + readTransitionPending, + writeTransitionPending, + clearTransitionPending, + reconcileHooksRemaining, +} from "./transition-pending.js"; export type { WorkflowDefinition, WorkflowDefinitionInput, @@ -74,6 +188,11 @@ export { getBuiltinWorkflow, isBuiltinWorkflowId, } from "./builtin-workflows.js"; +export { + resolveWorkflowIrForTask, + resolveWorkflowIrById, + type WorkflowIrResolverStore, +} from "./workflow-ir-resolver.js"; // ── Engine wiring (set by @fusion/engine at module load) ──────────── export { @@ -186,6 +305,7 @@ export { MergeQueueLeaseOwnershipError, InvalidMergeQueueLeaseDurationError, HandoffInvariantViolationError, + TransitionRejectionError, } from "./store.js"; export { STOPWORDS, @@ -570,6 +690,9 @@ export type { PluginLogger, PluginSkillContribution, PluginWorkflowStepContribution, + PluginTraitContribution, + PluginTraitHookDescriptor, + PluginTraitFlags, PluginPromptSurface, PluginPromptContribution, PluginPromptContributions, @@ -584,7 +707,15 @@ export type { PluginState, PluginInstallation, } from "./plugin-types.js"; -export { validatePluginManifest, normalizePluginUiContributionSurface, normalizePluginUiContributionDefinition } from "./plugin-types.js"; +export { + validatePluginManifest, + validatePluginTraitContribution, + PLUGIN_TRAIT_RESTRICTED_FLAGS, + PLUGIN_TRAIT_ALLOWED_HOOK_POINTS, + PLUGIN_TRAIT_SCHEMA_VERSION, + normalizePluginUiContributionSurface, + normalizePluginUiContributionDefinition, +} from "./plugin-types.js"; export { PluginStore } from "./plugin-store.js"; export type { PluginStoreEvents, PluginRegistrationInput, PluginUpdateInput } from "./plugin-store.js"; export { PluginLoader, resolvePluginEntryPath } from "./plugin-loader.js"; @@ -1143,6 +1274,10 @@ export { deriveStageTransitions, buildWorkflowObservationFromTask, buildWorkflowObservation, + checkTransitionParity, + countDualAcceptDisagreements, + computeWorkflowColumnsGraduationReport, + DUAL_ACCEPT_PARITY_MUTATIONS, } from "./workflow-parity.js"; export type { WorkflowAuditObservation, @@ -1157,6 +1292,11 @@ export type { WorkflowObservationBuildOptions, WorkflowObservationParts, WorkflowParitySummary, + TransitionParityDiff, + TransitionParityReport, + DualAcceptDisagreementReport, + WorkflowColumnsGraduationReport, + GraduationReportInputs, } from "./workflow-parity.js"; export { isResearchExperimentalEnabled, resolveResearchSettings } from "./research-settings.js"; export type { ResolvedResearchSettings } from "./research-settings.js"; diff --git a/packages/core/src/near-duplicate.ts b/packages/core/src/near-duplicate.ts index 992ff1c99c..c9480d4826 100644 --- a/packages/core/src/near-duplicate.ts +++ b/packages/core/src/near-duplicate.ts @@ -1,5 +1,5 @@ import { STOPWORDS, tokenize } from "./duplicate-detection.js"; -import type { Column } from "./types.js"; +import type { ColumnId } from "./types.js"; const DEFAULT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; const DEFAULT_LIMIT = 5; @@ -34,7 +34,7 @@ export interface NearDuplicateCandidate { id: string; title: string; description: string; - column: Column; + column: ColumnId; fileScope?: string[]; createdAt?: number; } diff --git a/packages/core/src/plugin-gate-verdict.ts b/packages/core/src/plugin-gate-verdict.ts new file mode 100644 index 0000000000..ad51217afb --- /dev/null +++ b/packages/core/src/plugin-gate-verdict.ts @@ -0,0 +1,83 @@ +/** + * Pre-evaluated plugin gate verdicts (U8, KTD-2). + * + * Per KTD-2 a plugin gate is evaluated *before* the move is attempted, OUTSIDE + * the task lock (via the prompt-session/script/verdict machinery). The verdict + * is recorded and then re-checked cheaply IN-LOCK at move time — this removes + * any path where plugin code can block or wedge the task lock. + * + * The engine (PluginRunner trait adapter) evaluates the gate and records the + * verdict through `TaskStore.recordPluginGateVerdict`; the flag-ON guard site in + * `moveTaskInternal` consumes it through `consumePluginGateVerdicts` and rejects + * the move when a blocking gate has no recorded `allow` verdict. + * + * U8 keeps the storage minimal and surgical (an in-memory map on the store) per + * the unit's "define it here minimally" note. The shape below is the seam a + * later unit can back with SQLite without changing call sites. + */ + +import type { WorkflowIr, WorkflowIrV2, WorkflowIrColumn } from "./workflow-ir-types.js"; +import type { TraitDefinition } from "./trait-types.js"; + +/** A recorded gate verdict for a (task, targetColumn, trait). */ +export interface PluginGateVerdict { + /** The registry-facing trait id (e.g. `plugin::`). */ + traitId: string; + /** Whether the gate verdict allows the move into the target column. */ + allow: boolean; + /** `blocking` fails closed on a non-allow verdict; `advisory` records+allows. */ + gateMode: "blocking" | "advisory"; + /** Human-readable detail surfaced in the rejection / audit. */ + detail?: string; + /** When the verdict was recorded (epoch ms). */ + recordedAt: number; +} + +/** A plugin gate trait found on a column (id + its declared gate mode). */ +export interface ColumnPluginGate { + /** The column trait's registry id. */ + traitId: string; + /** Gate mode from the column trait's `config.gateMode` (defaults to blocking). */ + gateMode: "blocking" | "advisory"; +} + +/** Resolve a workflow column by id from a (v2) IR, or undefined. */ +export function findWorkflowColumn( + ir: WorkflowIr, + columnId: string, +): WorkflowIrColumn | undefined { + const v2 = ir as WorkflowIrV2; + if (!Array.isArray(v2.columns)) return undefined; + return v2.columns.find((c) => c.id === columnId); +} + +/** + * Identify the PLUGIN gate traits on a target column. A trait qualifies when: + * - its registry id is namespaced (`plugin:...`) — built-in gate traits are + * handled by the built-in gate path, not this plugin-facing surface; AND + * - it actually declares a gate (a `gate` hook descriptor or the `gate` flag), + * resolved via `lookupTrait`. A plugin trait with only onEnter/onExit/etc. + * is NOT a gate and must not demand a verdict. + * + * The gate mode is read from the column trait's `config.gateMode` (defaults to + * blocking, matching the built-in gate's fail-closed posture). + */ +export function resolveColumnPluginGates( + column: WorkflowIrColumn | undefined, + lookupTrait?: (traitId: string) => TraitDefinition | undefined, +): ColumnPluginGate[] { + if (!column) return []; + const gates: ColumnPluginGate[] = []; + for (const ct of column.traits) { + if (!ct.trait.startsWith("plugin:")) continue; + const def = lookupTrait?.(ct.trait); + // When a lookup is supplied, require the trait to actually declare a gate. + // Without a lookup (no registry access) we fall back to treating any plugin + // trait as a potential gate — the conservative fail-closed default. + if (lookupTrait && !(def?.hooks?.gate || def?.flags?.gate)) continue; + const cfgMode = ct.config?.gateMode; + const gateMode = cfgMode === "advisory" ? "advisory" : "blocking"; + gates.push({ traitId: ct.trait, gateMode }); + } + return gates; +} diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index c9a5b28472..098635f848 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -33,6 +33,7 @@ import type { PluginInstallation, PluginSkillContribution, PluginWorkflowStepContribution, + PluginTraitContribution, PluginPromptContribution, PluginPromptContributions, PluginSetupManifest, @@ -1066,6 +1067,21 @@ export class PluginLoader extends EventEmitter<{ return steps; } + /** + * Get all trait contributions from loaded plugins (U8). + */ + getPluginTraits(): Array<{ pluginId: string; trait: PluginTraitContribution }> { + const traits: Array<{ pluginId: string; trait: PluginTraitContribution }> = []; + for (const [pluginId, plugin] of this.plugins) { + if (plugin.traits) { + for (const trait of plugin.traits) { + traits.push({ pluginId, trait }); + } + } + } + return traits; + } + /** * Get all workflow step templates derived from loaded plugin contributions. */ diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts index 2af8d98957..4158bb57b1 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -49,6 +49,8 @@ export interface PluginManifest { skills?: Array<{ skillId: string; name: string }>; /** Optional workflow step metadata used for discovery UIs. */ workflowSteps?: Array<{ stepId: string; name: string }>; + /** Optional trait metadata used for discovery UIs (U8). */ + traits?: Array<{ traitId: string; name: string }>; /** Prompt surfaces this plugin contributes to. */ promptSurfaces?: PluginPromptSurface[]; /** Setup metadata for plugin-managed binaries/runtimes. */ @@ -692,6 +694,212 @@ export interface PluginWorkflowStepContribution { modelId?: string; } +/** + * Plugin-contributed trait (U8, R6/R22, KTD-7). + * + * Plugins declare traits in their manifest the way they declare workflow steps. + * A trait carries declarative flags + an optional config schema + async-only + * hook descriptors. The contract is a VERSIONED hook-descriptor schema + * (`schemaVersion`) so the built-in trait vocabulary can grow additively (new + * flags, hook points, config fields) without breaking published plugin traits. + * + * Restricted (built-in-only) capabilities a plugin trait may NOT declare (R22, + * KTD-2/KTD-7), rejected at validation: + * - the `complete` / `archived` flags (silently satisfying dependencies / + * hiding cards is a scheduling-poison surface); + * - a sync `guard` hook (sync guards run in-lock and must be fast/pure — a + * plugin hook there could wedge the task lock). + * + * Plugin traits get ASYNC hook points only: `gate`, `onEnter`, `onExit`, + * `releaseCondition`. Each hook descriptor mirrors PluginWorkflowStepContribution's + * declarative shape (mode + prompt/scriptName) so the existing prompt-session / + * script / verdict machinery executes them; gates additionally carry `gateMode`. + */ +export interface PluginTraitHookDescriptor { + /** How the hook runs: a model prompt or a named project script. */ + mode: "prompt" | "script"; + /** Prompt text used when `mode === "prompt"`. */ + prompt?: string; + /** Named project script used when `mode === "script"`. */ + scriptName?: string; + /** + * Gate semantics (gate hook only): `blocking` fails closed (a non-pass + * verdict rejects the move); `advisory` records the verdict and allows the + * move. Ignored for non-gate hooks. Defaults to `blocking` for gate hooks. + */ + gateMode?: "blocking" | "advisory"; +} + +/** + * The declarative flag subset a plugin trait may declare. Restricted flags + * (`complete`, `archived`) are intentionally absent from this type AND rejected + * at validation — declaring them is a contribution error, not silently ignored. + */ +export interface PluginTraitFlags { + countsTowardWip?: boolean; + hiddenFromBoard?: boolean; + abortOnExit?: boolean; + humanReview?: boolean; + intake?: boolean; + hold?: boolean; + mergeOrchestration?: boolean; + mergeBlocker?: boolean; + resetOnEntry?: boolean; + timing?: boolean; + stallDetection?: boolean; + notify?: boolean; + gate?: boolean; +} + +export interface PluginTraitContribution { + /** Unique trait identifier within the plugin namespace (kebab-case). The + * registry-facing id is namespaced as `plugin::`. */ + traitId: string; + /** Human-readable trait name. */ + name: string; + /** Short description for UI. */ + description?: string; + /** Versioned hook-descriptor schema. Currently `1`. Required so the + * vocabulary can extend additively without breaking published traits. */ + schemaVersion: 1; + /** Declarative flags (restricted flags rejected at validation, R22). */ + flags?: PluginTraitFlags; + /** Optional declarative config schema fields (shape mirrors TraitConfigField). */ + configSchema?: { + fields: Array<{ + key: string; + type: "string" | "number" | "boolean" | "enum" | "object" | "array"; + required?: boolean; + enumValues?: readonly string[]; + description?: string; + }>; + }; + /** Async-only hook descriptors (R22). A `guard` key is NOT permitted and is + * rejected at validation. */ + hooks?: { + gate?: PluginTraitHookDescriptor; + onEnter?: PluginTraitHookDescriptor; + onExit?: PluginTraitHookDescriptor; + releaseCondition?: PluginTraitHookDescriptor; + }; +} + +/** The restricted flag keys a plugin trait may not declare (R22, KTD-7). */ +export const PLUGIN_TRAIT_RESTRICTED_FLAGS = ["complete", "archived"] as const; + +/** The async-only hook points a plugin trait may declare (R22). The sync + * `guard` hook point is built-in-only and rejected at validation. */ +export const PLUGIN_TRAIT_ALLOWED_HOOK_POINTS = [ + "gate", + "onEnter", + "onExit", + "releaseCondition", +] as const; + +/** The current plugin trait hook-descriptor schema version. */ +export const PLUGIN_TRAIT_SCHEMA_VERSION = 1 as const; + +/** + * Validate one plugin trait contribution. Returns a list of human-readable + * error strings (empty = valid). Mirrors the validation posture of + * `validatePluginManifest`'s `workflowSteps` block: structural checks plus the + * R22 restricted-capability checks (sync `guard` key, restricted flags) and the + * required versioned `schemaVersion`. + */ +export function validatePluginTraitContribution( + trait: unknown, + index = 0, +): string[] { + const errors: string[] = []; + const prefix = `traits[${index}]`; + if (!trait || typeof trait !== "object" || Array.isArray(trait)) { + return [`${prefix} must be an object`]; + } + const t = trait as Record; + + if (!t.traitId || typeof t.traitId !== "string" || t.traitId.trim() === "") { + errors.push(`${prefix}.traitId is required and must be a non-empty string`); + } else if (!SLUG_PATTERN.test(t.traitId)) { + errors.push( + `${prefix}.traitId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)`, + ); + } + + if (!t.name || typeof t.name !== "string" || t.name.trim() === "") { + errors.push(`${prefix}.name is required and must be a non-empty string`); + } + + // schemaVersion is required and must be the supported version (versioned + // hook-descriptor extension contract). + if (t.schemaVersion === undefined) { + errors.push(`${prefix}.schemaVersion is required (versioned hook-descriptor schema)`); + } else if (t.schemaVersion !== PLUGIN_TRAIT_SCHEMA_VERSION) { + errors.push( + `${prefix}.schemaVersion must be ${PLUGIN_TRAIT_SCHEMA_VERSION}; got ${String(t.schemaVersion)}`, + ); + } + + // Restricted flags (R22): a plugin trait must not declare complete/archived. + if (t.flags !== undefined) { + if (typeof t.flags !== "object" || t.flags === null || Array.isArray(t.flags)) { + errors.push(`${prefix}.flags must be an object`); + } else { + const flags = t.flags as Record; + for (const restricted of PLUGIN_TRAIT_RESTRICTED_FLAGS) { + if (flags[restricted]) { + errors.push( + `${prefix}.flags.${restricted} is a restricted (built-in-only) flag and may not be declared by a plugin trait`, + ); + } + } + } + } + + // Hooks: async-only. A sync `guard` key is rejected (R22, KTD-2). + if (t.hooks !== undefined) { + if (typeof t.hooks !== "object" || t.hooks === null || Array.isArray(t.hooks)) { + errors.push(`${prefix}.hooks must be an object`); + } else { + const hooks = t.hooks as Record; + if ("guard" in hooks) { + errors.push( + `${prefix}.hooks.guard is a sync (built-in-only) hook point and may not be declared by a plugin trait`, + ); + } + for (const [hookKind, descriptor] of Object.entries(hooks)) { + if (hookKind === "guard") continue; // already reported + if (!(PLUGIN_TRAIT_ALLOWED_HOOK_POINTS as readonly string[]).includes(hookKind)) { + errors.push( + `${prefix}.hooks.${hookKind} is not a recognized async hook point (allowed: ${PLUGIN_TRAIT_ALLOWED_HOOK_POINTS.join(", ")})`, + ); + continue; + } + if (!descriptor || typeof descriptor !== "object") { + errors.push(`${prefix}.hooks.${hookKind} must be an object`); + continue; + } + const d = descriptor as Record; + if (d.mode !== "prompt" && d.mode !== "script") { + errors.push(`${prefix}.hooks.${hookKind}.mode must be one of: prompt, script`); + } + if (d.mode === "script" && (typeof d.scriptName !== "string" || d.scriptName.trim() === "")) { + errors.push(`${prefix}.hooks.${hookKind}.scriptName is required when mode is "script"`); + } + if ( + hookKind === "gate" && + d.gateMode !== undefined && + d.gateMode !== "blocking" && + d.gateMode !== "advisory" + ) { + errors.push(`${prefix}.hooks.gate.gateMode must be one of: blocking, advisory`); + } + } + } + } + + return errors; +} + /** * Prompt injection surfaces for plugin-contributed instructions. * - executor-system: Appended to executor agent system prompt @@ -829,6 +1037,8 @@ export interface FusionPlugin { skills?: PluginSkillContribution[]; /** Plugin-contributed workflow step templates. */ workflowSteps?: PluginWorkflowStepContribution[]; + /** Plugin-contributed column traits (U8). */ + traits?: PluginTraitContribution[]; /** Plugin-contributed prompt injections. */ promptContributions?: PluginPromptContributions; /** Plugin-managed setup metadata and lifecycle hooks. */ @@ -1024,6 +1234,38 @@ export function validatePluginManifest(manifest: unknown): { valid: boolean; err } } + // Optional: plugin trait contributions (U8). Full contribution shapes (with + // hooks/flags) validate via validatePluginTraitContribution; the discovery + // metadata form (`{ traitId, name }`) validates structurally here. + if (m.traits !== undefined) { + if (!Array.isArray(m.traits)) { + errors.push("traits must be an array"); + } else { + for (const [index, trait] of m.traits.entries()) { + if (!trait || typeof trait !== "object") { + errors.push(`traits[${index}] must be an object`); + continue; + } + const traitMeta = trait as Record; + // A full contribution carries schemaVersion/flags/hooks — validate it + // fully. The discovery-metadata form (just traitId + name) is validated + // structurally. + if (traitMeta.schemaVersion !== undefined || traitMeta.hooks !== undefined || traitMeta.flags !== undefined) { + errors.push(...validatePluginTraitContribution(traitMeta, index)); + continue; + } + if (!traitMeta.traitId || typeof traitMeta.traitId !== "string" || traitMeta.traitId.trim() === "") { + errors.push(`traits[${index}].traitId is required and must be a non-empty string`); + } else if (!SLUG_PATTERN.test(traitMeta.traitId)) { + errors.push(`traits[${index}].traitId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)`); + } + if (!traitMeta.name || typeof traitMeta.name !== "string" || traitMeta.name.trim() === "") { + errors.push(`traits[${index}].name is required and must be a non-empty string`); + } + } + } + } + // Optional: prompt surface metadata if (m.promptSurfaces !== undefined) { if (!Array.isArray(m.promptSurfaces)) { diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 2322ed9883..3a436d8903 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -3,11 +3,50 @@ import { randomUUID } from "node:crypto"; import { mkdir, readdir, readFile, writeFile, rename, unlink } from "node:fs/promises"; import { join } from "node:path"; import { existsSync, watch, type FSWatcher } from "node:fs"; -import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, CompletionHandoffMarker } from "./types.js"; +import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, ColumnId, CheckoutClaimPrecondition, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter, ArchivedTaskEntry, ArchiveAgentLogMode, TaskPriority, SourceType, WorkflowStepTemplate, Agent, AutostashOrphanRecord, TaskCommitAssociation, TaskCommitAssociationMatchSource, TaskCommitAssociationConfidence, GithubIssueAction, MergeQueueEntry, MergeQueueEnqueueOptions, MergeQueueAcquireOptions, MergeQueueReleaseOutcome, HandoffToReviewOptions, GoalCitation, GoalCitationFilter, GoalCitationInput, GoalCitationSurface, BranchGroup, BranchGroupCreateInput, BranchGroupUpdate, TaskBranchAssignmentMode, MergeRequestRecord, MergeRequestState, CompletionHandoffMarker } from "./types.js"; import { createActivityLogSnapshot, createRunAuditSnapshot, createTaskMetadataSnapshot, toTaskMetadataRecord, validateSnapshotEnvelope, type ActivityLogSnapshot, type RunAuditSnapshot, type TaskMetadataSnapshot } from "./shared-mesh-state.js"; -import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js"; +import { VALID_TRANSITIONS, COLUMNS, DEFAULT_SETTINGS, isGlobalOnlySettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js"; import { DEFAULT_PROJECT_SETTINGS } from "./settings-schema.js"; -import { parseWorkflowIr, serializeWorkflowIr } from "./workflow-ir.js"; +import { parseWorkflowIr, serializeWorkflowIr, downgradeIrToV1IfPure } from "./workflow-ir.js"; +import { isWorkflowColumnsEnabled } from "./workflow-columns-settings.js"; +import { resolveAllowedColumns, workflowHasColumn } from "./workflow-transitions.js"; +import { + type PluginGateVerdict, + findWorkflowColumn, + resolveColumnPluginGates, +} from "./plugin-gate-verdict.js"; +import { getTraitRegistry, assertColumnTraitsValid } from "./trait-registry.js"; +import { resolveColumnCapacity, DEFAULT_WORKFLOW_POOL_ID } from "./workflow-capacity.js"; +import { + OccupiedColumnsError, + assertRehomeTargetValid, + computeRemovedOccupiedColumns, + resolveEntryColumnId, + resolveSwitchReconciliation, + runReconciliationAbort, +} from "./workflow-reconciliation.js"; +import { + type DefaultWorkflowMoveContext, + applyDefaultWorkflowMoveEffects, + evaluateMergeBlockerGuard, + registerDefaultWorkflowHooks, +} from "./default-workflow-hooks.js"; +import { + type TransitionRejection, + makeTransitionRejection, + makeTransitionPending, +} from "./transition-types.js"; +import { + writeTransitionPending, + clearTransitionPending, + readTransitionPending, + reconcileHooksRemaining, +} from "./transition-pending.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js"; +import type { WorkflowIr, WorkflowIrColumn } from "./workflow-ir-types.js"; +// Side-effect import: registers the 14 built-in trait DEFINITIONS into the +// shared trait registry on load (the flag-ON path resolves traits by id). +import "./builtin-traits.js"; import type { WorkflowDefinition, WorkflowDefinitionInput, @@ -19,8 +58,11 @@ import { BUILTIN_WORKFLOWS, getBuiltinWorkflow, isBuiltinWorkflowId } from "./bu import { WORKFLOW_PARITY_OBSERVED_MUTATION, WORKFLOW_PARITY_DRIFT_MUTATION, + DUAL_ACCEPT_PARITY_MUTATIONS, + computeWorkflowColumnsGraduationReport, type WorkflowParityDiff, type WorkflowParitySummary, + type WorkflowColumnsGraduationReport, } from "./workflow-parity.js"; /** Tags WorkflowStep rows materialized by compiling a workflow so they can be @@ -649,7 +691,7 @@ function deepMergeWithNullDelete( export interface TaskStoreEvents { "task:created": [task: Task]; - "task:moved": [data: { task: Task; from: Column; to: Column; source: "user" | "engine" }]; + "task:moved": [data: { task: Task; from: ColumnId; to: ColumnId; source: "user" | "engine" | "scheduler" }]; "task:updated": [task: Task]; "task:deleted": [task: Task, meta?: { githubIssueAction?: GithubIssueAction }]; "task:merged": [result: MergeResult]; @@ -1039,7 +1081,7 @@ export class InvalidMergeQueueLeaseDurationError extends Error { export class HandoffInvariantViolationError extends Error { constructor( public readonly taskId: string, - public readonly fromColumn: Column, + public readonly fromColumn: ColumnId, message: string, ) { super(message); @@ -1047,15 +1089,58 @@ export class HandoffInvariantViolationError extends Error { } } +/** + * Thrown by the flag-ON (`workflowColumns`) `moveTaskInternal` path when a move + * is rejected, carrying the typed {@link TransitionRejection} (KTD-3/R13). The + * existing callers of `moveTask` catch thrown `Error`s (e.g. the dashboard move + * route inspects `err.message`), so the rejection rides on an `Error` subclass + * — `.message` reproduces the legacy human-readable string so flag-ON callers + * that only read the message keep working, while `.rejection` exposes the + * machine-stable code/messageKey/retryable for surfaces that want it. + * + * The FLAG-OFF path still throws the bare legacy `Error` strings unchanged + * (zero behavior change while the flag is off — proven by the characterization + * suite). + */ +export class TransitionRejectionError extends Error { + readonly rejection: TransitionRejection; + constructor(rejection: TransitionRejection, message: string) { + super(message); + this.name = "TransitionRejectionError"; + this.rejection = rejection; + } +} + interface MoveTaskOptions { preserveResumeState?: boolean; preserveProgress?: boolean; preserveWorktree?: boolean; preserveStatus?: boolean; allocateWorktree?: (reservedNames: Set) => string | null; - moveSource?: "user" | "engine"; + moveSource?: "user" | "engine" | "scheduler"; skipMergeBlocker?: boolean; allowDirectInReviewMove?: boolean; + /** + * KTD-9: engine/recovery moves bypass trait guards and abort-on-exit effects + * (the generalization of `skipMergeBlocker`). It NEVER bypasses capacity + * (KTD-10). Engine-internal only: HTTP move endpoints hardcode it off and must + * never forward a caller-supplied value (mirrors the hardcoded + * `moveSource: "user"` posture). When unset, the flag-ON path derives it from + * `moveSource === "engine"` plus `skipMergeBlocker`. + */ + bypassGuards?: boolean; + /** + * U5 (R15/R20): a workflow-reconciliation re-home move (switch/edit/delete). + * Unlike `bypassGuards` (which skips trait guards but still enforces the + * column-graph adjacency, so the U4 parity matrix is unaffected), a recovery + * re-home must reach the new workflow's entry column from ANY current column — + * a card that would otherwise be stranded in a column its (new) workflow does + * not define. So this additionally skips the adjacency check (step 2). The + * structural unknown-column check (step 1) and the in-txn capacity check + * (KTD-10) still apply. Engine-internal only: never forwarded from an HTTP + * endpoint. When set, implies `bypassGuards`. + */ + recoveryRehome?: boolean; } interface MoveTaskInternalOptions { @@ -1068,6 +1153,12 @@ interface MoveTaskInternalOptions { export class TaskStore extends EventEmitter { private static readonly ACTIVE_TASKS_WHERE = '"deletedAt" IS NULL'; + /** U6: sentinel effective-workflow id for default-workflow (null-selection) + * tasks, so they all share one per-column capacity pool (KTD-10). It is not a + * real workflow row id (no `builtin:`/custom collision possible). Re-exposed + * as a static member for internal call sites; the canonical const lives in + * `workflow-capacity.ts` (`DEFAULT_WORKFLOW_POOL_ID`). */ + private static readonly DEFAULT_WORKFLOW_POOL_ID = DEFAULT_WORKFLOW_POOL_ID; static async getOrCreateForProject( projectId?: string, @@ -1134,6 +1225,15 @@ export class TaskStore extends EventEmitter { private watcher: FSWatcher | null = null; /** In-memory cache of tasks for diffing watcher events */ private taskCache: Map = new Map(); + /** + * U8 (KTD-2): pre-evaluated plugin gate verdicts, keyed `taskId` → `toColumn` + * → recorded verdicts (one per plugin gate trait). A plugin gate is evaluated + * OUTSIDE the lock by the engine's trait adapter; the verdict is recorded here + * and re-checked cheaply in-lock at move time so plugin code never blocks or + * wedges the task lock. Kept in-memory (minimal/surgical per U8); the + * `plugin-gate-verdict.ts` seam can later back this with SQLite. + */ + private pluginGateVerdicts: Map> = new Map(); /** Paths recently written by in-process mutations (suppresses duplicate events) */ private recentlyWritten: Set = new Set(); /** Pending debounce timers keyed by task ID */ @@ -1395,7 +1495,14 @@ export class TaskStore extends EventEmitter { async init(): Promise { await mkdir(this.tasksDir, { recursive: true }); - + + // U4: register the default-workflow trait hook implementations into the + // shared trait registry (the flag-ON moveTaskInternal path resolves the + // legacy per-column effects through these). Idempotent; built-in trait + // DEFINITIONS self-register on import of ./builtin-traits.js (pulled in + // transitively via default-workflow-hooks / trait-registry). + registerDefaultWorkflowHooks(); + // Initialize SQLite database if (!this._db) { // Startup corruption guard: before opening, detect a malformed fusion.db @@ -1493,6 +1600,31 @@ export class TaskStore extends EventEmitter { error: err instanceof Error ? err.message : String(err), }); } + + // U12: workflow-columns integrity pass. When the flag is ON, audit + re-home + // any task whose stored column is no longer valid in its resolved workflow + // (KTD-1 guarantees zero rewrites for healthy legacy rows, so this is a + // no-op for the common case). Idempotent; non-fatal — never blocks startup. + try { + const settings = await this.getSettingsFast(); + if (isWorkflowColumnsEnabled(settings)) { + await this.runWorkflowColumnsIntegrityPass(); + // #1401: recover any transitionPending markers stranded by a crash + // between the in-txn write and the post-commit clear (they otherwise + // permanently inflate capacity counts for their target column). + await this.recoverStaleTransitionPending(); + } else { + // #1409: flag-OFF init — evacuate any card stuck in a non-legacy column + // (e.g. the flag was toggled OFF out-of-process while a card sat in a + // custom column) so the board stays listable and moves work. + await this.evacuateCustomColumnsToLegacy("flag-off-init"); + } + } catch (err) { + storeLog.warn("workflowColumns integrity pass failed during init", { + phase: "init:workflow-columns-integrity", + error: err instanceof Error ? err.message : String(err), + }); + } } // ── Row <-> Task Conversion ──────────────────────────────────────── @@ -3251,6 +3383,20 @@ export class TaskStore extends EventEmitter { const updatedMerged: Settings = { ...DEFAULT_SETTINGS, ...globalSettings, ...updatedProjectSettings } as Settings; this.emit("settings:updated", { settings: updatedMerged, previous: previousMerged }); + // #1409: if this update flipped workflowColumns ON→OFF, evacuate any card + // stranded in a custom (non-legacy) column back to a legacy column so the + // board stays listable / movable on the legacy path. + if (isWorkflowColumnsEnabled(previousMerged) && !isWorkflowColumnsEnabled(updatedMerged)) { + try { + await this.evacuateCustomColumnsToLegacy("flag-toggled-off"); + } catch (err) { + storeLog.warn("workflowColumns ON→OFF evacuation failed", { + phase: "evacuate-custom-columns", + error: err instanceof Error ? err.message : String(err), + }); + } + } + // Bootstrap project memory file when memory is toggled on if (updatedMerged.memoryEnabled !== false && previousMerged.memoryEnabled === false) { try { @@ -3351,6 +3497,21 @@ export class TaskStore extends EventEmitter { // Emit settings:updated so SSE listeners pick up the change this.emit("settings:updated", { settings: merged, previous }); + + // #1409: workflowColumns lives in experimentalFeatures (a global key), so the + // ON→OFF toggle flows through here. Evacuate any card stranded in a custom + // column when the flag flips off. + if (isWorkflowColumnsEnabled(previous) && !isWorkflowColumnsEnabled(merged)) { + try { + await this.evacuateCustomColumnsToLegacy("flag-toggled-off"); + } catch (err) { + storeLog.warn("workflowColumns ON→OFF evacuation failed", { + phase: "evacuate-custom-columns", + error: err instanceof Error ? err.message : String(err), + }); + } + } + return merged; } @@ -4245,7 +4406,7 @@ export class TaskStore extends EventEmitter { id: candidate.id, title: candidate.title ?? "", description: candidate.description, - column: "todo" as Column, + column: "todo", createdAt: Date.parse(candidate.createdAt), sourceAgentId: candidate.sourceAgentId, sourceParentTaskId: null, @@ -4730,8 +4891,9 @@ export class TaskStore extends EventEmitter { * from each row to make list responses cheap for board-style consumers. Detail fields default * to empty arrays in the returned Task objects; use `getTask(id)` to load full data. */ slim?: boolean; - /** Restrict to a single column (e.g. 'in-review' for the auto-merge sweep). */ - column?: Column; + /** Restrict to a single column (e.g. 'in-review' for the auto-merge sweep). + * Widened to {@link ColumnId} (#1403) so custom-column filters are accepted. */ + column?: ColumnId; /** Opt-in startup-only memo for repeated slim reads during boot choreography. */ startupMemo?: boolean; }): Promise { @@ -4885,6 +5047,173 @@ export class TaskStore extends EventEmitter { return sorted.slice(offset, offset + Math.max(0, limit)); } + /** + * Residual B (U13/U9): per-branch progress snapshots for the given tasks, + * read from the `workflow_run_branches` table. Used to populate the optional + * additive `branchProgress` field on the board task payload so U9's parallel- + * window badge can render. Cheap and additive: + * - returns an empty map immediately when the table is empty (the common + * case — no fan-out runs in flight); + * - one query for the whole task batch (no per-card N+1); + * - returns only the LATEST run's branches per task (a card is in exactly + * one parallel window at a time — KTD-11 one-card-one-position). + * Never throws on a missing/legacy table (additive guard). + */ + getBranchProgressByTask( + taskIds: readonly string[], + ): Map> { + const result = new Map>(); + if (taskIds.length === 0) return result; + try { + // Skip entirely when the table has no rows (cheap existence probe). + const any = this.db + .prepare("SELECT 1 FROM workflow_run_branches LIMIT 1") + .get(); + if (!any) return result; + + const placeholders = taskIds.map(() => "?").join(", "); + // Filter to the latest run per task entirely in SQL (#1413): the + // correlated subquery resolves the winning (updatedAt, runId) pair per + // task — MAX(updatedAt) with a deterministic MAX(runId) tie-break — and + // the JOIN matches both columns so only the latest run's rows are read. + // The runId tie-break makes ties on updatedAt deterministic instead of + // letting an arbitrary historical run win. + const rows = this.db + .prepare( + `SELECT b.taskId AS taskId, b.runId AS runId, b.branchId AS branchId, + b.currentNodeId AS nodeId, b.status AS status, b.updatedAt AS updatedAt + FROM workflow_run_branches b + JOIN ( + -- Resolve the winning run per task: the run owning the row with + -- the greatest updatedAt, with runId as a deterministic + -- tie-break when two runs share an updatedAt. Returns the whole + -- run's rows (all its branches), not just the single max row. + SELECT taskId, runId AS latestRunId + FROM ( + SELECT taskId, runId, + ROW_NUMBER() OVER ( + PARTITION BY taskId + ORDER BY MAX(updatedAt) DESC, runId DESC + ) AS rn + FROM workflow_run_branches + WHERE taskId IN (${placeholders}) + GROUP BY taskId, runId + ) + WHERE rn = 1 + ) latest_run + ON latest_run.taskId = b.taskId + AND latest_run.latestRunId = b.runId + WHERE b.taskId IN (${placeholders})`, + ) + .all(...taskIds, ...taskIds) as Array<{ + taskId: string; + runId: string; + branchId: string; + nodeId: string; + status: string; + updatedAt: string; + }>; + + for (const row of rows) { + const list = result.get(row.taskId) ?? []; + list.push({ branchId: row.branchId, nodeId: row.nodeId, status: row.status }); + result.set(row.taskId, list); + } + } catch { + // Legacy/missing table or query failure — degrade to no branch progress. + return new Map(); + } + return result; + } + + /** + * Persist (idempotent upsert) one branch's progress for a fan-out run (#1407). + * Keyed by (taskId, runId, branchId) — the table PK — so re-running the same + * branch overwrites its single row with the latest currentNodeId/status. The + * executor's crash-resume reads only `status = 'completed'` rows and skips + * those nodes, so resume granularity is keyed by the persisted currentNodeId. + * Additive: silently no-ops on a legacy/missing table. + */ + saveWorkflowRunBranch(state: { + taskId: string; + runId: string; + branchId: string; + currentNodeId: string; + status: string; + }): void { + try { + this.db + .prepare( + `INSERT INTO workflow_run_branches + (taskId, runId, branchId, currentNodeId, status, updatedAt) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(taskId, runId, branchId) DO UPDATE SET + currentNodeId = excluded.currentNodeId, + status = excluded.status, + updatedAt = excluded.updatedAt`, + ) + .run( + state.taskId, + state.runId, + state.branchId, + state.currentNodeId, + state.status, + new Date().toISOString(), + ); + } catch { + // Legacy/missing table — persistence is additive, so degrade silently. + } + } + + /** Load persisted branch states for a run (crash-resume; #1407). */ + loadWorkflowRunBranches( + taskId: string, + runId: string, + ): Array<{ + taskId: string; + runId: string; + branchId: string; + currentNodeId: string; + status: "running" | "completed" | "failed" | "aborted"; + }> { + try { + const rows = this.db + .prepare( + `SELECT taskId, runId, branchId, currentNodeId, status + FROM workflow_run_branches + WHERE taskId = ? AND runId = ?`, + ) + .all(taskId, runId) as Array<{ + taskId: string; + runId: string; + branchId: string; + currentNodeId: string; + status: "running" | "completed" | "failed" | "aborted"; + }>; + return rows; + } catch { + return []; + } + } + + /** + * Prune stale branch rows for a task (#1412). Deletes every row for `taskId` + * whose runId differs from the supplied `keepRunId`, bounding growth across a + * long-lived task's repeated runs. Called on run start and run completion. + * Additive: silently no-ops on a legacy/missing table. + */ + clearWorkflowRunBranches(taskId: string, keepRunId: string): void { + try { + this.db + .prepare( + `DELETE FROM workflow_run_branches WHERE taskId = ? AND runId != ?`, + ) + .run(taskId, keepRunId); + } catch { + // Legacy/missing table — pruning is additive, so degrade silently. + } + } + async listTasksForGithubTrackingReconcile(options?: { offset?: number; limit?: number }): Promise<{ tasks: Task[]; hasMore: boolean }> { const reconcileScanLimit = 200; const offset = Math.max(0, options?.offset ?? 0); @@ -5496,9 +5825,12 @@ export class TaskStore extends EventEmitter { async moveTask( id: string, - toColumn: Column, + toColumn: ColumnId, options?: MoveTaskOptions, ): Promise { + // ColumnId admits workflow-defined custom column ids (KTD-1). Both paths + // runtime-validate: flag-ON against the task's resolved workflow, flag-OFF + // via the VALID_TRANSITIONS lookup (non-legacy ids reject as before). return this.withTaskLock(id, () => this.moveTaskInternal(id, toColumn, options, { fromHandoff: false })); } @@ -5533,6 +5865,9 @@ export class TaskStore extends EventEmitter { { ...opts.moveOptions, skipMergeBlocker: true, + // KTD-9: handoff is an engine/recovery-class move; its skipMergeBlocker + // maps onto bypassGuards under the flag (identical behavior both paths). + bypassGuards: true, }, { fromHandoff: true, @@ -5551,7 +5886,7 @@ export class TaskStore extends EventEmitter { private async moveTaskInternal( id: string, - toColumn: Column, + toColumn: ColumnId, options: MoveTaskOptions | undefined, internal: MoveTaskInternalOptions, currentTask?: Task, @@ -5560,6 +5895,30 @@ export class TaskStore extends EventEmitter { const task = currentTask ?? await this.readTaskForMove(id); const moveSource = options?.moveSource ?? "engine"; + // ── U4: flag-gated workflow-resolved transition path (KTD-8) ───────────── + // Flag OFF (default): the legacy `VALID_TRANSITIONS` / inline-side-effect + // path below runs byte-identical (proven by the characterization suite). + // Flag ON: validate against the task's resolved workflow column graph, run + // sync trait guards (unless bypassed), and route the legacy per-column side + // effects through the default-workflow trait hooks. + // `experimentalFeatures` is a global-scoped setting, so the project-only + // `getSettingsSync()` row would miss it — read merged settings (global + + // project) via getSettingsFast(). This is an async read taken before the + // lock-sensitive transaction; it does not touch the task lock. + const mergedSettingsForMove = await this.getSettingsFast(); + const useWorkflow = isWorkflowColumnsEnabled(mergedSettingsForMove); + // bypassGuards (KTD-9): engine-sourced moves + the existing skipMergeBlocker + // call sites map onto it. Capacity (KTD-10) is NEVER bypassed by this — the + // capacity check is not a guard (U6 fills the enforcement; U4 leaves a + // pass-through slot). An explicit option value wins; otherwise derive it. + const bypassGuards = + options?.recoveryRehome === true || + (options?.bypassGuards ?? + (moveSource === "engine" || moveSource === "scheduler" || options?.skipMergeBlocker === true)); + const workflowIr: WorkflowIr | undefined = useWorkflow + ? this.resolveTaskWorkflowIrSync(id) + : undefined; + if (task.column === toColumn) { if (internal.fromHandoff && toColumn === "in-review") { this.db.transactionImmediate(() => { @@ -5616,19 +5975,146 @@ export class TaskStore extends EventEmitter { return task; } - const validTargets = VALID_TRANSITIONS[task.column]; - if (!validTargets.includes(toColumn)) { - throw new Error( - `Invalid transition: '${task.column}' → '${toColumn}'. ` + - `Valid targets: ${validTargets.join(", ") || "none"}`, - ); - } - const fromColumn = task.column; - if (fromColumn === "in-review" && toColumn === "done" && !options?.skipMergeBlocker) { - const mergeBlocker = getTaskMergeBlocker(task); - if (mergeBlocker) { - throw new Error(`Cannot move ${id} to done: ${mergeBlocker}`); + + if (useWorkflow && workflowIr) { + // ── Flag-ON validation + sync guards (typed rejections, KTD-3/R13) ───── + // 1. Target column must exist in the task's workflow → unknown-column. + // #1411: a recoveryRehome move to a LEGACY column (todo/archived/…) is + // the engine's self-healing rescue path — those targets are guaranteed + // safe landing columns even when a custom workflow never defined them. + // recoveryRehome already skips adjacency (below); it must likewise skip + // the unknown-column rejection for legacy recovery targets, otherwise a + // custom-workflow card could never be rescued to todo/archived and would + // stay stuck — the exact bug #1411 describes. Non-legacy unknown targets + // still reject (a genuine programming error), and normal (non-recovery) + // moves are unaffected. + const recoveryToLegacy = + options?.recoveryRehome === true && (COLUMNS as readonly string[]).includes(toColumn); + if (!workflowHasColumn(workflowIr, toColumn) && !recoveryToLegacy) { + throw new TransitionRejectionError( + makeTransitionRejection( + "unknown-column", + "transition.rejected.unknownColumn", + false, + `Column '${toColumn}' is not defined in this task's workflow`, + ), + `Invalid transition: '${fromColumn}' → '${toColumn}'. Unknown column for this workflow.`, + ); + } + // 2. Column-graph adjacency. For the default workflow this reproduces + // VALID_TRANSITIONS verbatim (resolveAllowedColumns); the + // transition-parity suite machine-checks the equivalence. A U5 recovery + // re-home (recoveryRehome) skips this so a stranded card can reach its + // new workflow's entry column from any current column. + const allowed = resolveAllowedColumns(workflowIr, fromColumn); + if (options?.recoveryRehome !== true && !allowed.includes(toColumn)) { + throw new TransitionRejectionError( + makeTransitionRejection( + "guard-rejected", + "transition.rejected.invalidTransition", + false, + `Valid targets: ${allowed.join(", ") || "none"}`, + ), + `Invalid transition: '${fromColumn}' → '${toColumn}'. ` + + `Valid targets: ${allowed.join(", ") || "none"}`, + ); + } + // 3. Sync trait guards (in-lock). Skipped entirely when bypassGuards + // (engine/recovery moves, KTD-9). The default workflow's merge-blocker + // trait reads the same getTaskMergeBlocker. + if (!bypassGuards) { + const guardReason = evaluateMergeBlockerGuard(task, fromColumn, toColumn); + if (guardReason) { + throw new TransitionRejectionError( + makeTransitionRejection( + "merge-blocked", + "transition.rejected.mergeBlocked", + true, + guardReason, + ), + `Cannot move ${id} to done: ${guardReason}`, + ); + } + // 4. Plugin gate verdict re-check (U8, KTD-2). For each PLUGIN gate trait + // on the target column, consume the pre-evaluated verdict (recorded by + // the engine's trait adapter outside the lock). A blocking gate with + // no recorded `allow` verdict fails closed (typed rejection); advisory + // gates record-and-allow. Built-in gates are handled by their own + // path; this guard is the plugin gate surface only. + const registry = getTraitRegistry(); + const pluginGates = resolveColumnPluginGates( + findWorkflowColumn(workflowIr, toColumn), + (tid) => registry.getTrait(tid), + ); + if (pluginGates.length > 0) { + const recorded = this.consumePluginGateVerdicts(id, toColumn); + const byTrait = new Map(recorded.map((v) => [v.traitId, v])); + for (const gate of pluginGates) { + if (gate.gateMode === "advisory") continue; // record-and-allow + // Degraded (force-disabled) plugin gate: its hook impl is gone, so + // the registry resolves it to a no-op + audit warning (KTD-7). A + // degraded gate is PASSIVE — the column never blocks the card; the + // registry's warning is the audit signal. Cards remain movable. + const resolved = registry.resolveTraitHook(gate.traitId, "gate"); + if (resolved.warning) continue; + const verdict = byTrait.get(gate.traitId); + // Fail closed: a blocking gate with no recorded allow verdict rejects. + if (!verdict || !verdict.allow) { + const reason = + verdict?.detail ?? + (verdict + ? `Gate '${gate.traitId}' did not pass` + : `Gate '${gate.traitId}' has not been evaluated for this move`); + throw new TransitionRejectionError( + makeTransitionRejection( + "merge-blocked", + "transition.rejected.gateBlocked", + true, + reason, + ), + `Cannot move ${id} to '${toColumn}': ${reason}`, + ); + } + } + } + } + } else { + // ── Flag-OFF legacy path (unchanged) ─────────────────────────────────── + // A task can sit in a custom column when the flag was toggled ON→OFF; + // `VALID_TRANSITIONS` only keys the legacy columns, so a missing entry + // degrades to the legacy "Invalid transition" error instead of a TypeError. + // #1409: flag-OFF evacuation. A recoveryRehome move OUT of a non-legacy + // (custom) column into a legacy target is the ON→OFF evacuation path — + // `VALID_TRANSITIONS` never keys a custom source column, so the legacy + // check below would strand the card forever. Allow it through (bypassing + // only the adjacency check; this is unreachable for normal flag-OFF moves, + // which never set recoveryRehome and always start from a legacy column, so + // characterization behavior is byte-identical). + const sourceIsLegacy = (COLUMNS as readonly string[]).includes(task.column); + const isEvacuation = + options?.recoveryRehome === true && + !sourceIsLegacy && + (COLUMNS as readonly string[]).includes(toColumn); + if (!isEvacuation) { + // Legacy flag-OFF branch (useWorkflow === false): both columns are + // guaranteed legacy ids here — a non-legacy `toColumn` returns `?? []` + // and rejects below, and flag-OFF tasks never hold custom column ids. + // The `as Column` is provably safe within this branch (#1403). + const validTargets = VALID_TRANSITIONS[task.column as Column] ?? []; + if (!validTargets.includes(toColumn as Column)) { + throw new Error( + `Invalid transition: '${task.column}' → '${toColumn}'. ` + + `Valid targets: ${validTargets.join(", ") || "none"}`, + ); + } + } + + if (fromColumn === "in-review" && toColumn === "done" && !options?.skipMergeBlocker) { + const mergeBlocker = getTaskMergeBlocker(task); + if (mergeBlocker) { + throw new Error(`Cannot move ${id} to done: ${mergeBlocker}`); + } } } @@ -5642,106 +6128,153 @@ export class TaskStore extends EventEmitter { task.columnMovedAt = movedAt; task.updatedAt = movedAt; - if (fromColumn === "in-progress" && toColumn !== "in-progress") { - const segmentStartMs = Date.parse(task.executionStartedAt ?? task.columnMovedAt); - const segmentEndMs = Date.parse(task.columnMovedAt); - const segmentDeltaMs = - Number.isFinite(segmentStartMs) && Number.isFinite(segmentEndMs) - ? Math.max(0, segmentEndMs - segmentStartMs) - : 0; - task.cumulativeActiveMs = Math.max(0, task.cumulativeActiveMs ?? 0) + segmentDeltaMs; - } - - if (toColumn === "in-progress") { - task.cumulativeActiveMs ??= 0; - if (!task.firstExecutionAt) { - task.firstExecutionAt = task.columnMovedAt; - } - if (!task.executionStartedAt) { - task.executionStartedAt = task.columnMovedAt; - } - task.userPaused = undefined; - } - if (toColumn === "done" && !task.executionCompletedAt) { - task.executionCompletedAt = task.columnMovedAt; - } - - if (toColumn === "done") { - this.clearDoneTransientFields(task); - } - - const isReopenToTodoOrTriage = - (fromColumn === "in-progress" || fromColumn === "done" || fromColumn === "in-review") - && (toColumn === "todo" || toColumn === "triage"); - - if (isReopenToTodoOrTriage) { - if (!options?.preserveStatus) { - task.status = undefined; - task.error = undefined; - task.pausedReason = undefined; - } - task.blockedBy = undefined; - task.overlapBlockedBy = undefined; - task.paused = undefined; - task.pausedByAgentId = undefined; - if (moveSource === "user" && toColumn === "todo") { - task.userPaused = true; - } else { - task.userPaused = undefined; - } - + if (useWorkflow) { + // ── Flag-ON: route the legacy per-column side effects through the + // default-workflow trait hooks (timing, reset-on-entry, abort-on-exit, + // merge.onEnter). "Moved, not duplicated" applies to this path; the + // flag-off branch below keeps the legacy inline code verbatim. ─────── + const ctx: DefaultWorkflowMoveContext = { + task, + fromColumn, + toColumn, + moveSource, + bypassGuards, + movedAt, + settings: settingsForInReview, + options: { + preserveStatus: options?.preserveStatus, + preserveResumeState: options?.preserveResumeState, + preserveProgress: options?.preserveProgress, + preserveWorktree: options?.preserveWorktree, + }, + resetSteps: () => this.resetAllStepsToPending(task), + }; + const isReopenToTodoOrTriage = + (fromColumn === "in-progress" || fromColumn === "done" || fromColumn === "in-review") && + (toColumn === "todo" || toColumn === "triage"); const hasNonPendingStepProgress = task.steps.some((step) => step.status !== "pending"); const preserveStepProgress = - options?.preserveResumeState || (options?.preserveProgress === true && hasNonPendingStepProgress); - - if (!options?.preserveWorktree) { - task.worktree = undefined; + options?.preserveResumeState || + (options?.preserveProgress === true && hasNonPendingStepProgress); + const { warnings } = applyDefaultWorkflowMoveEffects(ctx); + for (const warning of warnings) { + storeLog.warn("Default-workflow trait hook degraded to no-op", { + phase: "moveTaskInternal:workflow-hooks", + taskId: id, + ...warning, + }); } - - if (!options?.preserveResumeState) { - task.executionStartedAt = undefined; - task.executionCompletedAt = undefined; - } else { - task.executionCompletedAt = undefined; + // Store-owned effects the hooks intentionally do NOT perform (filesystem / + // store-private): clearing done transient fields + prompt-checkbox reset. + if (toColumn === "done") { + this.clearDoneTransientFields(task); } - - if (!preserveStepProgress) { - this.resetAllStepsToPending(task); + if (isReopenToTodoOrTriage && !preserveStepProgress) { await this.resetPromptCheckboxes(dir); } - } - - if (toColumn === "in-review") { - if (task.autoMerge === undefined && settingsForInReview) { - task.autoMerge = settingsForInReview.autoMerge; + } else { + // ── Flag-OFF legacy inline side effects (UNCHANGED — the flag-off path) ── + if (fromColumn === "in-progress" && toColumn !== "in-progress") { + const segmentStartMs = Date.parse(task.executionStartedAt ?? task.columnMovedAt); + const segmentEndMs = Date.parse(task.columnMovedAt); + const segmentDeltaMs = + Number.isFinite(segmentStartMs) && Number.isFinite(segmentEndMs) + ? Math.max(0, segmentEndMs - segmentStartMs) + : 0; + task.cumulativeActiveMs = Math.max(0, task.cumulativeActiveMs ?? 0) + segmentDeltaMs; } - task.recoveryRetryCount = undefined; - task.nextRecoveryAt = undefined; - // Clear scheduler-side dispatch state: `queued`, `blockedBy`, and - // `overlapBlockedBy` are stamped while the task waits in `todo`. If - // they survive the transition into `in-review` they permanently block - // the merge gate (see getTaskMergeBlocker's BLOCKING_TASK_STATUSES). - if (task.status === "queued") { - task.status = undefined; + + if (toColumn === "in-progress") { + task.cumulativeActiveMs ??= 0; + if (!task.firstExecutionAt) { + task.firstExecutionAt = task.columnMovedAt; + } + if (!task.executionStartedAt) { + task.executionStartedAt = task.columnMovedAt; + } + task.userPaused = undefined; + } + if (toColumn === "done" && !task.executionCompletedAt) { + task.executionCompletedAt = task.columnMovedAt; } - task.blockedBy = undefined; - task.overlapBlockedBy = undefined; - } - if ( - (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "in-progress" || toColumn === "triage")) - || (fromColumn === "done" && (toColumn === "todo" || toColumn === "triage")) - ) { - task.workflowStepResults = undefined; - } + if (toColumn === "done") { + this.clearDoneTransientFields(task); + } - if (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "triage")) { - task.branch = undefined; - task.executionStartBranch = undefined; - task.baseCommitSha = undefined; - task.summary = undefined; - task.recoveryRetryCount = undefined; - task.nextRecoveryAt = undefined; + const isReopenToTodoOrTriage = + (fromColumn === "in-progress" || fromColumn === "done" || fromColumn === "in-review") + && (toColumn === "todo" || toColumn === "triage"); + + if (isReopenToTodoOrTriage) { + if (!options?.preserveStatus) { + task.status = undefined; + task.error = undefined; + task.pausedReason = undefined; + } + task.blockedBy = undefined; + task.overlapBlockedBy = undefined; + task.paused = undefined; + task.pausedByAgentId = undefined; + if (moveSource === "user" && toColumn === "todo") { + task.userPaused = true; + } else { + task.userPaused = undefined; + } + + const hasNonPendingStepProgress = task.steps.some((step) => step.status !== "pending"); + const preserveStepProgress = + options?.preserveResumeState || (options?.preserveProgress === true && hasNonPendingStepProgress); + + if (!options?.preserveWorktree) { + task.worktree = undefined; + } + + if (!options?.preserveResumeState) { + task.executionStartedAt = undefined; + task.executionCompletedAt = undefined; + } else { + task.executionCompletedAt = undefined; + } + + if (!preserveStepProgress) { + this.resetAllStepsToPending(task); + await this.resetPromptCheckboxes(dir); + } + } + + if (toColumn === "in-review") { + if (task.autoMerge === undefined && settingsForInReview) { + task.autoMerge = settingsForInReview.autoMerge; + } + task.recoveryRetryCount = undefined; + task.nextRecoveryAt = undefined; + // Clear scheduler-side dispatch state: `queued`, `blockedBy`, and + // `overlapBlockedBy` are stamped while the task waits in `todo`. If + // they survive the transition into `in-review` they permanently block + // the merge gate (see getTaskMergeBlocker's BLOCKING_TASK_STATUSES). + if (task.status === "queued") { + task.status = undefined; + } + task.blockedBy = undefined; + task.overlapBlockedBy = undefined; + } + + if ( + (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "in-progress" || toColumn === "triage")) + || (fromColumn === "done" && (toColumn === "todo" || toColumn === "triage")) + ) { + task.workflowStepResults = undefined; + } + + if (fromColumn === "in-review" && (toColumn === "todo" || toColumn === "triage")) { + task.branch = undefined; + task.executionStartBranch = undefined; + task.baseCommitSha = undefined; + task.summary = undefined; + task.recoveryRetryCount = undefined; + task.nextRecoveryAt = undefined; + } } if (toColumn === "in-progress" && !task.worktree && options?.allocateWorktree) { @@ -5769,6 +6302,41 @@ export class TaskStore extends EventEmitter { return; } + // ── U6: in-txn capacity enforcement (KTD-10) ────────────────────────── + // WIP limits are trait *config*; enforcement is a substrate capability + // that runs HERE, inside the move transaction, so two holds releasing into + // one slot serialize — exactly one commits, the other rejects and retries + // next sweep. It is NOT a guard: it runs regardless of bypassGuards / + // recoveryRehome / moveSource (engine/recovery/scheduler moves honor it + // too). Only a real column change into a capacity-bearing column is gated; + // same-column no-ops were returned earlier. The count is taken with the + // moving task EXCLUDED and the prospective slot it is about to occupy + // added back implicitly (it must fit alongside existing holders), so a + // full column (occupants == limit) rejects. + if (useWorkflow && workflowIr && fromColumn !== toColumn) { + const capacity = resolveColumnCapacity(workflowIr, toColumn, mergedSettingsForMove); + if (capacity.hasCapacity && Number.isFinite(capacity.limit)) { + const workflowId = this.resolveEffectiveWorkflowIdSync(id); + const occupants = this.countActiveInCapacitySlotSync({ + targetColumn: toColumn, + workflowId, + countPending: capacity.countPending, + excludeTaskId: id, + }); + if (occupants >= capacity.limit) { + throw new TransitionRejectionError( + makeTransitionRejection( + "capacity-exhausted", + "transition.rejected.capacityExhausted", + true, + `Column '${toColumn}' is at capacity (${occupants}/${capacity.limit})`, + ), + `Cannot move ${id} to '${toColumn}': column at capacity (${occupants}/${capacity.limit})`, + ); + } + } + } + this.upsertTaskWithFtsRecovery(task); this.insertRunAuditEventRow({ taskId: id, @@ -5785,6 +6353,22 @@ export class TaskStore extends EventEmitter { }); this.dequeueMergeQueueOnColumnExit(id, fromColumn, toColumn, movedAt); + // U4 (flag-ON): write the crash-safe transitionPending marker in the SAME + // transaction as the column change (KTD-2). It records the post-commit + // hooks that still owe idempotent execution so a crash mid-transition is + // recoverable from SQLite (the authoritative store, ADR-0001). The store + // clears it immediately after the post-commit hook runner completes + // (below). For the default workflow the field effects already applied + // in-lock; the marker guards the post-commit completion so recovery never + // double-runs (idempotent) and never strands the card. + if (useWorkflow) { + writeTransitionPending( + this.db, + id, + makeTransitionPending(toColumn, ["default-workflow:postCommit"], Date.parse(movedAt) || Date.now()), + ); + } + if (toColumn === "in-review" && !internal.fromHandoff && options?.allowDirectInReviewMove !== true) { this.insertRunAuditEventRow({ taskId: id, @@ -5859,12 +6443,152 @@ export class TaskStore extends EventEmitter { if (this.isWatching) this.taskCache.set(id, { ...task }); + // U4 (flag-ON): post-commit hook completion. The default-workflow field + // effects already ran in-lock and committed; the post-commit phase here is + // the fire-and-forget hook runner per KTD-2. It is idempotent and clears the + // transitionPending marker once done. A crash before this point leaves the + // marker for the recovery sweep to re-run (re-running is a no-op for the + // default workflow's already-committed field effects). + // + // Residual C (U8): AFTER the built-in effects, invoke registered PLUGIN + // onExit (from column) / onEnter (to column) trait hook impls, recording + // per-hook completion in the marker's hooksRemaining. A throwing plugin hook + // DEGRADES (audit) and never wedges the lock or strands the marker — the + // marker is always cleared at the end regardless of hook failures. + if (useWorkflow) { + // Plugin hooks are skipped on engine/recovery-sourced moves (KTD-9 — those + // bypass trait effects) and on same-column no-ops. + if (!bypassGuards && fromColumn !== toColumn && workflowIr) { + try { + await this.runPluginColumnTransitionHooks(id, workflowIr, fromColumn, toColumn); + } catch (err) { + // The runner itself swallows per-hook failures; this is a final guard + // so a runner-level fault never strands the marker. + storeLog.warn("Plugin column transition hook runner faulted (degraded)", { + phase: "moveTaskInternal:plugin-hooks", + taskId: id, + error: err instanceof Error ? err.message : String(err), + }); + } + } + try { + clearTransitionPending(this.db, id); + } catch { + // Clearing is best-effort; the marker recovery sweep is the backstop. + } + } + if (fromColumn !== toColumn) { this.emit("task:moved", { task, from: fromColumn, to: toColumn, source: moveSource }); } return task; } + /** + * Residual C (U8): run registered PLUGIN onExit (from column) / onEnter (to + * column) trait hook impls AFTER the built-in default-workflow effects, on the + * post-commit path. Plugin hooks are async-only (KTD-7) and route through the + * registry's resolved impl (the engine wires `runCustomNode` in via the trait + * adapter; an unregistered/degraded hook resolves to a no-op + audit warning). + * + * Per-hook completion is recorded in the `transitionPending` marker's + * `hooksRemaining` so a crash mid-hook is recoverable. A hook that THROWS is + * audited (`plugin:trait-hook-degraded`) and treated as completed (removed + * from `hooksRemaining`) — a misbehaving plugin never wedges the task lock or + * strands the card (KTD-2 degraded-not-stranded posture). The caller clears + * the marker after this returns. + */ + private async runPluginColumnTransitionHooks( + taskId: string, + workflowIr: WorkflowIr, + fromColumn: string, + toColumn: string, + ): Promise { + const registry = getTraitRegistry(); + // Collect (traitId, hookKind) pairs: onExit for from-column plugin traits, + // onEnter for to-column plugin traits. Only plugin-namespaced traits (KTD-7). + const pending: Array<{ traitId: string; hookKind: "onEnter" | "onExit" }> = []; + const fromCol = findWorkflowColumn(workflowIr, fromColumn); + for (const ct of fromCol?.traits ?? []) { + if (!ct.trait.startsWith("plugin:")) continue; + const def = registry.getTrait(ct.trait); + if (def?.hooks?.onExit) pending.push({ traitId: ct.trait, hookKind: "onExit" }); + } + const toCol = findWorkflowColumn(workflowIr, toColumn); + for (const ct of toCol?.traits ?? []) { + if (!ct.trait.startsWith("plugin:")) continue; + const def = registry.getTrait(ct.trait); + if (def?.hooks?.onEnter) pending.push({ traitId: ct.trait, hookKind: "onEnter" }); + } + if (pending.length === 0) return; + + // Record the plugin hooks in the marker's hooksRemaining (alongside the + // default-workflow:postCommit marker already written in-txn) so a crash + // mid-hook is recoverable. + const hookIds = pending.map((p) => `${p.traitId}:${p.hookKind}`); + const startedAt = Date.now(); + try { + writeTransitionPending( + this.db, + taskId, + makeTransitionPending(toColumn, ["default-workflow:postCommit", ...hookIds], startedAt), + ); + } catch { + // Marker bookkeeping is best-effort; proceed to run the hooks regardless. + } + + // Read the task once for hook context. MUST be a non-locking read — this + // runs inside `withTaskLock`, so `getTask` (which re-acquires the lock) + // would deadlock. `readTaskFromDb` is the in-lock-safe read. + const taskRow = this.readTaskFromDb(taskId, { includeDeleted: false }); + const taskDetail = taskRow as unknown as TaskDetail | undefined; + + const remaining = ["default-workflow:postCommit", ...hookIds]; + for (const { traitId, hookKind } of pending) { + const resolved = registry.resolveTraitHook(traitId, hookKind); + if (resolved.warning) { + // Degraded (no impl / force-disabled) → passive no-op, audit the warning. + this.recordRunAuditEvent({ + taskId, + agentId: "system", + runId: `plugin-trait-hook-${traitId}-${taskId}-${Date.now()}`, + domain: "database", + mutationType: "plugin:trait-hook-degraded", + target: taskId, + metadata: { traitId, hookKind, reason: "no-impl", message: resolved.warning.message }, + }); + } else if (resolved.impl) { + try { + await resolved.impl({ task: taskDetail, context: { fromColumn, toColumn, hookKind } }); + } catch (err) { + // A throwing plugin hook DEGRADES — audited, never wedges the lock. + this.recordRunAuditEvent({ + taskId, + agentId: "system", + runId: `plugin-trait-hook-${traitId}-${taskId}-${Date.now()}`, + domain: "database", + mutationType: "plugin:trait-hook-degraded", + target: taskId, + metadata: { + traitId, + hookKind, + reason: "threw", + error: err instanceof Error ? err.message : String(err), + }, + }); + } + } + // Mark this hook complete in the marker (whether it ran, degraded, or threw). + const idx = remaining.indexOf(`${traitId}:${hookKind}`); + if (idx >= 0) remaining.splice(idx, 1); + try { + writeTransitionPending(this.db, taskId, makeTransitionPending(toColumn, remaining, startedAt)); + } catch { + // Best-effort progress bookkeeping; the final clear is the backstop. + } + } + } + private resetAllStepsToPending(task: Task): void { if (task.steps.length === 0) { return; @@ -7392,6 +8116,37 @@ export class TaskStore extends EventEmitter { }; } + /** + * Aggregate the `workflowColumns` flag default-flip criteria (U12, KTD-8) into + * a single graduation report: five-invariant dual-observe parity, the default + * workflow's transition parity vs VALID_TRANSITIONS, and the dual-accept + * marker/column disagreement count (U6, FN-5719). The flip is a FIELD decision + * — this report is the GATE. Does NOT flip the flag; callers inspect `ready` + * and `blockers`. + */ + computeWorkflowColumnsGraduationReport( + options: { since?: string; limit?: number } = {}, + ): WorkflowColumnsGraduationReport { + const limit = options.limit ?? 1000; + const parity = this.getWorkflowParitySummary(options); + const dualAcceptEvents: RunAuditEvent[] = []; + for (const mutationType of DUAL_ACCEPT_PARITY_MUTATIONS) { + dualAcceptEvents.push( + ...this.getRunAuditEvents({ + domain: "database", + mutationType: mutationType as unknown as RunAuditEvent["mutationType"], + startTime: options.since, + limit, + }), + ); + } + return computeWorkflowColumnsGraduationReport({ + parity, + defaultWorkflowIr: BUILTIN_CODING_WORKFLOW_IR, + dualAcceptEvents, + }); + } + enqueueMergeQueue(taskId: string, opts: MergeQueueEnqueueOptions = {}): MergeQueueEntry { let invalidColumn: Column | null = null; const entry = this.db.transactionImmediate(() => { @@ -7492,7 +8247,7 @@ export class TaskStore extends EventEmitter { } } - private dequeueMergeQueueOnColumnExit(taskId: string, previousColumn: Column, nextColumn: Column, now: string): void { + private dequeueMergeQueueOnColumnExit(taskId: string, previousColumn: ColumnId, nextColumn: ColumnId, now: string): void { if (previousColumn !== "in-review" || nextColumn === "in-review") { return; } @@ -11219,15 +11974,32 @@ ${stepsSection}`; return {}; } + /** Server-side trait-composition validation (residual A). Throws a typed + * ColumnTraitValidationError when the IR's columns have save-blocking trait + * conflicts, so conflicts reject server-side and not only in the editor. A + * v1 IR (no columns) is a no-op. */ + private assertWorkflowIrTraitsValid(ir: WorkflowIr): void { + const columns = (ir as { columns?: WorkflowIrColumn[] }).columns; + if (Array.isArray(columns) && columns.length > 0) { + assertColumnTraitsValid(columns); + } + } + /** Create a named workflow definition. The IR is validated via parseWorkflowIr. */ async createWorkflowDefinition( input: WorkflowDefinitionInput, ): Promise { + // Rollback compat (#1405): with the flag OFF, persist a pure-v1-equivalent + // graph in the v1 shape so a binary downgrade can still load the row. + const flagOnForCreate = await this.workflowColumnsFlagOn(); return this.withConfigLock(async () => { const name = input.name?.trim(); if (!name) throw new Error("Workflow name is required"); // Validate the IR shape up front so we never persist a malformed graph. const ir = parseWorkflowIr(input.ir); + // Residual A: also reject save-blocking trait composition conflicts here, + // not only in the editor's client-side validation. + this.assertWorkflowIrTraitsValid(ir); const layout = input.layout ?? {}; const now = new Date().toISOString(); const id = this.nextWorkflowDefinitionId(); @@ -11250,7 +12022,9 @@ ${stepsSection}`; definition.id, definition.name, definition.description, - serializeWorkflowIr(definition.ir), + serializeWorkflowIr( + flagOnForCreate ? definition.ir : downgradeIrToV1IfPure(definition.ir), + ), JSON.stringify(definition.layout), definition.createdAt, definition.updatedAt, @@ -11305,13 +12079,48 @@ ${stepsSection}`; updates: WorkflowDefinitionUpdate, ): Promise { if (isBuiltinWorkflowId(id)) throw new Error("Built-in workflows cannot be edited"); - return this.withConfigLock(async () => { + // U5 (R20): flag-ON edits that remove an occupied column block with a typed + // OccupiedColumnsError unless `rehomeTo` is supplied. Computed before taking + // the config lock (pure DB reads) so the lock body stays focused. + const flagOn = await this.workflowColumnsFlagOn(); + let pendingRehome: { rehomeTo: string; occupantTaskIds: string[] } | undefined; + if (flagOn && updates.ir !== undefined) { + const existingForCheck = await this.getWorkflowDefinition(id); + if (!existingForCheck) throw new Error(`Workflow '${id}' not found`); + const nextIrForCheck = parseWorkflowIr(updates.ir); + const occupantsByColumn = this.occupantsByColumnForWorkflow(id, false); + const removed = computeRemovedOccupiedColumns( + existingForCheck.ir, + nextIrForCheck, + occupantsByColumn, + ); + if (removed.length > 0) { + if (updates.rehomeTo === undefined) { + throw new OccupiedColumnsError(id, removed); + } + assertRehomeTargetValid(nextIrForCheck, updates.rehomeTo); + // Collect the occupant task ids of the removed columns to re-home AFTER + // the IR save commits, so the cards land in a column the new IR defines. + const removedSet = new Set(removed.map((r) => r.columnId)); + const occupantTaskIds = this.listWorkflowOccupantTaskIds(id, false).filter((taskId) => { + const row = this.db.prepare(`SELECT "column" AS column FROM tasks WHERE id = ?`).get(taskId) as + | { column: string } + | undefined; + return row ? removedSet.has(row.column) : false; + }); + pendingRehome = { rehomeTo: updates.rehomeTo, occupantTaskIds }; + } + } + const saved = await this.withConfigLock(async () => { const existing = await this.getWorkflowDefinition(id); if (!existing) throw new Error(`Workflow '${id}' not found`); const name = updates.name !== undefined ? updates.name.trim() : existing.name; if (!name) throw new Error("Workflow name is required"); const ir = updates.ir !== undefined ? parseWorkflowIr(updates.ir) : existing.ir; + // Residual A: reject save-blocking trait composition conflicts server-side + // when the IR is being changed. + if (updates.ir !== undefined) this.assertWorkflowIrTraitsValid(ir); const next: WorkflowDefinition = { ...existing, name, @@ -11328,7 +12137,8 @@ ${stepsSection}`; .run( next.name, next.description, - serializeWorkflowIr(next.ir), + // Rollback compat (#1405): persist v1 shape when pure and flag OFF. + serializeWorkflowIr(flagOn ? next.ir : downgradeIrToV1IfPure(next.ir)), JSON.stringify(next.layout), next.updatedAt, id, @@ -11338,6 +12148,18 @@ ${stepsSection}`; this.db.bumpLastModified(); return next; }); + + // U5 (R20): now that the new IR is committed, re-home the occupants of the + // removed columns into `rehomeTo` (one audit event per card). Done outside + // the config lock; each rehome takes its own task lock via moveTask. + if (pendingRehome) { + for (const taskId of pendingRehome.occupantTaskIds) { + await this.rehomeOccupant(taskId, pendingRehome.rehomeTo, "workflow-edit-rehome", { + workflowId: id, + }); + } + } + return saved; } /** Delete a workflow definition, cascading to per-task selections, their @@ -11345,6 +12167,11 @@ ${stepsSection}`; * not exist. */ async deleteWorkflowDefinition(id: string): Promise { if (isBuiltinWorkflowId(id)) throw new Error("Built-in workflows cannot be deleted"); + // U5 (R20): flag-ON, capture the occupant task ids BEFORE the cascade clears + // their selection rows, so we can re-home them to the DEFAULT workflow's + // entry column once their selection resolves back to the default (KTD-1). + const flagOn = await this.workflowColumnsFlagOn(); + const occupantTaskIds = flagOn ? this.listWorkflowOccupantTaskIds(id, false) : []; const deleted = this.db.prepare("DELETE FROM workflows WHERE id = ?").run(id) as { changes?: number }; if ((deleted.changes || 0) === 0) { throw new Error(`Workflow '${id}' not found`); @@ -11388,6 +12215,409 @@ ${stepsSection}`; } if (selections.length > 0) this.workflowStepsCache = null; this.db.bumpLastModified(); + + // U5 (R20) delete reconciliation: re-home each occupant to the default + // workflow's entry column. Their selection rows are already cleared above, + // so they now resolve to the built-in default workflow (KTD-1); the re-home + // move preserves task fields (preserveProgress) and emits one audit per card. + if (flagOn && occupantTaskIds.length > 0) { + const defaultEntry = resolveEntryColumnId(BUILTIN_CODING_WORKFLOW_IR); + if (defaultEntry) { + for (const taskId of occupantTaskIds) { + await this.rehomeOccupant(taskId, defaultEntry, "workflow-delete", { workflowId: id }); + } + } + } + } + + // ── U5: workflow lifecycle reconciliation (switch / edit / delete) ────────── + // + // These helpers are only consulted when the `workflowColumns` flag is ON; the + // flag-OFF CRUD paths above keep their exact current behavior. Re-homing moves + // always route through `moveTask` with `moveSource: "engine"` + `bypassGuards` + // (a recovery-class move, KTD-9) — never a raw column write — so capacity + // (KTD-10) and the single transition authority (KTD-3) are honored. + + /** True when the `workflowColumns` flag is ON (merged global + project). */ + private async workflowColumnsFlagOn(): Promise { + return isWorkflowColumnsEnabled(await this.getSettingsFast()); + } + + /** The active (non-deleted) task ids currently selecting `workflowId`. A + * built-in/default workflow additionally owns every task with NO selection + * row (null selection resolves to the default workflow, KTD-1). */ + private listWorkflowOccupantTaskIds(workflowId: string, includeNullSelection: boolean): string[] { + const ids: string[] = []; + const selected = this.db + .prepare( + `SELECT s.taskId AS taskId FROM task_workflow_selection s + JOIN tasks t ON t.id = s.taskId + WHERE s.workflowId = ? AND t."deletedAt" IS NULL`, + ) + .all(workflowId) as Array<{ taskId: string }>; + for (const row of selected) ids.push(row.taskId); + if (includeNullSelection) { + const unselected = this.db + .prepare( + `SELECT t.id AS id FROM tasks t + WHERE t."deletedAt" IS NULL + AND NOT EXISTS (SELECT 1 FROM task_workflow_selection s WHERE s.taskId = t.id)`, + ) + .all() as Array<{ id: string }>; + for (const row of unselected) ids.push(row.id); + } + return ids; + } + + /** Map column id → occupant count for the tasks selecting `workflowId` + * (plus null-selection tasks when `includeNullSelection`). */ + private occupantsByColumnForWorkflow( + workflowId: string, + includeNullSelection: boolean, + ): Map { + const counts = new Map(); + for (const taskId of this.listWorkflowOccupantTaskIds(workflowId, includeNullSelection)) { + const row = this.db.prepare(`SELECT "column" AS column FROM tasks WHERE id = ?`).get(taskId) as + | { column: string } + | undefined; + if (!row) continue; + counts.set(row.column, (counts.get(row.column) ?? 0) + 1); + } + return counts; + } + + /** Re-home a single occupant to `targetColumn` via an engine-sourced, + * guard-bypassing recovery move, aborting in-flight work first, and emit one + * audit event. Best-effort per card: a failure is audited and skipped so one + * stuck card never blocks the rest of the batch. */ + private async rehomeOccupant( + taskId: string, + targetColumn: string, + reason: "workflow-switch" | "workflow-delete" | "workflow-edit-rehome", + metadata: Record, + ): Promise { + const current = this.readTaskFromDb(taskId, { includeDeleted: false }); + if (!current) return; + const fromColumn = current.column; + if (fromColumn === targetColumn) { + // Already in the target column — nothing to move, but still record the + // reconciliation decision for audit traceability. + this.recordRunAuditEvent({ + taskId, + agentId: "system", + runId: `workflow-reconcile-${reason}-${taskId}-${Date.now()}`, + domain: "database", + mutationType: "task:workflow-reconcile", + target: taskId, + metadata: { ...metadata, reason, fromColumn, toColumn: targetColumn, moved: false }, + }); + return; + } + const abortRan = await runReconciliationAbort({ taskId, fromColumn, reason }); + let moved = false; + let error: string | undefined; + try { + // Recovery-class move: engine source + bypassGuards (KTD-9). preserveProgress + // keeps the task's fields intact (R20 delete semantics). Capacity (KTD-10) is + // NOT bypassed — a full target column rejects, which we audit and skip. + await this.moveTask(taskId, targetColumn, { + moveSource: "engine", + bypassGuards: true, + recoveryRehome: true, + preserveProgress: true, + preserveResumeState: true, + preserveWorktree: true, + allowDirectInReviewMove: true, + }); + moved = true; + } catch (err) { + error = err instanceof Error ? err.message : String(err); + } + this.recordRunAuditEvent({ + taskId, + agentId: "system", + runId: `workflow-reconcile-${reason}-${taskId}-${Date.now()}`, + domain: "database", + mutationType: "task:workflow-reconcile", + target: taskId, + metadata: { ...metadata, reason, fromColumn, toColumn: targetColumn, abortRan, moved, error }, + }); + } + + // ── U12: workflow-columns integrity pass ────────────────────────────────── + // + // Migration rewrites ZERO task rows (KTD-1): a null selection resolves to the + // built-in default workflow at read time, and the default workflow's column + // IDs are byte-identical to the legacy enum values, so every legacy row is + // already valid. The only residual risk is a task whose stored column is not a + // valid column in its RESOLVED workflow — e.g. a custom workflow was edited to + // drop a column out-of-band, or a legacy row references a column the selected + // custom workflow never defined. The integrity pass audits those and re-homes + // them via the U5 reconciliation path (`recoveryRehome`, guard-bypassing, + // capacity-honoring), one audit event per card. + // + // Idempotent: a second run finds nothing out-of-place (the re-home lands the + // card in a valid column) and is a pure no-op. Tasks in complete- or + // archived-flagged columns are left UNTOUCHED (done/archived cards are terminal + // — re-homing them would corrupt the board) even if (defensively) their column + // were somehow not in the resolved IR; we never disturb terminal cards. + // + // Runs only when the `workflowColumns` flag is ON (flag-OFF keeps the legacy + // enum path, where every column is valid by construction). + async runWorkflowColumnsIntegrityPass(): Promise<{ scanned: number; rehomed: number; skippedTerminal: number }> { + let scanned = 0; + let rehomed = 0; + let skippedTerminal = 0; + + const rows = this.db + .prepare(`SELECT id FROM tasks WHERE "deletedAt" IS NULL`) + .all() as Array<{ id: string }>; + + const registry = getTraitRegistry(); + + for (const { id } of rows) { + scanned += 1; + const task = this.readTaskFromDb(id, { includeDeleted: false }); + if (!task) continue; + const ir = this.resolveTaskWorkflowIrSync(id); + const currentColumn = task.column; + + // Already valid in its resolved workflow — nothing to do (the common case; + // this is why the pass is idempotent and a no-op for healthy DBs). + if (workflowHasColumn(ir, currentColumn)) continue; + + // The stored column is not in the resolved workflow. Before re-homing, + // never disturb a terminal card: if the column the card sits in carries a + // complete/archived flag in its workflow it is terminal — but since the + // column is NOT in the IR we cannot read its flags there. Fall back to the + // legacy terminal semantics (done/archived) so terminal cards are never + // re-homed, matching the plan's "done/archived untouched" rule. + const column = findWorkflowColumn(ir, currentColumn); + const flags = column ? registry.resolveColumnFlags(column) : undefined; + const isTerminal = + flags?.complete === true || + flags?.archived === true || + currentColumn === "done" || + currentColumn === "archived"; + if (isTerminal) { + skippedTerminal += 1; + continue; + } + + const targetColumn = resolveEntryColumnId(ir); + if (!targetColumn) continue; // non-reconcilable IR — leave the card put. + + await this.rehomeOccupant(id, targetColumn, "workflow-edit-rehome", { + integrityPass: true, + invalidColumn: currentColumn, + }); + rehomed += 1; + } + + if (rehomed > 0 || skippedTerminal > 0) { + storeLog.log("workflowColumns integrity pass completed", { + phase: "init:workflow-columns-integrity", + scanned, + rehomed, + skippedTerminal, + }); + } + return { scanned, rehomed, skippedTerminal }; + } + + // ── #1401: transitionPending recovery sweep ─────────────────────────────── + // + // A crash between the in-txn `transitionPending` marker write and the + // post-commit `clearTransitionPending` leaves the marker set forever. Because + // `countActiveInCapacitySlotSync` counts a pending marker as occupying a + // capacity slot for its `toColumn`, a stale marker permanently inflates that + // (workflow, column) capacity count. This sweep is the backstop the comments + // across store.ts / merge-trait.ts / transition-pending.ts reference: it scans + // every task carrying a non-null marker, reconciles `hooksRemaining` against + // the currently-known hook set, re-runs the surviving idempotent post-commit + // hooks via the same runner the live path uses, audits the recovery, and + // clears the marker so the reserved capacity slot is released. + // + // Idempotent: the default-workflow field effects already committed in-lock, so + // re-running them is a no-op, and a second sweep finds no markers. Plugin hooks + // are re-derived from the resolved IR (so an uninstalled-plugin hook simply + // drops, surfaced as an audit warning) and are expected to be idempotent per + // KTD-2. Runs at store init (alongside the integrity pass) and periodically + // from the flag-ON sweep cadence. + async recoverStaleTransitionPending(): Promise<{ scanned: number; recovered: number; degradedHooks: number }> { + let scanned = 0; + let recovered = 0; + let degradedHooks = 0; + + const rows = this.db + .prepare( + `SELECT id FROM tasks WHERE transitionPending IS NOT NULL AND transitionPending != '' AND deletedAt IS NULL`, + ) + .all() as Array<{ id: string }>; + + // The set of hook ids the current process can still honor: the always-present + // default-workflow post-commit marker plus every registered plugin trait's + // onEnter/onExit hook. A marker entry not in this set belongs to an + // uninstalled plugin and is dropped (audited) rather than re-run. + const registry = getTraitRegistry(); + const knownHookIds = new Set(["default-workflow:postCommit"]); + for (const def of registry.listTraits()) { + if (def.hooks?.onEnter) knownHookIds.add(`${def.id}:onEnter`); + if (def.hooks?.onExit) knownHookIds.add(`${def.id}:onExit`); + } + + for (const { id } of rows) { + scanned += 1; + const marker = readTransitionPending(this.db, id); + // null = nothing pending (corrupt/empty marker degrades to settled); we + // still clear the stored column so the slot is released. undefined = row + // vanished mid-sweep — skip. + if (marker === undefined) continue; + + await this.withTaskLock(id, async () => { + // Re-read inside the lock: another path may have cleared it already. + const live = readTransitionPending(this.db, id); + if (live == null) { + // Corrupt/empty marker — clear the stored value defensively so it stops + // counting against capacity, then move on. + if (live === null) { + try { + clearTransitionPending(this.db, id); + } catch { + // best-effort + } + } + return; + } + + const { hooksRemaining, warnings } = reconcileHooksRemaining(live.hooksRemaining, knownHookIds); + degradedHooks += warnings.length; + + // Re-run the surviving idempotent post-commit hooks. The default-workflow + // field effects already committed in-lock pre-crash, so the only work that + // can still be owed is the plugin trait hook runner, which re-derives its + // pending set from the resolved IR and is idempotent (KTD-2). We invoke it + // only when a plugin hook entry survived (a marker carrying just + // `default-workflow:postCommit` needs no re-run — just a clear). + const hasSurvivingPluginHook = hooksRemaining.some((h) => h !== "default-workflow:postCommit"); + if (hasSurvivingPluginHook) { + const task = this.readTaskFromDb(id, { includeDeleted: false }); + if (task) { + const ir = this.resolveTaskWorkflowIrSync(id); + // fromColumn is unknown post-crash; the marker only records toColumn. + // The hook runner keys onEnter off toColumn (and onExit off fromColumn); + // re-running onEnter for the destination is the recoverable, idempotent + // half. Use the task's current column as fromColumn (it committed to + // toColumn at marker-write time, so current == toColumn and onExit is a + // no-op, which is correct — we never re-fire an exit we may have run). + try { + await this.runPluginColumnTransitionHooks(id, ir, task.column, live.toColumn); + } catch (err) { + storeLog.warn("transitionPending recovery: hook re-run faulted (degraded)", { + phase: "recover-stale-transition-pending", + taskId: id, + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + + for (const warning of warnings) { + storeLog.warn(warning, { + phase: "recover-stale-transition-pending", + taskId: id, + }); + } + + // Clear the marker — releases the reserved capacity slot. + try { + clearTransitionPending(this.db, id); + } catch { + // best-effort; a later sweep retries. + } + + this.recordRunAuditEvent({ + taskId: id, + agentId: "system", + runId: `transition-pending-recovery-${id}-${Date.now()}`, + domain: "database", + mutationType: "task:transition-pending-recovered", + target: id, + metadata: { + toColumn: live.toColumn, + hooksReran: hooksRemaining, + droppedHooks: warnings.length, + startedAt: live.startedAt, + }, + }); + recovered += 1; + }); + } + + if (recovered > 0 || degradedHooks > 0) { + storeLog.log("transitionPending recovery sweep completed", { + phase: "recover-stale-transition-pending", + scanned, + recovered, + degradedHooks, + }); + } + return { scanned, recovered, degradedHooks }; + } + + // ── #1409: flag ON→OFF evacuation ───────────────────────────────────────── + // + // When `workflowColumns` is disabled (or at flag-OFF store init), the board + // reverts to the legacy enum/`VALID_TRANSITIONS` path, where only the legacy + // {@link COLUMNS} are valid. Any card sitting in a CUSTOM (non-legacy) column + // would be stuck: it can't be listed/moved through the legacy path. This pass + // detects those cards and re-homes each to the nearest legacy column — the + // default workflow's entry column (`todo`) — via the existing recovery-rehome + // path (engine source + bypassGuards + recoveryRehome, capacity-honoring), + // auditing one event per card. Terminal cards (done/archived) are left put. + // + // Idempotent: a second run finds every card in a legacy column and is a no-op. + async evacuateCustomColumnsToLegacy( + trigger: "flag-off-init" | "flag-toggled-off", + ): Promise<{ scanned: number; evacuated: number }> { + let scanned = 0; + let evacuated = 0; + + const legacyColumns = new Set(COLUMNS); + // Nearest legacy landing column: the default workflow's entry column + // (triage). Falls back to "triage" defensively if the IR can't be resolved. + const targetColumn = resolveEntryColumnId(BUILTIN_CODING_WORKFLOW_IR) ?? "triage"; + + const rows = this.db + .prepare(`SELECT id, "column" AS col FROM tasks WHERE deletedAt IS NULL`) + .all() as Array<{ id: string; col: string }>; + + for (const { id, col } of rows) { + scanned += 1; + // Already in a legacy column (the common case) — nothing to evacuate. + if (legacyColumns.has(col)) continue; + // Never disturb terminal cards (legacy terminal semantics — these column + // ids are never legacy here, but guard defensively for parity with the + // integrity pass). + if (col === "done" || col === "archived") continue; + + await this.rehomeOccupant(id, targetColumn, "workflow-edit-rehome", { + evacuation: true, + trigger, + invalidColumn: col, + }); + evacuated += 1; + } + + if (evacuated > 0) { + storeLog.log("workflowColumns ON→OFF evacuation completed", { + phase: "evacuate-custom-columns", + trigger, + scanned, + evacuated, + }); + } + return { scanned, evacuated }; } // ── Workflow selection (resolves a workflow to enabledWorkflowSteps) ──── @@ -11436,6 +12666,150 @@ ${stepsSection}`; } /** Read the workflow currently selected for a task, if any. */ + /** + * Synchronously resolve the parsed WorkflowIr that governs a task's columns + * (U4, flag-ON path). Resolution order: + * 1. the task's workflow selection (side table) → that workflow's IR; + * 2. null/missing selection → the built-in default workflow IR (KTD-1). + * Built-in workflow IRs are resolved from the parsed module constant; custom + * workflows are read + parsed from the `workflows` row. Pure DB read, safe to + * call inside `withTaskLock` (no further locks taken). A parse failure or + * missing custom row falls back to the default workflow so a move is never + * stranded by a corrupt definition (degraded, not crashed). + */ + /** + * U8 (KTD-2): record a pre-evaluated plugin gate verdict for a move into + * `toColumn`. Called by the engine's plugin trait adapter AFTER it evaluated + * the gate (prompt/script) outside the task lock. The flag-ON guard site in + * `moveTaskInternal` re-checks the recorded verdict in-lock. Verdicts are + * consumed (cleared) by `consumePluginGateVerdicts` once read so a stale + * verdict can't silently re-authorize a later move. + */ + recordPluginGateVerdict( + taskId: string, + toColumn: string, + verdict: Omit & { recordedAt?: number }, + ): void { + let byColumn = this.pluginGateVerdicts.get(taskId); + if (!byColumn) { + byColumn = new Map(); + this.pluginGateVerdicts.set(taskId, byColumn); + } + const list = byColumn.get(toColumn) ?? []; + // Replace any prior verdict for the same trait (latest evaluation wins). + const filtered = list.filter((v) => v.traitId !== verdict.traitId); + filtered.push({ ...verdict, recordedAt: verdict.recordedAt ?? Date.now() }); + byColumn.set(toColumn, filtered); + } + + /** + * U8: read AND clear the recorded plugin gate verdicts for a (task, column). + * Returns the recorded verdicts (possibly empty). Consuming clears them so the + * verdict authorizes exactly one move attempt. + */ + consumePluginGateVerdicts(taskId: string, toColumn: string): PluginGateVerdict[] { + const byColumn = this.pluginGateVerdicts.get(taskId); + if (!byColumn) return []; + const list = byColumn.get(toColumn) ?? []; + byColumn.delete(toColumn); + if (byColumn.size === 0) this.pluginGateVerdicts.delete(taskId); + return list; + } + + private resolveTaskWorkflowIrSync(taskId: string): WorkflowIr { + const selection = this.getTaskWorkflowSelection(taskId); + const workflowId = selection?.workflowId; + if (!workflowId) return BUILTIN_CODING_WORKFLOW_IR; + if (isBuiltinWorkflowId(workflowId)) { + const builtin = getBuiltinWorkflow(workflowId); + return builtin?.ir ?? BUILTIN_CODING_WORKFLOW_IR; + } + try { + const row = this.db + .prepare("SELECT ir FROM workflows WHERE id = ?") + .get(workflowId) as { ir: string } | undefined; + if (!row) return BUILTIN_CODING_WORKFLOW_IR; + return parseWorkflowIr(row.ir); + } catch { + return BUILTIN_CODING_WORKFLOW_IR; + } + } + + /** + * U6 (KTD-10): the *effective workflow id* used to scope the per-(workflow, + * column) capacity count. A task with no selection (or a missing/empty + * selection row) resolves to the built-in default workflow, represented by a + * stable sentinel so all default-workflow tasks share one capacity pool. A + * selected workflow id (builtin or custom) is its own pool. Pure DB read; safe + * inside the move transaction. + */ + private resolveEffectiveWorkflowIdSync(taskId: string): string { + const selection = this.getTaskWorkflowSelection(taskId); + return selection?.workflowId ?? TaskStore.DEFAULT_WORKFLOW_POOL_ID; + } + + /** + * U6 (KTD-10): count cards currently occupying a (workflow, column) capacity + * slot, for the in-txn capacity check. Runs INSIDE `moveTaskInternal`'s + * transaction. A slot is held by a card that: + * - has committed its column to `targetColumn` (the steady-state holders), OR + * - (when `countPending`) has a `transitionPending` marker targeting + * `targetColumn` — it reserved the slot at commit time even though its + * post-commit hooks haven't finished yet. + * The moving task itself (`excludeTaskId`) is excluded so a same-column no-op + * or re-entry never counts itself. Only the candidates in the SAME effective + * workflow as the mover count (capacity is per-(workflow, column)). Soft-deleted + * tasks never hold a slot. + */ + private countActiveInCapacitySlotSync(params: { + targetColumn: string; + workflowId: string; + countPending: boolean; + excludeTaskId: string; + }): number { + const { targetColumn, workflowId, countPending, excludeTaskId } = params; + // Candidate rows: in the column now, or (optionally) mid-transition into it. + // LEFT JOIN the selection row so we can scope by effective workflow id in JS. + const rows = this.db + .prepare( + `SELECT t.id AS id, t."column" AS col, t.transitionPending AS tp, s.workflowId AS wid + FROM tasks t + LEFT JOIN task_workflow_selection s ON s.taskId = t.id + WHERE t.deletedAt IS NULL + AND t.id != ? + AND (t."column" = ? OR (t.transitionPending IS NOT NULL AND t.transitionPending != ''))`, + ) + .all(excludeTaskId, targetColumn) as Array<{ + id: string; + col: string; + tp: string | null; + wid: string | null; + }>; + + let count = 0; + for (const row of rows) { + const effectiveWorkflowId = row.wid ?? TaskStore.DEFAULT_WORKFLOW_POOL_ID; + if (effectiveWorkflowId !== workflowId) continue; + + if (row.col === targetColumn) { + count += 1; + continue; + } + // Not committed into the column — only counts if it has reserved the slot + // via a transitionPending marker targeting this column AND countPending. + if (!countPending || !row.tp) continue; + let toColumn: string | undefined; + try { + const parsed = JSON.parse(row.tp) as { toColumn?: unknown }; + if (typeof parsed.toColumn === "string") toColumn = parsed.toColumn; + } catch { + // Corrupt marker — treat as not holding this slot. + } + if (toColumn === targetColumn) count += 1; + } + return count; + } + getTaskWorkflowSelection(taskId: string): { workflowId: string; stepIds: string[] } | undefined { const row = this.db .prepare("SELECT workflowId, stepIds FROM task_workflow_selection WHERE taskId = ?") @@ -11603,6 +12977,46 @@ ${stepsSection}`; }); } + /** + * U5 (R20) workflow switch: select a workflow for a task and, when the + * `workflowColumns` flag is ON, reconcile the card's board column against the + * NEW workflow. Same-id column preserves position; otherwise the card re-homes + * to the new workflow's entry (intake-flagged, else first) column, aborting + * in-flight processing first (KTD-9). Returns the materialized step ids plus + * the switch outcome so the dashboard can surface the re-home. + * + * Reconciliation runs AFTER `selectTaskWorkflow` releases the per-task lock + * (moveTask takes its own lock; the per-task lock is non-reentrant). + */ + async selectTaskWorkflowAndReconcile( + taskId: string, + workflowId: string, + ): Promise<{ + enabledWorkflowSteps: string[]; + reconciliation?: { preserved: boolean; fromColumn: string; toColumn: string }; + }> { + const enabledWorkflowSteps = await this.selectTaskWorkflow(taskId, workflowId); + if (!(await this.workflowColumnsFlagOn())) { + return { enabledWorkflowSteps }; + } + const newIr = this.resolveTaskWorkflowIrSync(taskId); + const current = this.readTaskFromDb(taskId, { includeDeleted: false }); + if (!current) return { enabledWorkflowSteps }; + const fromColumn = current.column; + const decision = resolveSwitchReconciliation(newIr, fromColumn); + if (!decision.preserved && decision.targetColumn !== fromColumn) { + await this.rehomeOccupant(taskId, decision.targetColumn, "workflow-switch", { workflowId }); + } + return { + enabledWorkflowSteps, + reconciliation: { + preserved: decision.preserved, + fromColumn, + toColumn: decision.targetColumn, + }, + }; + } + /** Clear a task's workflow selection and its enabled steps. */ async clearTaskWorkflowSelection(taskId: string): Promise { await this.withTaskLock(taskId, async () => { diff --git a/packages/core/src/task-age-staleness.ts b/packages/core/src/task-age-staleness.ts index b55ed779f4..f4bf79c5dd 100644 --- a/packages/core/src/task-age-staleness.ts +++ b/packages/core/src/task-age-staleness.ts @@ -50,6 +50,10 @@ export function getTaskAgeStalenessSignal( if (task.column !== "in-progress" && task.column !== "in-review") { return undefined; } + // The guard above proves `column` is one of these two legacy ids; the + // `ColumnId` union's `string & {}` member can't be excluded by literal `!==` + // narrowing, so the cast is provably safe here (#1403). + const activeColumn = task.column as "in-progress" | "in-review"; if (task.mergeDetails?.mergeConfirmed === true) { return undefined; } @@ -105,7 +109,7 @@ export function getTaskAgeStalenessSignal( ageMs, warningThresholdMs: warningThresholdMs ?? 0, criticalThresholdMs: criticalThresholdMs ?? 0, - column: task.column, + column: activeColumn, paused: task.paused === true, }; } diff --git a/packages/core/src/trait-registry.ts b/packages/core/src/trait-registry.ts new file mode 100644 index 0000000000..382339b058 --- /dev/null +++ b/packages/core/src/trait-registry.ts @@ -0,0 +1,432 @@ +/** + * Trait registry (U2, R6/R8/R22). + * + * One registry resolving trait ids to definitions (flags + hook descriptors) + * for both built-ins and (later) plugins. Provides: + * - registration with `builtin:`-style namespace protection and + * restricted-capability enforcement (R22); + * - hook-implementation DI (engine registers impls; unregistered hooks + * resolve to a no-op + audit warning — degraded, not crashed); + * - effective-flag resolution for a column's trait set; + * - the save-time / load-time composition validator returning typed + * violations with named reason codes, distinguishing `error` + * (save-blocked) from `degraded` (load-time advisory). + * + * Core stays engine-free: no `@fusion/engine` import. Hook implementations are + * wired in via `registerTraitHookImpl` (mirrors `setCreateFnAgent`). + */ + +import type { + TraitDefinition, + TraitFlags, + TraitHookImpl, + TraitHookKind, +} from "./trait-types.js"; +import { RESTRICTED_TRAIT_FLAGS, traitHookKey } from "./trait-types.js"; +import type { WorkflowIrColumn, WorkflowIrColumnTrait } from "./workflow-ir-types.js"; + +// ── Registration error ────────────────────────────────────────────────────── + +/** Named reason codes for a rejected trait registration. */ +export type TraitRegistrationReason = + | "duplicate-id" + | "builtin-namespace-protected" + | "restricted-flag" + | "restricted-guard-hook" + | "invalid-definition"; + +export class TraitRegistrationError extends Error { + readonly reason: TraitRegistrationReason; + readonly traitId: string; + constructor(reason: TraitRegistrationReason, traitId: string, message: string) { + super(message); + this.name = "TraitRegistrationError"; + this.reason = reason; + this.traitId = traitId; + } +} + +// ── Composition violation contract ────────────────────────────────────────── + +/** Named reason codes for a composition violation. */ +export type TraitViolationCode = + | "complete-with-wip" + | "two-capacity-traits" + | "complete-with-intake" + | "archived-with-wip" + | "multiple-intake-columns" + | "unknown-trait"; + +/** Severity: `error` blocks the save; `degraded` is a load-time advisory — the + * definition still loads (per U2's load-time re-validation requirement). */ +export type TraitViolationSeverity = "error" | "degraded"; + +export interface TraitViolation { + code: TraitViolationCode; + severity: TraitViolationSeverity; + /** Column id the violation applies to, or null for workflow-wide violations. */ + columnId: string | null; + /** The trait ids implicated (for actionable messaging). */ + traitIds: string[]; + message: string; +} + +/** + * Thrown by the store's create/update workflow paths (residual A) when a + * workflow's trait composition has `error`-severity violations under `save` + * mode — so trait conflicts reject server-side, not only in the editor. Carries + * the structured violations so the surface can render them per-column. The + * dashboard routes map this to a 400 (consistent with `WorkflowIrError`). + */ +export class ColumnTraitValidationError extends Error { + readonly violations: TraitViolation[]; + constructor(violations: TraitViolation[]) { + const summary = violations.map((v) => v.message).join("; "); + super(`Workflow trait composition invalid: ${summary}`); + this.name = "ColumnTraitValidationError"; + this.violations = violations; + } +} + +/** A simple audit-warning record returned by hook resolution / load-time + * re-validation. Modeled as a returned value (not a thrown error and not an + * engine logger) so core stays engine-free; callers may forward it to audit. */ +export interface TraitAuditWarning { + kind: "missing-hook-impl" | "degraded-composition"; + traitId?: string; + hookKind?: TraitHookKind; + message: string; +} + +// ── The registry ──────────────────────────────────────────────────────────── + +export class TraitRegistry { + private readonly traits = new Map(); + private readonly hookImpls = new Map(); + + /** Register a trait. Rejects duplicates, builtin-namespace overrides by + * non-builtins, and restricted-capability declarations by non-builtins (R22). */ + register(def: TraitDefinition): void { + if (!def.id || typeof def.id !== "string") { + throw new TraitRegistrationError( + "invalid-definition", + String(def.id), + "Trait definition must have a non-empty string id", + ); + } + + const existing = this.traits.get(def.id); + if (existing) { + // A built-in id (or any already-registered id) cannot be overridden. + if (!def.builtin && existing.builtin) { + throw new TraitRegistrationError( + "builtin-namespace-protected", + def.id, + `Trait id '${def.id}' is a built-in trait and cannot be overridden by a non-builtin registration`, + ); + } + throw new TraitRegistrationError( + "duplicate-id", + def.id, + `Trait id '${def.id}' is already registered`, + ); + } + + if (!def.builtin) { + // Non-builtin (plugin) traits cannot declare restricted flags (R22). + for (const flag of RESTRICTED_TRAIT_FLAGS) { + if (def.flags?.[flag]) { + throw new TraitRegistrationError( + "restricted-flag", + def.id, + `Non-builtin trait '${def.id}' may not declare the restricted flag '${flag}'`, + ); + } + } + // Non-builtin traits cannot declare the sync `guard` hook (KTD-2/R22). + if (def.hooks?.guard) { + throw new TraitRegistrationError( + "restricted-guard-hook", + def.id, + `Non-builtin trait '${def.id}' may not declare a sync 'guard' hook (built-in only)`, + ); + } + } + + this.traits.set(def.id, def); + } + + getTrait(id: string): TraitDefinition | undefined { + return this.traits.get(id); + } + + /** Catalog of all registered traits (for the dashboard endpoint, later). */ + listTraits(): TraitDefinition[] { + return [...this.traits.values()]; + } + + has(id: string): boolean { + return this.traits.has(id); + } + + // ── Hook implementation DI (engine wires impls in) ──────────────────────── + + /** Register a hook implementation for a (traitId, hookKind). Called by the + * engine (mirrors `setCreateFnAgent`); core never supplies impls. */ + registerTraitHookImpl(traitId: string, hookKind: TraitHookKind, impl: TraitHookImpl): void { + this.hookImpls.set(traitHookKey(traitId, hookKind), impl); + } + + /** + * Deregister a hook implementation for a (traitId, hookKind). After this, a + * trait that still DECLARES the hook resolves to a no-op + audit warning (the + * degraded path) rather than executing — this is exactly the "force-disable a + * plugin → columns degrade to passive" path (U8/KTD-7). Returns true if an + * impl was present and removed. + */ + deregisterTraitHookImpl(traitId: string, hookKind: TraitHookKind): boolean { + return this.hookImpls.delete(traitHookKey(traitId, hookKind)); + } + + /** + * Remove a trait definition entirely (e.g. when a plugin is fully + * unregistered with no live dependents). Also drops any registered hook impls + * for that trait. Returns true if the trait was present. Built-in traits are + * never removed by this (they are not plugin-owned); callers should only pass + * plugin-namespaced ids. + */ + unregisterTrait(traitId: string): boolean { + const def = this.traits.get(traitId); + if (!def || def.builtin) return false; + for (const hookKind of ["guard", "gate", "onEnter", "onExit", "releaseCondition"] as TraitHookKind[]) { + this.hookImpls.delete(traitHookKey(traitId, hookKind)); + } + return this.traits.delete(traitId); + } + + /** Resolve a hook implementation. If the trait declares the hook but no impl + * is registered, returns a no-op plus an audit warning (degraded, not + * crashed). Returns `{ impl: undefined }` with no warning if the trait does + * not declare the hook at all. */ + resolveTraitHook( + traitId: string, + hookKind: TraitHookKind, + ): { impl: TraitHookImpl | undefined; warning?: TraitAuditWarning } { + const def = this.traits.get(traitId); + const declared = Boolean(def?.hooks?.[hookKind]); + const impl = this.hookImpls.get(traitHookKey(traitId, hookKind)); + if (impl) return { impl }; + if (declared) { + const noop: TraitHookImpl = () => undefined; + return { + impl: noop, + warning: { + kind: "missing-hook-impl", + traitId, + hookKind, + message: `Trait '${traitId}' declares a '${hookKind}' hook but no implementation is registered; resolving to a no-op`, + }, + }; + } + return { impl: undefined }; + } + + // ── Flag resolution ─────────────────────────────────────────────────────── + + /** Merged effective flags of a column's traits (OR across booleans). Unknown + * trait ids are ignored here (validation surfaces them via + * validateColumnTraits). */ + resolveColumnFlags(column: WorkflowIrColumn): TraitFlags { + const merged: TraitFlags = {}; + for (const ct of column.traits) { + const def = this.traits.get(ct.trait); + if (!def) continue; + for (const [key, value] of Object.entries(def.flags) as [keyof TraitFlags, boolean][]) { + if (value) merged[key] = true; + } + } + return merged; + } + + // ── Composition validation ──────────────────────────────────────────────── + + /** + * Validate a workflow's columns' trait composition. Returns typed violations + * with named reason codes. `mode: "save"` produces `error` severities that + * block the save; `mode: "load"` degrades the *unknown-trait* violation to an + * advisory so definitions predating a newly added trait still load (per U2's + * load-time re-validation requirement). Hard structural conflicts remain + * errors in both modes (they reflect genuine nonsense, not vocabulary drift). + */ + validateColumnTraits( + columns: WorkflowIrColumn[], + mode: "save" | "load" = "save", + ): TraitViolation[] { + const violations: TraitViolation[] = []; + + let intakeColumnCount = 0; + + for (const column of columns) { + const knownDefs: TraitDefinition[] = []; + + // Unknown trait ids: degradable. In save mode it's an error; in load mode + // it degrades to an advisory (the rule/vocabulary may have changed under + // a persisted definition). + for (const ct of column.traits) { + const def = this.traits.get(ct.trait); + if (!def) { + violations.push({ + code: "unknown-trait", + severity: mode === "load" ? "degraded" : "error", + columnId: column.id, + traitIds: [ct.trait], + message: `Column '${column.id}' references unknown trait '${ct.trait}'`, + }); + continue; + } + knownDefs.push(def); + } + + const flags = this.mergeFlags(knownDefs); + + // Capacity traits on this column (traits whose flags set countsTowardWip). + const capacityTraitIds = knownDefs + .filter((d) => d.flags.countsTowardWip) + .map((d) => d.id); + + if (flags.complete && flags.countsTowardWip) { + violations.push({ + code: "complete-with-wip", + severity: "error", + columnId: column.id, + traitIds: this.traitIdsWithFlags(knownDefs, ["complete", "countsTowardWip"]), + message: `Column '${column.id}' is both a completion column and counts toward WIP — a terminal column cannot hold a capacity slot`, + }); + } + + if (capacityTraitIds.length > 1) { + violations.push({ + code: "two-capacity-traits", + severity: "error", + columnId: column.id, + traitIds: capacityTraitIds, + message: `Column '${column.id}' has more than one capacity (WIP) trait: ${capacityTraitIds.join(", ")}`, + }); + } + + if (flags.complete && flags.intake) { + violations.push({ + code: "complete-with-intake", + severity: "error", + columnId: column.id, + traitIds: this.traitIdsWithFlags(knownDefs, ["complete", "intake"]), + message: `Column '${column.id}' is both a completion column and an intake column`, + }); + } + + if (flags.archived && flags.countsTowardWip) { + violations.push({ + code: "archived-with-wip", + severity: "error", + columnId: column.id, + traitIds: this.traitIdsWithFlags(knownDefs, ["archived", "countsTowardWip"]), + message: `Column '${column.id}' is archived but counts toward WIP — archived cards must not hold capacity`, + }); + } + + if (flags.intake) intakeColumnCount += 1; + } + + if (intakeColumnCount > 1) { + violations.push({ + code: "multiple-intake-columns", + severity: "error", + columnId: null, + traitIds: [], + message: `Workflow has ${intakeColumnCount} intake columns; exactly one is allowed`, + }); + } + + return violations; + } + + private mergeFlags(defs: TraitDefinition[]): TraitFlags { + const merged: TraitFlags = {}; + for (const def of defs) { + for (const [key, value] of Object.entries(def.flags) as [keyof TraitFlags, boolean][]) { + if (value) merged[key] = true; + } + } + return merged; + } + + private traitIdsWithFlags(defs: TraitDefinition[], flagKeys: (keyof TraitFlags)[]): string[] { + return defs + .filter((d) => flagKeys.some((k) => d.flags[k])) + .map((d) => d.id); + } +} + +// ── Module-level default registry ──────────────────────────────────────────── +// +// A single shared registry instance the built-ins register into and the engine +// wires hook impls into. Tests can construct fresh `new TraitRegistry()` +// instances for isolation. + +let defaultRegistry: TraitRegistry | undefined; + +export function getTraitRegistry(): TraitRegistry { + if (!defaultRegistry) defaultRegistry = new TraitRegistry(); + return defaultRegistry; +} + +/** Test-only: reset the shared registry (so built-in registration can be + * re-exercised in isolation). */ +export function __resetTraitRegistryForTests(): void { + defaultRegistry = undefined; +} + +// ── Convenience pass-throughs to the default registry ──────────────────────── + +export function getTrait(id: string): TraitDefinition | undefined { + return getTraitRegistry().getTrait(id); +} + +export function listTraits(): TraitDefinition[] { + return getTraitRegistry().listTraits(); +} + +export function resolveColumnFlags(column: WorkflowIrColumn): TraitFlags { + return getTraitRegistry().resolveColumnFlags(column); +} + +export function validateColumnTraits( + columns: WorkflowIrColumn[], + mode: "save" | "load" = "save", +): TraitViolation[] { + return getTraitRegistry().validateColumnTraits(columns, mode); +} + +/** + * Save-mode composition validation that THROWS (residual A). Runs the registry's + * `validateColumnTraits` in `save` mode and throws a {@link ColumnTraitValidationError} + * if any `error`-severity violations are present. `degraded` advisories are + * ignored (they never block a save). A no-op for `[]`/no-error columns. + */ +export function assertColumnTraitsValid(columns: WorkflowIrColumn[]): void { + const violations = getTraitRegistry() + .validateColumnTraits(columns, "save") + .filter((v) => v.severity === "error"); + if (violations.length > 0) throw new ColumnTraitValidationError(violations); +} + +export function registerTraitHookImpl( + traitId: string, + hookKind: TraitHookKind, + impl: TraitHookImpl, +): void { + getTraitRegistry().registerTraitHookImpl(traitId, hookKind, impl); +} + +/** Re-export for callers that only need the column-trait shape. */ +export type { WorkflowIrColumnTrait }; diff --git a/packages/core/src/trait-types.ts b/packages/core/src/trait-types.ts new file mode 100644 index 0000000000..e17b71b15c --- /dev/null +++ b/packages/core/src/trait-types.ts @@ -0,0 +1,125 @@ +/** + * Trait model (U2). A trait is declarative flags + optional config schema + + * optional executable lifecycle hook *descriptors*. Per KTD-2 there are two + * guard classes: + * - `guard` — sync, in-lock, fast/pure (DB reads only). BUILT-IN ONLY. + * - `gate` — async, pre-evaluated outside the lock; the plugin-facing + * surface. The verdict is recorded and re-checked cheaply in-lock. + * + * Hooks here are *descriptors* (what the trait declares it participates in); + * the executable implementations are registered separately by the engine via + * the core→engine DI seam (mirrors `setCreateFnAgent`). This keeps core + * engine-free: core never imports `@fusion/engine`. + */ + +/** The set of hook points a trait can declare (KTD-2). */ +export type TraitHookKind = "guard" | "gate" | "onEnter" | "onExit" | "releaseCondition"; + +/** All declarative trait flags. Derived from the Trait Vocabulary table. + * Every flag is optional; an absent flag means `false`. Flags compose by OR + * across a column's traits (see resolveColumnFlags). */ +export interface TraitFlags { + /** Cards in this column count against a WIP/capacity limit (substrate-enforced). */ + countsTowardWip?: boolean; + /** Terminal-success column; satisfies dependencies. RESTRICTED (built-in only). */ + complete?: boolean; + /** Globally archived; hidden from the board. RESTRICTED (built-in only). */ + archived?: boolean; + /** Hidden from the board lane (e.g. archived columns). */ + hiddenFromBoard?: boolean; + /** Leaving this column hard-cancels in-flight work (abort-on-exit). */ + abortOnExit?: boolean; + /** Cards cannot leave until explicit human approval. */ + humanReview?: boolean; + /** Where new cards land; exactly one per workflow (validated). */ + intake?: boolean; + /** Passive dwell column with a release condition. */ + hold?: boolean; + /** Participates in merge/PR orchestration (enqueues onto the merge queue). */ + mergeOrchestration?: boolean; + /** Entry to this column is blocked until the merge-class node completed. */ + mergeBlocker?: boolean; + /** Card progress/fields are reset on entry (reopen semantics). */ + resetOnEntry?: boolean; + /** Cumulative active-time accounting runs on enter/exit. */ + timing?: boolean; + /** Stall detection is evaluated by the sweep for cards dwelling here. */ + stallDetection?: boolean; + /** Emits notifications on enter/exit. */ + notify?: boolean; + /** A gate (advisory or blocking) is evaluated before entry. */ + gate?: boolean; +} + +/** The flag keys that are restricted to built-in traits (R22, KTD-7). A + * non-builtin (plugin) trait declaring any of these is rejected at + * registration. The sync `guard` hook descriptor is restricted separately. */ +export const RESTRICTED_TRAIT_FLAGS = ["complete", "archived"] as const; +export type RestrictedTraitFlag = (typeof RESTRICTED_TRAIT_FLAGS)[number]; + +/** A trait's hook descriptors — *what* the trait declares it participates in. + * `true` means "this trait has a hook of this kind"; the implementation is + * registered separately via the engine DI seam. */ +export interface TraitHookDescriptors { + /** Sync, in-lock guard. BUILT-IN ONLY (KTD-2/R22). */ + guard?: boolean; + /** Async, pre-evaluated gate. Plugin-facing surface. */ + gate?: boolean; + /** Post-commit, async, idempotent enter effect. */ + onEnter?: boolean; + /** Post-commit, async, idempotent exit effect. */ + onExit?: boolean; + /** Release-condition evaluation for hold columns (sweep-driven). */ + releaseCondition?: boolean; +} + +/** A declarative description of a trait's config schema. Lightweight by design + * (U2 ships the shapes; richer validation lands with each behavior unit). */ +export interface TraitConfigField { + key: string; + type: "string" | "number" | "boolean" | "enum" | "object" | "array"; + required?: boolean; + /** For `enum` fields: the allowed values. */ + enumValues?: readonly string[]; + description?: string; +} + +export interface TraitConfigSchema { + fields: TraitConfigField[]; +} + +/** A trait definition: declarative flags + optional config schema + optional + * hook descriptors. Built-in traits set `builtin: true`. */ +export interface TraitDefinition { + id: string; + name: string; + description?: string; + flags: TraitFlags; + configSchema?: TraitConfigSchema; + hooks?: TraitHookDescriptors; + /** True for the 14 built-in traits; plugin/custom traits leave this falsy. + * Restricted capabilities (R22) are allowed only when `builtin` is true. */ + builtin?: boolean; +} + +// ── Hook implementation DI seam (core→engine) ─────────────────────────────── +// +// Implementations of trait hooks are NOT defined in core (core is engine-free). +// The engine registers them via `registerTraitHookImpl` the way it wires +// `setCreateFnAgent`. Core resolves an implementation through +// `getTraitHookImpl`; an unregistered hook resolves to a no-op (the registry's +// `resolveTraitHook` returns a no-op + an audit warning, see trait-registry). +// +// The impl signature is intentionally opaque here: core never invokes hooks +// directly (the store/sweep do, in engine-adjacent code), so core only needs to +// store/retrieve the registration. Using `unknown` keeps core free of engine +// types while remaining type-safe at the registration boundary. + +/** A registered hook implementation. Opaque to core; the engine supplies a + * concrete callable and casts at its own call sites. */ +export type TraitHookImpl = (...args: unknown[]) => unknown; + +/** Stable key for a (traitId, hookKind) implementation registration. */ +export function traitHookKey(traitId: string, hookKind: TraitHookKind): string { + return `${traitId}::${hookKind}`; +} diff --git a/packages/core/src/transition-pending.ts b/packages/core/src/transition-pending.ts new file mode 100644 index 0000000000..6f09f324ff --- /dev/null +++ b/packages/core/src/transition-pending.ts @@ -0,0 +1,115 @@ +/** + * Store-side read/write helpers for the crash-safe `tasks.transitionPending` + * marker (U3). + * + * These operate on a minimal db handle (anything exposing a `prepare` that + * returns a statement with `.get`/`.run`) so they can be unit-tested against a + * raw {@link import("./db.js").Database} without dragging in `store.ts`. U4 owns + * wiring these into `moveTaskInternal`'s transaction and the recovery sweep; + * this module is the clean seam they will call. + * + * The marker is written in the same transaction as the column change (KTD-2) and + * cleared once post-commit hooks complete. Recovery reads it back exclusively + * from SQLite (the authoritative store per ADR-0001). + */ + +import { + type TransitionPending, + deserializeTransitionPending, + serializeTransitionPending, +} from "./transition-types.js"; + +/** Minimal statement surface the helpers need (subset of node:sqlite's StatementSync). */ +interface MarkerStatement { + get(...params: unknown[]): unknown; + run(...params: unknown[]): unknown; +} + +/** Minimal db handle: just enough to prepare statements. Satisfied by `Database`. */ +export interface TransitionPendingDbHandle { + prepare(sql: string): MarkerStatement; +} + +/** + * Read the pending marker for a task. Returns `null` when the column is NULL, + * empty, or holds malformed JSON (a corrupt marker must never throw on a + * recovery path — it degrades to "no pending work" and the row is treated as + * settled). Returns `undefined` only when the task row does not exist. + */ +export function readTransitionPending( + db: TransitionPendingDbHandle, + taskId: string, +): TransitionPending | null | undefined { + const row = db + .prepare(`SELECT transitionPending FROM tasks WHERE id = ?`) + .get(taskId) as { transitionPending: string | null } | undefined; + if (row === undefined) return undefined; + if (row.transitionPending == null || row.transitionPending === "") return null; + return deserializeTransitionPending(row.transitionPending); +} + +/** + * Write (set or replace) the pending marker for a task. Intended to run inside + * the same transaction as the column change (U4). Stores the JSON-serialized + * marker into `tasks.transitionPending`. + */ +export function writeTransitionPending( + db: TransitionPendingDbHandle, + taskId: string, + pending: TransitionPending, +): void { + db.prepare(`UPDATE tasks SET transitionPending = ? WHERE id = ?`).run( + serializeTransitionPending(pending), + taskId, + ); +} + +/** + * Clear the pending marker for a task (sets the column to NULL). Called once all + * post-commit hooks for the transition have completed. + */ +export function clearTransitionPending(db: TransitionPendingDbHandle, taskId: string): void { + db.prepare(`UPDATE tasks SET transitionPending = NULL WHERE id = ?`).run(taskId); +} + +/** Result of reconciling a marker's `hooksRemaining` against the known hook set. */ +export interface ReconcileHooksResult { + /** Hooks that survived: still registered/known and owed execution. */ + hooksRemaining: string[]; + /** + * Audit warnings for each dropped hook entry — e.g. a hook belonging to a + * now-uninstalled plugin. One human-readable message per dropped entry so the + * recovery sweep can emit a degraded-hook audit event and complete the marker + * rather than leaving the card stuck waiting for a missing handler. + */ + warnings: string[]; +} + +/** + * Reconcile a marker's `hooksRemaining` against the set of currently-known hook + * IDs. Entries no longer present (e.g. a plugin hook removed by uninstall) are + * dropped and surfaced as audit warnings. Pure — no DB access — so U4/U8 can + * call it in or out of a transaction. + * + * This covers the U3-level slice of the "missing-plugin-hook" scenario: the + * type/helper guarantees a dangling hook entry resolves to a dropped entry plus + * a warning, never an indefinitely-stuck marker. The actual recovery wiring is + * U4/U8. + */ +export function reconcileHooksRemaining( + hooksRemaining: readonly string[], + knownHookIds: ReadonlySet, +): ReconcileHooksResult { + const surviving: string[] = []; + const warnings: string[] = []; + for (const hookId of hooksRemaining) { + if (knownHookIds.has(hookId)) { + surviving.push(hookId); + } else { + warnings.push( + `Dropping unknown transition hook "${hookId}" from transitionPending marker (handler not registered; likely an uninstalled plugin)`, + ); + } + } + return { hooksRemaining: surviving, warnings }; +} diff --git a/packages/core/src/transition-types.ts b/packages/core/src/transition-types.ts new file mode 100644 index 0000000000..728445afee --- /dev/null +++ b/packages/core/src/transition-types.ts @@ -0,0 +1,189 @@ +/** + * Typed transition contract (U3). + * + * `moveTaskInternal` (the single transition authority, KTD-3/R13) stops throwing + * bare strings on a rejected move and instead returns a typed {@link TransitionResult}. + * The rejection shape is shared verbatim across surfaces — the dashboard drop + * handler, the CLI, the HTTP move endpoint, and the recovery sweep — so every + * caller speaks one rejection contract. Because the rejection crosses the HTTP + * API boundary, the type is intentionally a flat, JSON-safe object (no class + * instances, no functions, no `undefined`-only fields that would survive a JSON + * round-trip differently than declared) and ships with explicit + * (de)serialization helpers below. + * + * The {@link TransitionPending} marker is the crash-safe hook protocol (KTD-2/KTD-9): + * it is written in the same SQLite transaction as the column change and records + * which post-commit, idempotent enter/exit hooks still owe execution. A crash + * mid-transition leaves the marker behind; the recovery sweep re-reads it from + * SQLite (the authoritative store per ADR-0001 — `task.json` is a stale follower + * across a crash) and re-runs the remaining idempotent hooks. The marker, like + * the rejection, crosses no class boundary and round-trips cleanly through JSON. + */ + +/** + * Reason codes for a rejected transition. Stable string literals — they are + * persisted in audit and matched by surfaces to choose user-facing copy, so + * they must not change without a migration of the consumers. + */ +export type TransitionRejectionCode = + | "guard-rejected" + | "capacity-exhausted" + | "unknown-column" + | "workflow-mismatch" + | "merge-blocked"; + +/** The full, immutable set of rejection codes (handy for exhaustive validation). */ +export const TRANSITION_REJECTION_CODES: readonly TransitionRejectionCode[] = [ + "guard-rejected", + "capacity-exhausted", + "unknown-column", + "workflow-mismatch", + "merge-blocked", +] as const; + +/** + * A typed transition rejection. Flat and JSON-safe by construction. + * + * - `code` — machine-stable {@link TransitionRejectionCode}. + * - `messageKey` — i18n key the surface resolves to user-facing copy (never a + * pre-translated string; translation is the surface's job). + * - `retryable` — whether re-issuing the same move could succeed later (e.g. a + * capacity exhaustion frees up) versus a structural rejection that will not + * (e.g. unknown column). + * - `detail` — optional, non-localized diagnostic context for audit/logs only. + */ +export interface TransitionRejection { + code: TransitionRejectionCode; + messageKey: string; + retryable: boolean; + detail?: string; +} + +/** + * Result of an attempted transition. Discriminated on `ok` so callers branch + * exhaustively. The success arm carries the resolved destination column so the + * caller need not re-read it. + */ +export type TransitionResult = + | { ok: true; toColumn: string } + | { ok: false; rejection: TransitionRejection }; + +/** + * Crash-safe marker persisted alongside the column change. `hooksRemaining` + * holds the IDs of post-commit enter/exit hooks that have not yet completed; + * recovery re-runs exactly these (idempotently) and clears the marker when the + * list empties. `startedAt` is an epoch-millis timestamp used for stall/age + * diagnostics and ordering during recovery. + */ +export interface TransitionPending { + toColumn: string; + hooksRemaining: string[]; + startedAt: number; +} + +// --------------------------------------------------------------------------- +// Helper constructors +// --------------------------------------------------------------------------- + +/** + * Construct a {@link TransitionRejection}. `detail` is omitted from the object + * when not supplied so the serialized shape stays minimal and stable. + */ +export function makeTransitionRejection( + code: TransitionRejectionCode, + messageKey: string, + retryable: boolean, + detail?: string, +): TransitionRejection { + const rejection: TransitionRejection = { code, messageKey, retryable }; + if (detail !== undefined) { + rejection.detail = detail; + } + return rejection; +} + +/** Construct a successful {@link TransitionResult}. */ +export function transitionOk(toColumn: string): TransitionResult { + return { ok: true, toColumn }; +} + +/** Construct a rejected {@link TransitionResult} from a rejection. */ +export function transitionRejected(rejection: TransitionRejection): TransitionResult { + return { ok: false, rejection }; +} + +/** + * Construct a {@link TransitionPending} marker. `startedAt` defaults to now so + * the common call site (`moveTaskInternal` writing the marker in-txn) stays + * terse; callers reconstructing a marker from a stored value pass it explicitly. + * The `hooksRemaining` array is copied so the marker does not alias caller state. + */ +export function makeTransitionPending( + toColumn: string, + hooksRemaining: string[], + startedAt: number = Date.now(), +): TransitionPending { + return { toColumn, hooksRemaining: [...hooksRemaining], startedAt }; +} + +// --------------------------------------------------------------------------- +// (De)serialization — JSON-safe round-trip across the API boundary +// --------------------------------------------------------------------------- + +function isTransitionRejectionCode(value: unknown): value is TransitionRejectionCode { + return typeof value === "string" && (TRANSITION_REJECTION_CODES as readonly string[]).includes(value); +} + +/** Serialize a rejection to a JSON string for transport/persistence. */ +export function serializeTransitionRejection(rejection: TransitionRejection): string { + return JSON.stringify(rejection); +} + +/** + * Parse a rejection from a JSON string produced by + * {@link serializeTransitionRejection}. Returns `null` for malformed or + * structurally invalid input rather than throwing, so a corrupt audit payload + * can never crash a recovery path. + */ +export function deserializeTransitionRejection(json: string): TransitionRejection | null { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const obj = parsed as Record; + if (!isTransitionRejectionCode(obj.code)) return null; + if (typeof obj.messageKey !== "string") return null; + if (typeof obj.retryable !== "boolean") return null; + if (obj.detail !== undefined && typeof obj.detail !== "string") return null; + return makeTransitionRejection(obj.code, obj.messageKey, obj.retryable, obj.detail as string | undefined); +} + +/** Serialize a pending marker to a JSON string for the `tasks.transitionPending` column. */ +export function serializeTransitionPending(pending: TransitionPending): string { + return JSON.stringify(pending); +} + +/** + * Parse a pending marker from the JSON stored in `tasks.transitionPending`. + * Returns `null` for malformed/invalid input. Non-string entries in + * `hooksRemaining` are dropped defensively (a corrupt array element must not + * strand the card); the structural shape is otherwise required. + */ +export function deserializeTransitionPending(json: string): TransitionPending | null { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const obj = parsed as Record; + if (typeof obj.toColumn !== "string") return null; + if (!Array.isArray(obj.hooksRemaining)) return null; + if (typeof obj.startedAt !== "number" || !Number.isFinite(obj.startedAt)) return null; + const hooksRemaining = obj.hooksRemaining.filter((h): h is string => typeof h === "string"); + return { toColumn: obj.toColumn, hooksRemaining, startedAt: obj.startedAt }; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 043f161575..41ea3f2de9 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -15,15 +15,51 @@ export type { CapacityRiskSignal } from "./capacity.js"; export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high"] as const; export type ThinkingLevel = (typeof THINKING_LEVELS)[number]; +/** + * The legacy default-workflow column set. Under + * `experimentalFeatures.workflowColumns` a task's valid columns are resolved + * from its workflow definition (the default workflow's column IDs are + * byte-identical to these — KTD-1). New flag-aware code should prefer the + * workflow-resolved path (`resolveAllowedColumns` / `workflowHasColumn` in + * `workflow-transitions.ts`) and trait-flag predicates over string equality; + * this enum remains the canonical id set for the built-in default workflow. + */ export const COLUMNS = ["triage", "todo", "in-progress", "in-review", "done", "archived"] as const; +/** + * The closed legacy column union — still the correct type for default-workflow + * column ids and the flag-OFF path. Movement entry points accept the wider + * {@link ColumnId}; flag-ON code validates ids against the task's resolved + * workflow at runtime. + */ export type Column = (typeof COLUMNS)[number]; +/** + * Column identifier accepted at task-movement entry points (KTD-1). + * Equals the legacy `Column` union for autocomplete purposes, but admits + * workflow-defined custom column ids; flag-ON paths validate the id against + * the task's resolved workflow at runtime, flag-OFF paths reject non-legacy + * ids exactly as before. + */ +export type ColumnId = Column | (string & {}); + export const DEFAULT_COLUMN: Column = "triage"; +/** + * Tests membership against the closed legacy column enum. Note: under the + * workflowColumns flag, column validity is workflow-scoped — flag-aware code + * should use `workflowHasColumn(ir, columnId)` (`workflow-transitions.ts`); + * this remains correct for the flag-OFF path and default-workflow ids. + */ export function isColumn(value: unknown): value is Column { return typeof value === "string" && (COLUMNS as readonly string[]).includes(value); } +/** + * @deprecated (workflowColumns, U12) Coerces an arbitrary value to a legacy + * column, DISCARDING workflow-defined custom column ids — lossy under the + * flag. Resolve and validate against the task's workflow instead. Retained + * for the legacy flag-OFF path while the flag exists. + */ export function normalizeColumn(value: unknown, fallback: Column = DEFAULT_COLUMN): Column { return isColumn(value) ? value : fallback; } @@ -1773,7 +1809,9 @@ export interface Task { * tasks are hydrated from persistence. */ priority?: TaskPriority; - column: Column; + /** The task's current column id. Widened to {@link ColumnId} so workflow-defined + * custom columns are representable; flag-OFF paths only ever store legacy ids. */ + column: ColumnId; dependencies: string[]; /** User-requested hint for triage: prefer splitting into child tasks when appropriate. */ breakIntoSubtasks?: boolean; @@ -2170,7 +2208,9 @@ export interface TaskCreateInput { * Optional task importance level. Omitted values default to `normal`. */ priority?: TaskPriority; - column?: Column; + /** Initial column id. Widened to {@link ColumnId} (#1403) so a custom-column + * task can be replicated/created; flag-OFF creation only ever uses legacy ids. */ + column?: ColumnId; dependencies?: string[]; breakIntoSubtasks?: boolean; /** When true, this task is expected to complete without creating git commits. */ @@ -3950,6 +3990,15 @@ export const COLUMN_DESCRIPTIONS: Record = { archived: "Completed and archived", }; +/** + * @deprecated (workflowColumns, U12) The hardcoded legacy transition graph. + * Under `experimentalFeatures.workflowColumns`, transition validity is resolved + * from the task's workflow column graph (`resolveAllowedColumns` in + * `workflow-transitions.ts`) plus trait guards in `moveTaskInternal` — this + * constant is now only the flag-OFF authority and the parity oracle the default + * workflow is machine-checked against (transition-parity suite). Retained while + * the flag exists; do NOT remove until graduation + legacy-path deletion. + */ export const VALID_TRANSITIONS: Record = { // FN-4892: intake-side heuristics may cold-archive tasks before execution starts. triage: ["todo", "archived"], diff --git a/packages/core/src/workflow-capacity.ts b/packages/core/src/workflow-capacity.ts new file mode 100644 index 0000000000..e34ae46eab --- /dev/null +++ b/packages/core/src/workflow-capacity.ts @@ -0,0 +1,121 @@ +/** + * Workflow capacity resolution (U6, KTD-10, R9 capacity half). + * + * WIP/capacity limits are trait *configuration*; their *enforcement* is a + * substrate capability that runs INSIDE `moveTaskInternal`'s transaction and is + * NEVER bypassable (not a guard — runs regardless of bypassGuards/recoveryRehome + * /moveSource). This module is the pure resolution layer shared by both the + * in-txn check (`store.ts`) and the hold/release sweep (`@fusion/engine` + * `hold-release.ts`): given a workflow IR + a column id + settings it answers + * - does this column have a `wip` (capacity) trait? + * - what is its effective limit (read-through to `settings.maxConcurrent` for + * the default workflow's in-progress column so the legacy knob keeps working + * — U6 scheduler-integration half)? + * - does its config opt into counting mid-`transitionPending` cards? + * + * It performs NO DB access and NO counting — the caller owns the count (the + * store counts in-txn; the sweep counts from a listTasks snapshot). Keeping the + * resolution pure means the two enforcement points can never disagree on what a + * limit *is*, only on the live count, which is exactly the serialization the + * in-txn check arbitrates (two holds, one slot → one wins). + */ + +import type { Settings } from "./types.js"; +import type { WorkflowIr, WorkflowIrV2, WorkflowIrColumn } from "./workflow-ir-types.js"; +import { DEFAULT_WORKFLOW_COLUMN_IDS } from "./workflow-ir.js"; +import { getTraitRegistry } from "./trait-registry.js"; + +/** The default-workflow column whose WIP limit read-through is + * `settings.maxConcurrent` (the legacy "N agents in-progress" gate). */ +const DEFAULT_WIP_COLUMN_ID = "in-progress"; + +/** U6 (KTD-10): sentinel effective-workflow id for default-workflow + * (null-selection) tasks, so they all share one per-column capacity pool. It + * is not a real workflow row id (no `builtin:`/custom collision possible). */ +export const DEFAULT_WORKFLOW_POOL_ID = "__default-workflow__"; + +/** Resolved capacity configuration for a single column. */ +export interface ColumnCapacity { + /** True when the column carries a capacity (`wip`/`countsTowardWip`) trait. */ + hasCapacity: boolean; + /** The effective max concurrent cards. `Infinity` means "no finite limit" + * (a capacity trait with no resolvable limit does not gate). */ + limit: number; + /** Whether mid-`transitionPending` cards (holding their destination slot from + * commit time) count toward the limit. Defaults true: a card that has + * committed its move into the column holds the slot even before its + * post-commit hooks finish (KTD-10). */ + countPending: boolean; +} + +const NO_CAPACITY: ColumnCapacity = { hasCapacity: false, limit: Infinity, countPending: true }; + +function findColumn(ir: WorkflowIr, columnId: string): WorkflowIrColumn | undefined { + const v2 = ir as WorkflowIrV2; + if (!Array.isArray(v2.columns)) return undefined; + return v2.columns.find((c) => c.id === columnId); +} + +/** True when the IR's column set is exactly the default-workflow column ids. */ +function isDefaultWorkflowColumns(ir: WorkflowIr): boolean { + const v2 = ir as WorkflowIrV2; + if (!Array.isArray(v2.columns)) return false; + const ids = v2.columns.map((c) => c.id); + if (ids.length !== DEFAULT_WORKFLOW_COLUMN_IDS.length) return false; + const set = new Set(ids); + return DEFAULT_WORKFLOW_COLUMN_IDS.every((id) => set.has(id)); +} + +/** + * Resolve the capacity configuration for `columnId` under `ir`. + * + * Limit resolution order: + * 1. An explicit numeric `limit` in the column's `wip` trait config wins. + * 2. Otherwise, for the DEFAULT workflow's `in-progress` column, read through + * to `settings.maxConcurrent` (default 2) so the legacy knob keeps working + * and flag-ON default-workflow scheduling matches flag-OFF (legacy parity). + * 3. Otherwise the column has a capacity trait but no resolvable finite limit + * → `Infinity` (does not gate; the trait is inert until configured). + */ +export function resolveColumnCapacity( + ir: WorkflowIr, + columnId: string, + settings?: Pick | undefined, +): ColumnCapacity { + const column = findColumn(ir, columnId); + if (!column) return NO_CAPACITY; + + const flags = getTraitRegistry().resolveColumnFlags(column); + if (!flags.countsTowardWip) return NO_CAPACITY; + + // The capacity trait config (the `wip` trait carries `limit` + `countPending`). + // Find the first trait config whose trait sets countsTowardWip. + let configLimit: number | undefined; + let countPending = true; + for (const ct of column.traits) { + const def = getTraitRegistry().getTrait(ct.trait); + if (!def?.flags.countsTowardWip) continue; + const cfg = ct.config ?? {}; + if (typeof cfg.limit === "number" && Number.isFinite(cfg.limit)) { + configLimit = cfg.limit; + } + if (typeof cfg.countPending === "boolean") { + countPending = cfg.countPending; + } + break; + } + + let limit: number; + if (configLimit !== undefined) { + limit = configLimit; + } else if (columnId === DEFAULT_WIP_COLUMN_ID && isDefaultWorkflowColumns(ir)) { + // Read-through: legacy maxConcurrent maps onto the default workflow's + // in-progress WIP limit (U6 scheduler integration). + const maxConcurrent = settings?.maxConcurrent; + limit = typeof maxConcurrent === "number" && Number.isFinite(maxConcurrent) ? maxConcurrent : 2; + } else { + limit = Infinity; + } + + return { hasCapacity: true, limit, countPending }; +} diff --git a/packages/core/src/workflow-columns-settings.ts b/packages/core/src/workflow-columns-settings.ts new file mode 100644 index 0000000000..e5a946546f --- /dev/null +++ b/packages/core/src/workflow-columns-settings.ts @@ -0,0 +1,18 @@ +import { isExperimentalFeatureEnabled } from "./experimental-features.js"; +import type { Settings } from "./types.js"; + +/** + * The `experimentalFeatures.workflowColumns` flag (KTD-8). OFF: the legacy + * enum/`VALID_TRANSITIONS` path runs untouched. ON: `moveTaskInternal` resolves + * each task's workflow column graph + trait guards. Default OFF until the + * transition-parity suite and field observations prove zero drift (U12). + * + * Mirrors `isSandboxExperimentalEnabled` / `isEvalsViewEnabled` — a thin, + * named accessor over the shared experimental-features map so the literal flag + * key lives in exactly one place. + */ +export function isWorkflowColumnsEnabled( + settings: Pick | undefined, +): boolean { + return isExperimentalFeatureEnabled(settings, "workflowColumns"); +} diff --git a/packages/core/src/workflow-definition-types.ts b/packages/core/src/workflow-definition-types.ts index 3383815a9d..026f544684 100644 --- a/packages/core/src/workflow-definition-types.ts +++ b/packages/core/src/workflow-definition-types.ts @@ -40,4 +40,12 @@ export interface WorkflowDefinitionUpdate { description?: string; ir?: WorkflowIr; layout?: Record; + /** + * U5 (R20): when an IR update removes a column that still holds cards, the + * update is blocked with a typed {@link import("./workflow-reconciliation.js").OccupiedColumnsError} + * unless `rehomeTo` is supplied — an explicit "save and re-home occupants to + * column X" target. The target must survive in the new IR. Only consulted when + * the `workflowColumns` flag is ON. + */ + rehomeTo?: string; } diff --git a/packages/core/src/workflow-ir-resolver.ts b/packages/core/src/workflow-ir-resolver.ts new file mode 100644 index 0000000000..c2c703aa29 --- /dev/null +++ b/packages/core/src/workflow-ir-resolver.ts @@ -0,0 +1,79 @@ +/** + * Single source of truth for the workflow-IR resolution rule. + * + * The selection → builtin/custom → default-fallback rule was independently + * reimplemented in engine/hold-release.ts, engine/merge-trait.ts, + * engine/plugin-runner.ts (which bypassed the public API via getDatabase()), + * and dashboard/board-workflows.ts, with behavioral divergence already creeping + * in (GitHub #1402). This module consolidates the read-only resolution into one + * pair of helpers built on the *public* store surface so every call site shares + * one implementation. + * + * A missing/corrupt definition degrades to the built-in default workflow so + * resolution never throws. The store-private, txn-hot `resolveTaskWorkflowIrSync` + * stays separate by design. + */ + +import { getBuiltinWorkflow, isBuiltinWorkflowId } from "./builtin-workflows.js"; +import { BUILTIN_CODING_WORKFLOW_IR } from "./builtin-coding-workflow-ir.js"; +import { parseWorkflowIr } from "./workflow-ir.js"; +import type { WorkflowIr } from "./workflow-ir-types.js"; + +/** Minimal store surface the resolver needs (public APIs only). */ +export interface WorkflowIrResolverStore { + getTaskWorkflowSelection(taskId: string): { workflowId: string; stepIds: string[] } | undefined; + getWorkflowDefinition(id: string): Promise<{ ir: string | WorkflowIr } | undefined>; +} + +/** + * Resolve a workflow IR by its id (built-in or custom). + * + * @param irCache optional cache keyed by workflowId so each distinct workflow's + * IR (and its definition fetch) is resolved at most once per caller-scoped + * sweep. Hits short-circuit before any builtin/db lookup. + */ +export async function resolveWorkflowIrById( + store: Pick, + workflowId: string, + irCache?: Map, +): Promise { + const cached = irCache?.get(workflowId); + if (cached) return cached; + + if (isBuiltinWorkflowId(workflowId)) { + const builtin = getBuiltinWorkflow(workflowId); + const ir = builtin?.ir ?? BUILTIN_CODING_WORKFLOW_IR; + const resolved = typeof ir === "string" ? parseWorkflowIr(ir) : ir; + irCache?.set(workflowId, resolved); + return resolved; + } + + try { + const def = await store.getWorkflowDefinition(workflowId); + if (!def) return BUILTIN_CODING_WORKFLOW_IR; + const ir = typeof def.ir === "string" ? parseWorkflowIr(def.ir) : def.ir; + irCache?.set(workflowId, ir); + return ir; + } catch { + return BUILTIN_CODING_WORKFLOW_IR; + } +} + +/** + * Resolve a task's workflow IR via its selection. A null/absent selection or any + * lookup failure degrades to the built-in default workflow. + */ +export async function resolveWorkflowIrForTask( + store: WorkflowIrResolverStore, + taskId: string, + irCache?: Map, +): Promise { + let workflowId: string | undefined; + try { + workflowId = store.getTaskWorkflowSelection(taskId)?.workflowId; + } catch { + return BUILTIN_CODING_WORKFLOW_IR; + } + if (!workflowId) return BUILTIN_CODING_WORKFLOW_IR; + return resolveWorkflowIrById(store, workflowId, irCache); +} diff --git a/packages/core/src/workflow-ir-types.ts b/packages/core/src/workflow-ir-types.ts index c5e2e537e2..d636618f8d 100644 --- a/packages/core/src/workflow-ir-types.ts +++ b/packages/core/src/workflow-ir-types.ts @@ -1,8 +1,20 @@ -export type WorkflowIrNodeKind = "start" | "prompt" | "script" | "gate" | "end"; +/** Node kinds. v1 kinds (start/prompt/script/gate/end) plus the v2 additions: + * `hold` (passive dwell column states), and `split`/`join` (parallel fan-out). */ +export type WorkflowIrNodeKind = + | "start" + | "prompt" + | "script" + | "gate" + | "end" + | "hold" + | "split" + | "join"; export interface WorkflowIrNode { id: string; kind: WorkflowIrNodeKind; + /** v2: the column this node is placed in. Must reference a defined column id. */ + column?: string; config?: Record; } @@ -12,9 +24,51 @@ export interface WorkflowIrEdge { condition?: string; } -export interface WorkflowIr { +/** A single trait configuration applied to a column. The `trait` is an opaque + * registry id (resolved by the trait registry shipped in U2); `config` carries + * trait-specific options validated by that trait's schema. */ +export interface WorkflowIrColumnTrait { + trait: string; + config?: Record; +} + +/** A workflow-defined board column. */ +export interface WorkflowIrColumn { + id: string; + name: string; + traits: WorkflowIrColumnTrait[]; +} + +/** Release conditions for a `hold` node (KTD-2, R3). */ +export type WorkflowHoldRelease = + | "manual" + | "timer" + | "capacity" + | "dependency" + | "external-event"; + +/** Join synchronization mode (KTD-11). `quorum` requires `quorum.n` completed branches. */ +export type WorkflowJoinMode = "all" | "any" | { quorum: number }; + +/** What happens to sibling branches when one branch fails before the join (KTD-11). */ +export type WorkflowJoinBranchFailure = "fail-fast" | "collect"; + +/** A v1 workflow IR graph. Frozen by FN-5769; retained for back-compat. */ +export interface WorkflowIrV1 { version: "v1"; name: string; nodes: WorkflowIrNode[]; edges: WorkflowIrEdge[]; } + +/** A v2 workflow IR graph: v1 plus workflow-defined columns and node placement. */ +export interface WorkflowIrV2 { + version: "v2"; + name: string; + columns: WorkflowIrColumn[]; + nodes: WorkflowIrNode[]; + edges: WorkflowIrEdge[]; +} + +/** Either IR version. v1 graphs upgrade to v2 on parse (see parseWorkflowIr). */ +export type WorkflowIr = WorkflowIrV1 | WorkflowIrV2; diff --git a/packages/core/src/workflow-ir.ts b/packages/core/src/workflow-ir.ts index 1e86e1bd4d..95ed2ac100 100644 --- a/packages/core/src/workflow-ir.ts +++ b/packages/core/src/workflow-ir.ts @@ -1,4 +1,13 @@ -import type { WorkflowIr } from "./workflow-ir-types.js"; +import type { + WorkflowIr, + WorkflowIrColumn, + WorkflowIrEdge, + WorkflowIrNode, + WorkflowIrNodeKind, + WorkflowIrV1, + WorkflowIrV2, + WorkflowHoldRelease, +} from "./workflow-ir-types.js"; export class WorkflowIrError extends Error { constructor(message: string) { @@ -7,13 +16,224 @@ export class WorkflowIrError extends Error { } } +const HOLD_RELEASE_KINDS: ReadonlySet = new Set([ + "manual", + "timer", + "capacity", + "dependency", + "external-event", +]); + +/** Seam config values that may not appear inside a parallel branch (KTD-11): + * one worktree/session per task and exclusive merge are physical constraints. */ +const SEAM_FORBIDDEN_IN_BRANCH: ReadonlySet = new Set(["execute", "merge"]); + +/** Default-workflow column ids in legacy enum order (KTD-1). */ +export const DEFAULT_WORKFLOW_COLUMN_IDS = [ + "triage", + "todo", + "in-progress", + "in-review", + "done", + "archived", +] as const; + +/** Place a v1 node into a synthesized default-workflow column by its seam. */ +function defaultColumnForNode(node: WorkflowIrNode): string { + const seam = node.config?.seam; + if (seam === "execute") return "in-progress"; + if (seam === "review") return "in-review"; + if (seam === "merge") return "in-review"; + return "todo"; +} + +/** The synthesized default-workflow columns used when upgrading a v1 graph. The + * trait set here is intentionally minimal (placement only); the full default + * workflow with traits is BUILTIN_CODING_WORKFLOW_IR. */ +function synthesizeDefaultColumns(): WorkflowIrColumn[] { + return DEFAULT_WORKFLOW_COLUMN_IDS.map((id) => ({ id, name: id, traits: [] })); +} + +/** Upgrade a v1 graph to v2 by synthesizing default columns and placing nodes + * by their seam (execute→in-progress, review/merge→in-review, others→todo). */ +function upgradeV1ToV2(ir: WorkflowIrV1): WorkflowIrV2 { + return { + version: "v2", + name: ir.name, + columns: synthesizeDefaultColumns(), + nodes: ir.nodes.map((node) => + node.column ? node : { ...node, column: defaultColumnForNode(node) }, + ), + edges: ir.edges, + }; +} + +function buildOutgoing(edges: WorkflowIrEdge[]): Map { + const outgoing = new Map(); + for (const edge of edges) { + const list = outgoing.get(edge.from); + if (list) list.push(edge); + else outgoing.set(edge.from, [edge]); + } + return outgoing; +} + +function seamOf(node: WorkflowIrNode): string | undefined { + const seam = node.config?.seam; + return typeof seam === "string" ? seam : undefined; +} + +/** + * Validate `split`/`join` parallelism (KTD-11): + * - every split has a reachable matching join (recursively for nested splits); + * - execute/merge seam nodes inside a branch reject (seam-in-branch); + * - join `quorum(n)` with n exceeding the split's branch count rejects. + */ +function validateParallelism( + nodes: WorkflowIrNode[], + outgoing: Map, + nodesById: Map, +): void { + const splits = nodes.filter((n) => n.kind === "split"); + + for (const split of splits) { + const branchEdges = outgoing.get(split.id) ?? []; + if (branchEdges.length < 2) { + throw new WorkflowIrError(`split '${split.id}' must fan out into at least two branches`); + } + + // Walk each branch forward until the matching join is reached. Track join + // hit-counts and ensure every branch reaches the SAME join (nested splits + // resolve to their own join first, so balanced nesting still terminates). + const joinsReached = new Set(); + for (const edge of branchEdges) { + const join = walkBranchToJoin(edge.to, split.id, outgoing, nodesById); + if (!join) { + throw new WorkflowIrError(`split '${split.id}' has a branch with no reachable matching join`); + } + joinsReached.add(join); + } + if (joinsReached.size !== 1) { + throw new WorkflowIrError(`split '${split.id}' branches converge on more than one join`); + } + const joinId = [...joinsReached][0]; + const join = nodesById.get(joinId)!; + + const mode = join.config?.mode; + if (mode && typeof mode === "object" && "quorum" in mode) { + const n = (mode as { quorum: unknown }).quorum; + if (typeof n !== "number" || !Number.isInteger(n) || n < 1) { + throw new WorkflowIrError(`join '${join.id}' quorum must be a positive integer`); + } + if (n > branchEdges.length) { + throw new WorkflowIrError( + `join '${join.id}' quorum(${n}) exceeds the split's ${branchEdges.length} branches`, + ); + } + } + } +} + +/** Walk a single branch from `startNodeId` until a `join` node is reached. + * Rejects execute/merge seam nodes encountered inside the branch. Handles one + * level of nesting by recursing through inner splits to their inner join. */ +function walkBranchToJoin( + startNodeId: string, + ownerSplitId: string, + outgoing: Map, + nodesById: Map, +): string | undefined { + const visited = new Set(); + let cursor: string | undefined = startNodeId; + while (cursor && !visited.has(cursor)) { + visited.add(cursor); + const node = nodesById.get(cursor); + if (!node) return undefined; + + if (node.kind === "join") return node.id; + + if (node.kind === "split") { + // Nested split: resolve to its inner join, then continue from there. + const inner = (outgoing.get(node.id) ?? []) + .map((e) => walkBranchToJoin(e.to, node.id, outgoing, nodesById)) + .find(Boolean); + if (!inner) return undefined; + cursor = innerJoinNext(inner, outgoing); + continue; + } + + const seam = seamOf(node); + if (seam && SEAM_FORBIDDEN_IN_BRANCH.has(seam)) { + throw new WorkflowIrError( + `seam '${seam}' node '${node.id}' is forbidden inside a parallel branch of split '${ownerSplitId}'`, + ); + } + + const next = (outgoing.get(cursor) ?? []).find((e) => e.condition !== "failure"); + cursor = next?.to; + } + return undefined; +} + +/** The node following a join along its (non-failure) outgoing edge. */ +function innerJoinNext(joinId: string, outgoing: Map): string | undefined { + return (outgoing.get(joinId) ?? []).find((e) => e.condition !== "failure")?.to; +} + +function validateColumns(ir: WorkflowIrV2): void { + if (!Array.isArray(ir.columns)) { + throw new WorkflowIrError("Workflow IR v2 columns must be an array"); + } + const seen = new Set(); + for (const column of ir.columns) { + if (!column || typeof column.id !== "string" || !column.id) { + throw new WorkflowIrError("Workflow IR column must have a non-empty id"); + } + if (seen.has(column.id)) { + throw new WorkflowIrError(`Workflow IR has duplicate column id '${column.id}'`); + } + seen.add(column.id); + if (!Array.isArray(column.traits)) { + throw new WorkflowIrError(`Workflow IR column '${column.id}' traits must be an array`); + } + } +} + +function validateV2(ir: WorkflowIrV2): void { + validateColumns(ir); + + const columnIds = new Set(ir.columns.map((c) => c.id)); + const nodesById = new Map(ir.nodes.map((n) => [n.id, n])); + + for (const node of ir.nodes) { + if (node.column !== undefined && !columnIds.has(node.column)) { + throw new WorkflowIrError( + `Workflow node '${node.id}' references undefined column '${node.column}'`, + ); + } + if (node.kind === "hold") { + const release = node.config?.release; + if (!HOLD_RELEASE_KINDS.has(release as WorkflowHoldRelease)) { + throw new WorkflowIrError( + `hold node '${node.id}' has unknown release kind '${String(release)}'`, + ); + } + } + } + + const outgoing = buildOutgoing(ir.edges); + validateParallelism(ir.nodes, outgoing, nodesById); +} + export function parseWorkflowIr(input: string | WorkflowIr): WorkflowIr { const value: unknown = typeof input === "string" ? JSON.parse(input) : input; if (!value || typeof value !== "object") { throw new WorkflowIrError("Workflow IR must be an object"); } const ir = value as WorkflowIr; - if (ir.version !== "v1") throw new WorkflowIrError("Workflow IR version must be v1"); + if (ir.version !== "v1" && ir.version !== "v2") { + throw new WorkflowIrError("Workflow IR version must be v1 or v2"); + } if (!Array.isArray(ir.nodes) || !Array.isArray(ir.edges)) { throw new WorkflowIrError("Workflow IR nodes/edges must be arrays"); } @@ -22,9 +242,73 @@ export function parseWorkflowIr(input: string | WorkflowIr): WorkflowIr { if (startCount !== 1 || endCount !== 1) { throw new WorkflowIrError("Workflow IR must contain exactly one start and one end node"); } + + if (ir.version === "v1") { + // Read-path upgrade: v1 graphs become v2 with synthesized default columns + // and seam-based node placement. v1 fixtures keep parsing (FN-5769 contract). + return upgradeV1ToV2(ir); + } + + validateV2(ir); return ir; } +/** v1 node kinds (FN-5769). A pure-v1 graph uses only these; the v2-only kinds + * (hold/split/join) force v2 persistence. */ +const V1_NODE_KINDS: ReadonlySet = new Set([ + "start", + "prompt", + "script", + "gate", + "end", +]); + +/** + * Rollback compat (FN issue #1405): if `ir` is a v2 graph that is byte-for-byte + * equivalent to an upgraded-v1 graph — only v1 node kinds, no hold/split/join, + * and exactly the synthesized default columns at their seam-derived placement — + * downgrade it back to the v1 shape so pre-v2 binaries (which hard-reject + * version !== 'v1') can still load the row. Returns the original `ir` unchanged + * when any v2-only feature is present (custom columns, non-default placement, + * v2-only node kinds), since those genuinely require v2. + */ +export function downgradeIrToV1IfPure(ir: WorkflowIr): WorkflowIr { + if (ir.version !== "v2") return ir; + + // Any v2-only node kind means the graph cannot be represented in v1. + for (const node of ir.nodes) { + if (!V1_NODE_KINDS.has(node.kind)) return ir; + } + + // Columns must be exactly the synthesized default set, same ids, same order, + // with the minimal (placement-only) empty trait set. Any custom column, rename, + // reorder, or applied trait forces v2. + if (ir.columns.length !== DEFAULT_WORKFLOW_COLUMN_IDS.length) return ir; + for (let i = 0; i < ir.columns.length; i++) { + const col = ir.columns[i]; + const expectedId = DEFAULT_WORKFLOW_COLUMN_IDS[i]; + if (col.id !== expectedId || col.name !== expectedId || col.traits.length !== 0) { + return ir; + } + } + + // Every node must sit in its default seam-derived column. A node placed + // elsewhere is a v2 feature (custom placement) and must stay v2. + for (const node of ir.nodes) { + if (node.column !== defaultColumnForNode(node)) return ir; + } + + // Pure v1: emit the v1 shape, dropping the synthesized `column` fields so the + // result round-trips through a pre-v2 binary. (Re-reading it on a v2 binary + // re-upgrades it to the identical v2 graph via upgradeV1ToV2.) + return { + version: "v1", + name: ir.name, + nodes: ir.nodes.map(({ column: _column, ...rest }) => rest), + edges: ir.edges, + }; +} + export function serializeWorkflowIr(ir: WorkflowIr): string { return JSON.stringify(ir, null, 2); } diff --git a/packages/core/src/workflow-parity.ts b/packages/core/src/workflow-parity.ts index 56bc4a92f7..28e1b89379 100644 --- a/packages/core/src/workflow-parity.ts +++ b/packages/core/src/workflow-parity.ts @@ -1,4 +1,7 @@ -import type { RunAuditEvent } from "./types.js"; +import type { Column, RunAuditEvent } from "./types.js"; +import { VALID_TRANSITIONS } from "./types.js"; +import type { WorkflowIr } from "./workflow-ir-types.js"; +import { resolveAllowedColumns, workflowHasColumn } from "./workflow-transitions.js"; export const WORKFLOW_PARITY_OBSERVED_MUTATION = "workflow:parity-observed" as const; export const WORKFLOW_PARITY_DRIFT_MUTATION = "workflow:parity-drift" as const; @@ -315,3 +318,168 @@ export function buildWorkflowObservation(parts: WorkflowObservationParts): Workf invariants: { ...DEFAULT_WORKFLOW_INVARIANTS, ...parts.invariants }, }; } + +// ── Transition parity (U12) ────────────────────────────────────────────────── +// +// The transition-parity suite (U4) proves, as a unit test, that the default +// workflow's resolved column adjacency equals the legacy VALID_TRANSITIONS +// graph. U12 surfaces the SAME comparison as a runtime check so the graduation +// gate can re-evaluate it against whatever IR is actually resolved for the +// default workflow in the field (not just the static fixture), catching a +// deliberately or accidentally drifted default-workflow adjacency. + +/** One adjacency disagreement between the legacy graph and the resolved IR. */ +export interface TransitionParityDiff { + /** The `from` column whose allowed-set diverged. */ + from: string; + /** Allowed targets per the legacy VALID_TRANSITIONS graph. */ + legacyAllowed: string[]; + /** Allowed targets per the resolved workflow IR column graph. */ + resolvedAllowed: string[]; +} + +export interface TransitionParityReport { + /** True when every legacy column's allowed-set matches the resolved IR's. */ + agree: boolean; + /** Per-column adjacency disagreements (empty when `agree`). */ + diffs: TransitionParityDiff[]; +} + +const LEGACY_COLUMNS = Object.keys(VALID_TRANSITIONS) as Column[]; + +function sortedUnique(values: readonly string[]): string[] { + return [...new Set(values)].sort(); +} + +/** + * Compare the default-workflow IR's resolved column adjacency against the legacy + * VALID_TRANSITIONS graph (R12 transition parity, machine-checked). For every + * legacy column, the resolved allowed-set must equal the legacy allowed-set + * exactly (allowed AND rejected). The IR must also recognize every legacy + * column. Any divergence is a graduation blocker. + */ +export function checkTransitionParity(ir: WorkflowIr): TransitionParityReport { + const diffs: TransitionParityDiff[] = []; + for (const from of LEGACY_COLUMNS) { + const legacyAllowed = sortedUnique(VALID_TRANSITIONS[from]); + // A column the resolved IR doesn't even define diverges by construction. + const resolvedAllowed = workflowHasColumn(ir, from) + ? sortedUnique(resolveAllowedColumns(ir, from)) + : []; + const equal = + legacyAllowed.length === resolvedAllowed.length && + legacyAllowed.every((value, index) => value === resolvedAllowed[index]); + if (!equal) diffs.push({ from, legacyAllowed, resolvedAllowed }); + } + return { agree: diffs.length === 0, diffs }; +} + +// ── Dual-accept disagreement counter (U12) ─────────────────────────────────── +// +// U6 logs `merge:dependency-parity-diff` audits whenever the explicit handoff +// marker and the complete-flag column disagree during the FN-5719 dual-accept +// window. The window CLOSES at graduation, so any disagreement above zero over +// the observation period blocks the flip. This surfaces the count (and the +// lease-parity counterpart) from the audit trail as a graduation signal. + +export const DUAL_ACCEPT_PARITY_MUTATIONS = [ + "merge:dependency-parity-diff", + "merge:lease-parity-diff", +] as const; + +const DUAL_ACCEPT_PARITY_MUTATION_SET = new Set(DUAL_ACCEPT_PARITY_MUTATIONS); + +export interface DualAcceptDisagreementReport { + /** Total dual-accept disagreement audit events in scope. */ + total: number; + /** Count per mutation type (dependency vs lease parity diff). */ + byMutationType: Record; +} + +/** + * Count the dual-accept marker/column disagreement audits (U6) in scope. Pure + * over the supplied events so the store can feed it whatever audit window the + * graduation report observes. + */ +export function countDualAcceptDisagreements( + events: readonly RunAuditEvent[], +): DualAcceptDisagreementReport { + const byMutationType: Record = {}; + let total = 0; + for (const event of events) { + const type = String(event.mutationType); + if (event.domain !== "database" || !DUAL_ACCEPT_PARITY_MUTATION_SET.has(type)) continue; + byMutationType[type] = (byMutationType[type] ?? 0) + 1; + total += 1; + } + return { total, byMutationType }; +} + +// ── Graduation report (U12) ────────────────────────────────────────────────── +// +// The flag default-flip criteria, aggregated into one report (KTD-8). The flip +// is a FIELD decision — this report is the GATE, not the trigger. `ready` is +// true only when ALL of: +// - the five-invariant dual-observe parity shows zero drift (drift === 0) over +// a non-empty observation window; +// - the default workflow's transition parity holds (no adjacency drift); +// - zero dual-accept marker/column disagreements over the window. + +export interface WorkflowColumnsGraduationReport { + /** Five-invariant dual-observe parity (from the audit trail). */ + parity: WorkflowParitySummary; + /** Default-workflow transition-graph parity vs VALID_TRANSITIONS. */ + transitionParity: TransitionParityReport; + /** Dual-accept marker/column disagreement count (U6). */ + dualAccept: DualAcceptDisagreementReport; + /** True only when every gate passes — the flag is eligible to default on. */ + ready: boolean; + /** Human-readable blockers when not ready (empty when ready). */ + blockers: string[]; +} + +export interface GraduationReportInputs { + /** Dual-observe parity summary (e.g. `store.getWorkflowParitySummary()`). */ + parity: WorkflowParitySummary; + /** The resolved default-workflow IR to transition-parity-check. */ + defaultWorkflowIr: WorkflowIr; + /** Audit events in the observation window for dual-accept counting. */ + dualAcceptEvents: readonly RunAuditEvent[]; +} + +/** + * Aggregate the flag default-flip criteria into a single graduation report + * (U12, absorbing plan 002's M-D). Pure: the caller assembles the inputs from + * the store's audit trail and resolved default workflow, and decides whether to + * flip the flag — this function only computes the gate. + */ +export function computeWorkflowColumnsGraduationReport( + inputs: GraduationReportInputs, +): WorkflowColumnsGraduationReport { + const { parity, defaultWorkflowIr, dualAcceptEvents } = inputs; + const transitionParity = checkTransitionParity(defaultWorkflowIr); + const dualAccept = countDualAcceptDisagreements(dualAcceptEvents); + + const blockers: string[] = []; + if (parity.observed === 0) { + blockers.push("no parity observations recorded yet (observation window empty)"); + } + if (parity.drift > 0) { + blockers.push(`five-invariant parity drift observed (${parity.drift} drift events)`); + } + if (!transitionParity.agree) { + const cols = transitionParity.diffs.map((d) => d.from).join(", "); + blockers.push(`default-workflow transition parity drifted (columns: ${cols})`); + } + if (dualAccept.total > 0) { + blockers.push(`dual-accept marker/column disagreements above zero (${dualAccept.total})`); + } + + return { + parity, + transitionParity, + dualAccept, + ready: blockers.length === 0, + blockers, + }; +} diff --git a/packages/core/src/workflow-reconciliation.ts b/packages/core/src/workflow-reconciliation.ts new file mode 100644 index 0000000000..382d3645fa --- /dev/null +++ b/packages/core/src/workflow-reconciliation.ts @@ -0,0 +1,236 @@ +/** + * Workflow lifecycle reconciliation (U5, R15/R20). + * + * Defines the policy for every case where a card's column could stop existing + * under it: + * + * (a) workflow SWITCH — the task's selection changes. If the new workflow + * defines a column with the task's current column id, position is + * preserved; otherwise the card re-homes to the new workflow's entry + * (intake-flagged, falling back to the first) column. In-flight processing + * is aborted first via an injected abort callback (engine wires the real + * abort; core ships a safe no-op default + audit entry so core stays + * engine-free). + * + * (b) workflow EDIT removing an occupied column — the update path blocks with + * a typed {@link OccupiedColumnsError} listing per-column occupant counts. + * An explicit `rehomeTo` option allows the save plus re-home of every + * occupant (one audit event per card). + * + * (c) workflow DELETE with occupants — built-ins stay blocked; custom + * workflows re-home occupants to the DEFAULT workflow's entry column, + * clear their selection rows, and preserve task fields (preserveProgress + * semantics), one audit event per card. + * + * Re-homing moves go through `moveTask` with `moveSource: "engine"` + + * `bypassGuards` (a recovery-class move, KTD-9) — never a raw column write — so + * capacity (KTD-10) and the single transition authority (KTD-3) are honored. + * + * This module is pure policy + a DI seam. The store (and dashboard routes via + * the store) own the actual DB reads/writes and the `moveTask` call; this module + * supplies the column-resolution rules and the abort indirection so the policy + * is independently testable and reused identically across switch/edit/delete. + */ + +import type { WorkflowIr, WorkflowIrV2, WorkflowIrColumn } from "./workflow-ir-types.js"; +import { resolveColumnFlags } from "./trait-registry.js"; +import { workflowHasColumn } from "./workflow-transitions.js"; + +// ── Entry-column resolution ────────────────────────────────────────────────── + +/** The v2 columns of an IR, or `[]` when (defensively) absent. */ +function columnsOf(ir: WorkflowIr): WorkflowIrColumn[] { + const v2 = ir as WorkflowIrV2; + return Array.isArray(v2.columns) ? v2.columns : []; +} + +/** + * The entry column id for a workflow: the intake-flagged column (resolved via + * the trait registry's effective-flag merge), falling back to the FIRST + * declared column. Returns `undefined` only when the workflow declares no + * columns at all (should never happen post-parse) — callers treat that as a + * non-reconcilable workflow and leave the card where it is. + */ +export function resolveEntryColumnId(ir: WorkflowIr): string | undefined { + const columns = columnsOf(ir); + if (columns.length === 0) return undefined; + for (const column of columns) { + if (resolveColumnFlags(column).intake) return column.id; + } + return columns[0].id; +} + +// ── (a) Workflow switch ────────────────────────────────────────────────────── + +/** The outcome of resolving where a card lands when its workflow switches. */ +export interface SwitchReconciliation { + /** The column the card should occupy under the new workflow. */ + targetColumn: string; + /** True when the card's current column id exists in the new workflow and was + * therefore preserved; false when it was re-homed to the entry column. */ + preserved: boolean; + /** The entry column the card would re-home to (always resolved, for audit). */ + entryColumn: string | undefined; +} + +/** + * Resolve where a card currently in `currentColumn` lands under `newWorkflowIr`. + * Same-id columns preserve position; otherwise the card re-homes to the new + * workflow's entry column. Pure — the caller performs the abort + move. + */ +export function resolveSwitchReconciliation( + newWorkflowIr: WorkflowIr, + currentColumn: string, +): SwitchReconciliation { + const entryColumn = resolveEntryColumnId(newWorkflowIr); + if (workflowHasColumn(newWorkflowIr, currentColumn)) { + return { targetColumn: currentColumn, preserved: true, entryColumn }; + } + // No same-id column: re-home to the entry column. When the new workflow + // declares no columns at all (entryColumn undefined), leave the card where it + // is rather than strand it in nowhere. + return { + targetColumn: entryColumn ?? currentColumn, + preserved: false, + entryColumn, + }; +} + +// ── (b) Workflow edit removing an occupied column ──────────────────────────── + +/** Per-column occupant count for a blocked edit/delete. */ +export interface ColumnOccupancy { + columnId: string; + count: number; +} + +/** + * Thrown by the store's update path (and surfaced as a structured 409 by the + * dashboard) when a workflow edit would remove one or more columns that still + * hold cards, and no `rehomeTo` was supplied. Carries the per-column occupant + * counts so the surface can prompt for a re-home target. + */ +export class OccupiedColumnsError extends Error { + readonly workflowId: string; + readonly occupancies: ColumnOccupancy[]; + constructor(workflowId: string, occupancies: ColumnOccupancy[]) { + const summary = occupancies + .map((o) => `${o.columnId} (${o.count})`) + .join(", "); + super( + `Workflow '${workflowId}' edit removes occupied column(s): ${summary}. ` + + `Re-home the occupants (rehomeTo) or move them out first.`, + ); + this.name = "OccupiedColumnsError"; + this.workflowId = workflowId; + this.occupancies = occupancies; + } +} + +/** + * Compute which currently-occupied columns would be removed by replacing the + * existing IR with `nextIr`. `occupantsByColumn` maps a column id to the number + * of cards currently in it (under this workflow). Returns one entry per removed + * column that still has occupants, in the existing IR's column order. + */ +export function computeRemovedOccupiedColumns( + existingIr: WorkflowIr, + nextIr: WorkflowIr, + occupantsByColumn: Map, +): ColumnOccupancy[] { + const nextIds = new Set(columnsOf(nextIr).map((c) => c.id)); + const removed: ColumnOccupancy[] = []; + for (const column of columnsOf(existingIr)) { + if (nextIds.has(column.id)) continue; + const count = occupantsByColumn.get(column.id) ?? 0; + if (count > 0) removed.push({ columnId: column.id, count }); + } + return removed; +} + +/** + * Thrown when a supplied `rehomeTo` names a column that does not exist in the + * post-edit workflow. Distinct from {@link OccupiedColumnsError} (which signals + * a conflict needing a re-home target) — this is a bad-request input error and + * the dashboard maps it to a 400, not a 409. + */ +export class InvalidRehomeTargetError extends Error { + readonly workflowId: string; + readonly rehomeTo: string; + constructor(workflowId: string, rehomeTo: string) { + super( + `Workflow '${workflowId}' has no column '${rehomeTo}' to re-home occupants into.`, + ); + this.name = "InvalidRehomeTargetError"; + this.workflowId = workflowId; + this.rehomeTo = rehomeTo; + } +} + +/** + * Validate that `rehomeTo` (when supplied for an edit that removes occupied + * columns) names a column that survives in `nextIr`. Throws when it does not, so + * occupants are never re-homed into a column that won't exist either. + */ +export function assertRehomeTargetValid(nextIr: WorkflowIr, rehomeTo: string): void { + if (!workflowHasColumn(nextIr, rehomeTo)) { + throw new InvalidRehomeTargetError( + (nextIr as WorkflowIrV2).name ?? "(unknown)", + rehomeTo, + ); + } +} + +// ── Abort-on-switch DI seam (core stays engine-free) ───────────────────────── +// +// A workflow switch must abort the card's in-flight processing BEFORE the move +// (mirroring abort-on-exit, KTD-9). Aborting touches engine machinery (sessions +// / leases), which core cannot import. The engine wires its abort in via +// `setReconciliationAbort` (mirrors `setCreateFnAgent`); when unset (isolated +// core tests, or engine not loaded) the default is a safe no-op that records an +// audit breadcrumb so the bypass is visible — degraded, not crashed. + +/** What the store passes to the abort callback so the engine can locate the + * session/lease to abort and the store can record audit. */ +export interface ReconciliationAbortContext { + taskId: string; + fromColumn: string; + reason: "workflow-switch" | "workflow-delete" | "workflow-edit-rehome"; +} + +/** The injected abort implementation. Returns nothing; failures must not throw + * (a failed abort degrades to an audit entry — it never strands the card). */ +export type ReconciliationAbort = (ctx: ReconciliationAbortContext) => void | Promise; + +let reconciliationAbort: ReconciliationAbort | undefined; + +/** + * Wire the engine's abort implementation into core. Called by `@fusion/engine` + * at module load; tests may register a stub (or leave it unset for the no-op). + * Passing `undefined` restores the default no-op. + */ +export function setReconciliationAbort(fn: ReconciliationAbort | undefined): void { + reconciliationAbort = fn; +} + +/** + * Run the wired abort, or the safe default no-op when none is registered. Always + * resolves (swallows abort errors) so reconciliation never wedges on a failing + * abort. Returns `true` when a real abort ran, `false` for the default no-op — + * the store records the appropriate audit either way. + */ +export async function runReconciliationAbort(ctx: ReconciliationAbortContext): Promise { + if (!reconciliationAbort) return false; + try { + await reconciliationAbort(ctx); + } catch { + // A failed abort must not strand the card — the caller still re-homes it, + // and records a degraded-abort audit. Swallow here. + } + return true; +} + +/** Test-only: reset the wired abort to the default no-op. */ +export function __resetReconciliationAbortForTests(): void { + reconciliationAbort = undefined; +} diff --git a/packages/core/src/workflow-transitions.ts b/packages/core/src/workflow-transitions.ts new file mode 100644 index 0000000000..ed81c1243d --- /dev/null +++ b/packages/core/src/workflow-transitions.ts @@ -0,0 +1,108 @@ +/** + * Workflow-resolved transition adjacency (U4, R4/R9/R13). + * + * `moveTaskInternal` (flag ON) and `board.ts` both derive "which columns can a + * card move to from here" from the SAME helper so the two surfaces never + * diverge — `resolveAllowedColumns(ir, fromColumn)`. + * + * ── Why an explicit adjacency, not pure graph-derivation ────────────────────── + * + * The plan asks: derive allowed column adjacency from node placement + edges, + * and for the DEFAULT workflow it MUST reproduce `VALID_TRANSITIONS` exactly. + * Pure graph-edge derivation CANNOT reproduce it: `VALID_TRANSITIONS` encodes + * backward/reopen edges (in-review → todo, done → todo, archived → done, …) and + * cross edges (in-progress → done) that have no counterpart in the linear + * execute → review → merge → end pipeline graph. The IR edges describe the + * forward automation walk; the column adjacency describes legal *board* moves + * (drags, reopens, recovery), which is a strictly larger, partly-cyclic set. + * + * So per the plan's documented fallback we attach an explicit per-column + * `transitions` adjacency: + * - For the BUILT-IN default workflow we reproduce `VALID_TRANSITIONS` verbatim + * (keyed by the legacy column ids, which are exactly the default workflow's + * column ids — KTD-1). This is the parity contract the transition-parity + * suite machine-checks. + * - For CUSTOM workflows (no explicit adjacency authored yet — authoring lands + * with the editor in U10) we derive a linear forward+back adjacency from the + * declared column ORDER: each column may move to its neighbors (prev/next). + * This is a safe, predictable default that keeps every column reachable and + * never strands a card; richer custom adjacency is future work. + * + * The adjacency is intentionally a column→columns map computed once per IR; it + * is read-only and pure. + */ + +import { VALID_TRANSITIONS } from "./types.js"; +import type { Column } from "./types.js"; +import type { WorkflowIr, WorkflowIrV2 } from "./workflow-ir-types.js"; +import { DEFAULT_WORKFLOW_COLUMN_IDS } from "./workflow-ir.js"; + +/** A column→allowed-target-columns adjacency map. */ +export type ColumnAdjacency = Map; + +/** True when the IR's columns are exactly the legacy default-workflow column ids + * (same set), i.e. this is the built-in default workflow (or an equivalent). */ +function isDefaultWorkflowColumns(ir: WorkflowIrV2): boolean { + const ids = ir.columns.map((c) => c.id); + if (ids.length !== DEFAULT_WORKFLOW_COLUMN_IDS.length) return false; + const set = new Set(ids); + return DEFAULT_WORKFLOW_COLUMN_IDS.every((id) => set.has(id)); +} + +/** Build the verbatim `VALID_TRANSITIONS` adjacency keyed by column id. */ +function defaultWorkflowAdjacency(): ColumnAdjacency { + const adj: ColumnAdjacency = new Map(); + for (const [from, targets] of Object.entries(VALID_TRANSITIONS) as [Column, Column[]][]) { + adj.set(from, [...targets]); + } + return adj; +} + +/** Derive a neighbor (prev/next by declared order) adjacency for a custom + * workflow. Each column can move to the column before and after it in the + * authored order. Endpoints have a single neighbor. */ +function orderDerivedAdjacency(ir: WorkflowIrV2): ColumnAdjacency { + const adj: ColumnAdjacency = new Map(); + const ids = ir.columns.map((c) => c.id); + for (let i = 0; i < ids.length; i++) { + const targets: string[] = []; + if (i > 0) targets.push(ids[i - 1]); + if (i < ids.length - 1) targets.push(ids[i + 1]); + adj.set(ids[i], targets); + } + return adj; +} + +/** + * Resolve the full column adjacency for a workflow IR. The default workflow + * reproduces `VALID_TRANSITIONS` exactly; custom workflows use order-derived + * neighbor adjacency. + */ +export function resolveColumnAdjacency(ir: WorkflowIr): ColumnAdjacency { + // v1 IR is upgraded to v2 on parse, but accept either defensively. + const v2 = ir as WorkflowIrV2; + if (!Array.isArray(v2.columns)) { + // No columns (shouldn't happen post-parse) → empty adjacency. + return new Map(); + } + if (isDefaultWorkflowColumns(v2)) { + return defaultWorkflowAdjacency(); + } + return orderDerivedAdjacency(v2); +} + +/** + * The allowed target columns for a move out of `fromColumn` under this workflow. + * Returns an empty array when `fromColumn` is unknown to the workflow (callers + * should first check column existence to distinguish "unknown column" from "no + * legal targets"). + */ +export function resolveAllowedColumns(ir: WorkflowIr, fromColumn: string): string[] { + return resolveColumnAdjacency(ir).get(fromColumn) ?? []; +} + +/** True when `toColumn` is a defined column of the workflow. */ +export function workflowHasColumn(ir: WorkflowIr, columnId: string): boolean { + const v2 = ir as WorkflowIrV2; + return Array.isArray(v2.columns) && v2.columns.some((c) => c.id === columnId); +} diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 09e9b60a8a..719bfaa214 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -533,6 +533,49 @@ export function moveTask( }); } +/** Resolved trait flags for a board column (subset the client cares about). */ +export interface BoardWorkflowColumnFlags { + countsTowardWip?: boolean; + complete?: boolean; + archived?: boolean; + hiddenFromBoard?: boolean; + hold?: boolean; + intake?: boolean; + mergeBlocker?: boolean; + humanReview?: boolean; + [key: string]: boolean | undefined; +} + +export interface BoardWorkflowColumn { + id: string; + name: string; + flags: BoardWorkflowColumnFlags; +} + +export interface BoardWorkflowDefinition { + id: string; + name: string; + columns: BoardWorkflowColumn[]; +} + +export interface BoardWorkflowsPayload { + flagEnabled: boolean; + defaultWorkflowId: string; + workflows: BoardWorkflowDefinition[]; + taskWorkflowIds: Record; +} + +/** Fetch the multi-lane board metadata (U9). When the flag is OFF the server + * returns `{ flagEnabled: false }` and the board renders its legacy form. */ +export function fetchBoardWorkflows(projectId?: string): Promise { + return api(withProjectId("/tasks/board-workflows", projectId)); +} + +/** Manually promote a held card out of its hold column (U9). */ +export function promoteTask(id: string, projectId?: string): Promise { + return api(withProjectId(`/tasks/${id}/promote`, projectId), { method: "POST" }); +} + /** * Soft-deletes a task by setting `deletedAt` server-side while preserving the row/artifacts, * and keeping the task ID reserved. @@ -4958,6 +5001,27 @@ export function fetchWorkflows(projectId?: string): Promise api(path)); } +/** A trait catalog entry as returned by GET /api/traits (U10). Mirrors the + * registry's TraitDefinition projection (flags + hook descriptors + schema). */ +export interface TraitCatalogEntry { + id: string; + name: string; + description?: string; + builtin: boolean; + flags: import("@fusion/core").TraitFlags; + hooks?: import("@fusion/core").TraitHookDescriptors; + configSchema?: import("@fusion/core").TraitConfigSchema; +} + +/** Fetch the trait catalog (built-ins + registered plugin traits) for the + * workflow editor's trait picker. Registry-backed, read-only, session-scoped. */ +export function fetchTraits(projectId?: string): Promise { + const path = withProjectId("/traits", projectId); + return dedupe(path, () => + api<{ traits: TraitCatalogEntry[] }>(path).then((res) => res.traits), + ); +} + /** Fetch a single workflow definition. */ export function fetchWorkflow(id: string, projectId?: string): Promise { return api(withProjectId(`/workflows/${encodeURIComponent(id)}`, projectId)); @@ -5011,8 +5075,18 @@ export function selectTaskWorkflow( taskId: string, workflowId: string | null, projectId?: string, -): Promise<{ workflowId: string | null; enabledWorkflowSteps: string[] }> { - return api<{ workflowId: string | null; enabledWorkflowSteps: string[] }>( +): Promise<{ + workflowId: string | null; + enabledWorkflowSteps: string[]; + // U5 (R20): present (flag ON) when the switch re-homed the card; `preserved` + // false means the card moved columns and the board needs a refresh. + reconciliation?: { preserved: boolean; fromColumn: string; toColumn: string }; +}> { + return api<{ + workflowId: string | null; + enabledWorkflowSteps: string[]; + reconciliation?: { preserved: boolean; fromColumn: string; toColumn: string }; + }>( withProjectId(`/tasks/${encodeURIComponent(taskId)}/workflow`, projectId), { method: "PUT", diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 423c39df46..59e68579bd 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -2,11 +2,16 @@ import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIss import { COLUMNS, DEFAULT_COLUMN, isColumn } from "@fusion/core"; import { sortTasksForDisplayColumn } from "./taskSorting"; import { Column } from "./Column"; +import { Lane } from "./Lane"; import type { ToastType } from "../hooks/useToast"; import { useState, useMemo, useEffect, useCallback, useRef } from "react"; -import { fetchWorkflowSteps, type ModelInfo } from "../api"; +import { fetchWorkflowSteps, fetchBoardWorkflows, promoteTask, type ModelInfo, type BoardWorkflowsPayload } from "../api"; import { useBlockerFanout } from "../hooks/useBlockerFanout"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; +import { subscribeSse } from "../sse-bus"; + +/** localStorage key for persisted lane collapse state (per project). */ +const LANE_COLLAPSE_STORAGE_KEY = "kb-dashboard-lane-collapsed"; interface BoardProps { tasks: Task[]; @@ -261,10 +266,215 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask }; }, []); + // ── U9 multi-lane board (flag-gated) ────────────────────────────────────── + // Fetch board-workflows metadata. When the flag is OFF the server returns + // { flagEnabled: false } and we render the legacy single-lane board below. + const [boardWorkflows, setBoardWorkflows] = useState(null); + const draggingTaskIdRef = useRef(null); + const [collapsedLanes, setCollapsedLanes] = useState>(() => { + if (typeof window === "undefined") return new Set(); + try { + const raw = window.localStorage.getItem(LANE_COLLAPSE_STORAGE_KEY); + const parsed = raw ? (JSON.parse(raw) as unknown) : null; + if (Array.isArray(parsed)) return new Set(parsed.filter((x): x is string => typeof x === "string")); + } catch { + /* ignore corrupt persisted state */ + } + return new Set(); + }); + + // Fetch board workflow lanes for the project. Deliberately NOT keyed on + // `tasks` — that refetched on every SSE tick. Instead we refetch on project + // change and when the tab regains visibility/focus. A stale-response guard + // (monotonic sequence ref) drops out-of-order responses. + // A `workflow:updated` (and create/delete) SSE event now drives invalidation + // when a definition's lanes / column traits change. The visibility/focus + // refetch below is retained as a stopgap for missed events / reconnects. + const boardWorkflowsFetchSeqRef = useRef(0); + useEffect(() => { + const runFetch = () => { + const seq = ++boardWorkflowsFetchSeqRef.current; + fetchBoardWorkflows(projectId) + .then((payload) => { + if (seq === boardWorkflowsFetchSeqRef.current) setBoardWorkflows(payload); + }) + .catch(() => { + if (seq === boardWorkflowsFetchSeqRef.current) { + setBoardWorkflows({ flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} }); + } + }); + }; + runFetch(); + const onVisible = () => { + if (typeof document === "undefined" || document.visibilityState === "visible") runFetch(); + }; + if (typeof document !== "undefined") document.addEventListener("visibilitychange", onVisible); + if (typeof window !== "undefined") window.addEventListener("focus", onVisible); + const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; + const unsubscribe = subscribeSse(`/api/events${query}`, { + events: { + "workflow:created": runFetch, + "workflow:updated": runFetch, + "workflow:deleted": runFetch, + }, + }); + return () => { + // Advance the seq so any in-flight response is dropped on cleanup. + boardWorkflowsFetchSeqRef.current++; + if (typeof document !== "undefined") document.removeEventListener("visibilitychange", onVisible); + if (typeof window !== "undefined") window.removeEventListener("focus", onVisible); + unsubscribe(); + }; + }, [projectId]); + + const handleToggleLaneCollapse = useCallback((workflowId: string) => { + setCollapsedLanes((prev) => { + const next = new Set(prev); + if (next.has(workflowId)) next.delete(workflowId); + else next.add(workflowId); + if (typeof window !== "undefined") { + try { + window.localStorage.setItem(LANE_COLLAPSE_STORAGE_KEY, JSON.stringify([...next])); + } catch { + /* ignore quota / serialization errors */ + } + } + return next; + }); + }, []); + + const handlePromote = useCallback(async (taskId: string) => { + await promoteTask(taskId, projectId); + }, [projectId]); + + const getDraggingTaskId = useCallback(() => draggingTaskIdRef.current, []); + + const flagOn = boardWorkflows?.flagEnabled === true; + + // Group visible tasks into lanes by resolved workflow (null → default lane). + const lanes = useMemo(() => { + if (!boardWorkflows || !flagOn) return []; + const { workflows, taskWorkflowIds, defaultWorkflowId } = boardWorkflows; + const byId = new Map(workflows.map((w) => [w.id, w] as const)); + const tasksByWorkflow = new Map(); + for (const task of tasks) { + // Archived cards are excluded from lanes (archived columns are hidden). + if (task.column === "archived") continue; + const workflowId = taskWorkflowIds[task.id] ?? defaultWorkflowId; + (tasksByWorkflow.get(workflowId) ?? tasksByWorkflow.set(workflowId, []).get(workflowId)!).push(task); + } + const result: Array<{ workflow: typeof workflows[number]; tasks: Task[] }> = []; + for (const [workflowId, laneTasks] of tasksByWorkflow) { + const workflow = byId.get(workflowId); + if (!workflow) continue; + if (laneTasks.length === 0) continue; // zero-card lanes hidden + result.push({ workflow, tasks: laneTasks }); + } + // Default lane first; then by workflow name for stable ordering. + result.sort((a, b) => { + if (a.workflow.id === defaultWorkflowId) return -1; + if (b.workflow.id === defaultWorkflowId) return 1; + return a.workflow.name.localeCompare(b.workflow.name); + }); + return result; + }, [boardWorkflows, flagOn, tasks]); + + // Drag pre-check (R17): adjacency + capacity from the lane's column metadata. + // Cross-lane drag → workflow-mismatch. Deterministic rejections return a + // messageKey (no-move); null = allowed. + const canDropTask = useCallback((taskId: string, targetColumnId: string, laneWorkflowId: string): string | null => { + if (!boardWorkflows) return null; + const sourceTask = tasks.find((t) => t.id === taskId); + if (!sourceTask) return null; + const sourceWorkflowId = boardWorkflows.taskWorkflowIds[taskId] ?? boardWorkflows.defaultWorkflowId; + // Cross-lane drag never switches workflows (R17). + if (sourceWorkflowId !== laneWorkflowId) { + return "board.rejection.workflowMismatch"; + } + const workflow = boardWorkflows.workflows.find((w) => w.id === laneWorkflowId); + if (!workflow) return null; + const targetCol = workflow.columns.find((c) => c.id === targetColumnId); + if (!targetCol) return "board.rejection.unknownColumn"; + // Capacity pre-check: a wip-flagged column that is already full rejects. + if (targetCol.flags.countsTowardWip) { + const occupants = tasks.filter( + (t) => t.column === targetColumnId && (boardWorkflows.taskWorkflowIds[t.id] ?? boardWorkflows.defaultWorkflowId) === laneWorkflowId, + ).length; + // The default workflow's in-progress limit is maxConcurrent; custom limits + // are enforced authoritatively server-side (the 409 fallback still snaps back). + if (Number.isFinite(maxConcurrent) && maxConcurrent > 0 && sourceTask.column !== targetColumnId && occupants >= maxConcurrent) { + return "board.rejection.capacityExhausted"; + } + } + return null; + }, [boardWorkflows, tasks, maxConcurrent]); + // FN-4380: GitHub badge state comes from persisted task fields (`task.prInfo`, // `task.issueInfo`, `task.githubTracking.issue`) and live WebSocket `badge:updated` // messages. We do NOT eagerly call `/api/github/batch-status` on board load. + if (flagOn) { + return ( +
{ + const id = (e.target as HTMLElement)?.closest?.("[data-id]")?.getAttribute("data-id"); + if (id) draggingTaskIdRef.current = id; + }} + onDragEnd={() => { + draggingTaskIdRef.current = null; + }} + > + {lanes.map(({ workflow, tasks: laneTasks }) => ( + + ))} +
+ ); + } + return ( <>
diff --git a/packages/dashboard/app/components/ChangesDiffModal.tsx b/packages/dashboard/app/components/ChangesDiffModal.tsx index 4ebcd553e2..e9147c1a85 100644 --- a/packages/dashboard/app/components/ChangesDiffModal.tsx +++ b/packages/dashboard/app/components/ChangesDiffModal.tsx @@ -11,7 +11,7 @@ import { RefreshCw, GitCommit, } from "lucide-react"; -import type { MergeDetails, Column } from "@fusion/core"; +import type { MergeDetails, ColumnId } from "@fusion/core"; import { highlightDiff } from "../utils/highlightDiff"; import "./TaskDiffShared.css"; import "./ChangesDiffModal.css"; @@ -31,7 +31,7 @@ interface ChangesDiffModalProps { files: NormalizedFile[]; stats: { filesChanged: number; additions: number; deletions: number }; mergeDetails?: MergeDetails; - column?: Column; + column?: ColumnId; onClose: () => void; onRefresh?: () => void; } diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index abcd3af535..b04835e8e4 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -11,13 +11,80 @@ import { PluginSlot } from "./PluginSlot"; import { groupByWorktree } from "../utils/worktreeGrouping"; import type { ToastType } from "../hooks/useToast"; import { ChevronDown, ChevronUp, Archive, MoreVertical } from "lucide-react"; -import type { ModelInfo } from "../api"; +import type { ModelInfo, BoardWorkflowColumnFlags } from "../api"; import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout"; const PAGINATED_COLUMN_THRESHOLD = 100; const VISIBLE_TASKS_INITIAL = 50; const VISIBLE_TASKS_INCREMENT = 25; +/** Shape of a structured transition rejection carried in a 409's `details`. */ +interface TransitionRejectionDetail { + code: string; + messageKey: string; + retryable: boolean; +} + +/** + * Pull a typed transition rejection out of an `ApiRequestError`'s `details` + * (the structured 409 the move/promote endpoints emit under the workflowColumns + * flag). Returns null for any other error shape (legacy errors are unchanged). + */ +export function extractTransitionRejection(err: unknown): TransitionRejectionDetail | null { + const details = (err as { details?: Record } | null)?.details; + if (!details || typeof details !== "object") return null; + const { code, messageKey, retryable } = details as Record; + if (typeof code === "string" && typeof messageKey === "string") { + return { code, messageKey, retryable: retryable === true }; + } + return null; +} + +/** + * Resolve a rejection (by stable code, falling back to its messageKey) to + * user-facing copy. The static `t()` literals here are what the i18next + * extractor sees, so the `board.rejection.*` keys persist in the catalog and + * the surfaces show real copy rather than a raw key. The `messageKey` carried by + * the rejection is still honored as the lookup so a server-chosen non-default + * key resolves correctly. + */ +type TFn = (key: string, defaultValue: string) => string; +export function translateRejection(t: TFn, rejection: TransitionRejectionDetail): string { + switch (rejection.code) { + case "guard-rejected": + return t("board.rejection.guardRejected", "This move is not allowed by the workflow."); + case "capacity-exhausted": + return t("board.rejection.capacityExhausted", "That column is at capacity. Try again when a slot frees up."); + case "unknown-column": + return t("board.rejection.unknownColumn", "That column doesn't exist in this task's workflow."); + case "workflow-mismatch": + return t("board.rejection.workflowMismatch", "Drag can't move a card between workflows. Use the workflow switcher instead."); + case "merge-blocked": + return t("board.rejection.mergeBlocked", "This task is blocked from completing until its merge step finishes."); + default: + return t(rejection.messageKey, rejection.messageKey); + } +} + +/** Translate a bare drag pre-check messageKey (R17 no-move) to copy. The same + * static literals as {@link translateRejection} so the extractor keeps them. */ +export function translateRejectionKey(t: TFn, messageKey: string): string { + switch (messageKey) { + case "board.rejection.guardRejected": + return t("board.rejection.guardRejected", "This move is not allowed by the workflow."); + case "board.rejection.capacityExhausted": + return t("board.rejection.capacityExhausted", "That column is at capacity. Try again when a slot frees up."); + case "board.rejection.unknownColumn": + return t("board.rejection.unknownColumn", "That column doesn't exist in this task's workflow."); + case "board.rejection.workflowMismatch": + return t("board.rejection.workflowMismatch", "Drag can't move a card between workflows. Use the workflow switcher instead."); + case "board.rejection.mergeBlocked": + return t("board.rejection.mergeBlocked", "This task is blocked from completing until its merge step finishes."); + default: + return t(messageKey, messageKey); + } +} + interface ColumnProps { column: ColumnType; tasks: Task[]; @@ -77,20 +144,67 @@ interface ColumnProps { blockerFanoutMap?: ReadonlyMap; /** Whether GitHub CLI auth is available for creating PRs from task cards. */ prAuthAvailable?: boolean; + // ── U9 workflow-columns (flag-ON) additive props ───────────────────────── + /** True when the board is in multi-lane workflow mode (flag ON). Switches + * column behavior (label, bulk actions, archived detection) from legacy + * literals to trait-flag predicates. Flag OFF leaves all behavior legacy. */ + workflowMode?: boolean; + /** Display name for this column, from the workflow definition. */ + columnDisplayName?: string; + /** Resolved trait flags for this column (workflow mode). */ + columnFlags?: BoardWorkflowColumnFlags; + /** Manually promote a held card out of this hold column (workflow mode). */ + onPromote?: (taskId: string) => Promise; + /** + * Pre-check whether a drop into THIS column is allowed for the dragged task. + * Returns null for "allowed", or an i18n messageKey for a deterministic + * rejection (guard/capacity/unknown-column/workflow-mismatch). When a + * rejection is returned, dragover is NOT prevented, so the card never renders + * in this column (no-move semantics, R17). The dragged task id is read from a + * board-level ref set on dragstart. + */ + canDropTask?: (taskId: string) => string | null; + /** Read the id of the task currently being dragged (board-level ref). */ + getDraggingTaskId?: () => string | null; } -function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup, blockerFanoutMap, prAuthAvailable }: ColumnProps) { +function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup, blockerFanoutMap, prAuthAvailable, workflowMode, columnDisplayName, columnFlags, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) { const { t } = useTranslation("app"); + // Anchor the board.rejection.* catalog keys for the i18next extractor (it + // scopes `t` to the useTranslation binding, so the shared translateRejection + // helper's calls are not statically discovered). These resolve the same copy. + const rejectionCopy = useMemo(() => ({ + guardRejected: t("board.rejection.guardRejected", "This move is not allowed by the workflow."), + capacityExhausted: t("board.rejection.capacityExhausted", "That column is at capacity. Try again when a slot frees up."), + unknownColumn: t("board.rejection.unknownColumn", "That column doesn't exist in this task's workflow."), + workflowMismatch: t("board.rejection.workflowMismatch", "Drag can't move a card between workflows. Use the workflow switcher instead."), + mergeBlocked: t("board.rejection.mergeBlocked", "This task is blocked from completing until its merge step finishes."), + promoteRejected: t("board.rejection.promoteRejected", "This card could not be promoted."), + }), [t]); + void rejectionCopy; const [dragOver, setDragOver] = useState(false); const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL); const [isMenuOpen, setIsMenuOpen] = useState(false); const [isReplanning, setIsReplanning] = useState(false); const [isPausingAll, setIsPausingAll] = useState(false); const [isMovingAllToTodo, setIsMovingAllToTodo] = useState(false); + // Workflow mode: per-card promote in-flight ids + inline capacity feedback. + const [promotingIds, setPromotingIds] = useState>(() => new Set()); + const [inlineFeedback, setInlineFeedback] = useState(null); const menuRef = useRef(null); const countFlashing = useFlashOnIncrease(tasks.length); const { confirm } = useConfirm(); + // Clear the inline capacity-exhausted banner once the column's task list + // changes via SSE (e.g. an occupant moves out and capacity frees up). The + // banner reflects a point-in-time promote rejection; a changed roster means + // the stale constraint may no longer hold. Keyed on the task-id signature so + // it only fires on real membership changes, not every parent re-render. + const taskIdSignature = useMemo(() => tasks.map((task) => task.id).join(","), [tasks]); + useEffect(() => { + setInlineFeedback(null); + }, [taskIdSignature]); + // Close the column dropdown menu when the user clicks anywhere else. useEffect(() => { if (!isMenuOpen) return; @@ -110,34 +224,54 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, }; }, [isMenuOpen]); - // Archived column is collapsed by default - don't show drag state when collapsed - const isArchived = column === "archived"; + // Archived column is collapsed by default - don't show drag state when collapsed. + // Workflow mode keys off the resolved `archived` trait flag instead of the + // literal column id (R9). A hold-flagged column shows the promote affordance. + const isArchived = workflowMode ? Boolean(columnFlags?.archived) : column === "archived"; + const isHoldColumn = workflowMode && Boolean(columnFlags?.hold); const isCollapsed = isArchived && collapsed; + // Legacy in-progress renders worktree groups (not paginated); in workflow + // mode there is no special-casing, so a processing column paginates normally. + const isLegacyInProgress = !workflowMode && column === "in-progress"; // When search is active, skip pagination so all matching tasks are visible - const shouldPaginate = !isArchived && !isSearchActive && column !== "in-progress" && tasks.length > PAGINATED_COLUMN_THRESHOLD; + const shouldPaginate = !isArchived && !isSearchActive && !isLegacyInProgress && tasks.length > PAGINATED_COLUMN_THRESHOLD; useEffect(() => { setVisibleTaskCount((current) => { - if (column === "in-progress" || isArchived || tasks.length <= PAGINATED_COLUMN_THRESHOLD) { + if (isLegacyInProgress || isArchived || tasks.length <= PAGINATED_COLUMN_THRESHOLD) { return VISIBLE_TASKS_INITIAL; } return Math.min(Math.max(current, VISIBLE_TASKS_INITIAL), tasks.length); }); - }, [column, isArchived, tasks.length]); + }, [isLegacyInProgress, isArchived, tasks.length]); const handleDragOver = useCallback((e: React.DragEvent) => { // Don't allow dropping into archived column via drag-drop if (isArchived) return; + // Workflow mode (R17): deterministic rejections are NO-MOVE — we do NOT + // call preventDefault, so the browser refuses the drop and the card never + // renders in this column. A null result means the drop is allowed. + if (workflowMode && canDropTask && getDraggingTaskId) { + const draggingId = getDraggingTaskId(); + if (draggingId) { + const rejectionKey = canDropTask(draggingId); + if (rejectionKey) { + setInlineFeedback(translateRejectionKey(t, rejectionKey)); + return; // no preventDefault → no-move + } + } + } e.preventDefault(); e.dataTransfer.dropEffect = "move"; setDragOver(true); - }, [isArchived]); + }, [isArchived, workflowMode, canDropTask, getDraggingTaskId, t]); const handleDragLeave = useCallback((e: React.DragEvent) => { const el = e.currentTarget as HTMLElement; if (!el.contains(e.relatedTarget as Node)) { setDragOver(false); + setInlineFeedback(null); } }, []); @@ -185,14 +319,52 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, await onMoveTask(taskId, column, moveOptions); } catch (err) { - addToast(getErrorMessage(err), "error"); + // Workflow mode (R17): a structured 409 carries a typed rejection. The + // optimistic move snaps back automatically (the next SSE/refresh restores + // the card's real column); surface the translated rejection messageKey. + const rejection = extractTransitionRejection(err); + if (rejection) { + addToast(translateRejection(t, rejection), "error"); + } else { + addToast(getErrorMessage(err), "error"); + } } - }, [addToast, allTasks, column, confirm, onMoveTask, tasks]); + }, [addToast, allTasks, column, confirm, onMoveTask, tasks, t]); + const handlePromote = useCallback(async (taskId: string) => { + if (!onPromote) return; + setInlineFeedback(null); + setPromotingIds((prev) => { + const next = new Set(prev); + next.add(taskId); + return next; + }); + try { + await onPromote(taskId); + } catch (err) { + const rejection = extractTransitionRejection(err); + if (rejection) { + // Capacity-exhausted (and any rejection) shows INLINE column feedback, + // not a toast — so multiple holds can promote concurrently without spam. + setInlineFeedback(translateRejection(t, rejection)); + } else { + setInlineFeedback(getErrorMessage(err)); + } + } finally { + setPromotingIds((prev) => { + const next = new Set(prev); + next.delete(taskId); + return next; + }); + } + }, [onPromote, t]); + + // Worktree grouping is a legacy in-progress affordance; in workflow mode a + // custom processing column renders plain cards (KTD-11 keeps one-card-one-lane). const worktreeGroups = useMemo(() => { - if (column !== "in-progress") return []; + if (!isLegacyInProgress) return []; return groupByWorktree(tasks, tasks, maxConcurrent); - }, [column, tasks, maxConcurrent]); + }, [isLegacyInProgress, tasks, maxConcurrent]); const visibleTasks = useMemo(() => { if (!shouldPaginate) return tasks; @@ -238,7 +410,13 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, [tasks], ); const pauseEligibleCount = pauseEligibleTasks.length; - const hasColumnBulkActions = column === "todo" || column === "in-progress" || column === "in-review"; + // Bulk-action eligibility (R9): workflow mode keys off trait flags instead of + // the literal column ids. Todo-equivalent = hold/intake (replan affordance); + // processing = wip/countsTowardWip; review = mergeBlocker/humanReview. + const isTodoLikeColumn = workflowMode ? Boolean(columnFlags?.hold || columnFlags?.intake) : column === "todo"; + const isProcessingColumn = workflowMode ? Boolean(columnFlags?.countsTowardWip) : column === "in-progress"; + const isReviewColumn = workflowMode ? Boolean(columnFlags?.mergeBlocker || columnFlags?.humanReview) : column === "in-review"; + const hasColumnBulkActions = isTodoLikeColumn || isProcessingColumn || isReviewColumn; const isMenuBusy = isReplanning || isPausingAll || isMovingAllToTodo; const handlePauseAll = useCallback(async () => { @@ -353,9 +531,9 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, >
-

{COLUMN_LABELS[column]}

+

{workflowMode ? (columnDisplayName ?? COLUMN_LABELS[column] ?? column) : COLUMN_LABELS[column]}

{tasks.length} - {column === "in-review" && onToggleAutoMerge && ( + {(workflowMode ? isReviewColumn : column === "in-review") && onToggleAutoMerge && (
@@ -2009,8 +2015,8 @@ export function ListView({ {columnLabel(task.column)} @@ -2043,7 +2049,7 @@ export function ListView({ className="list-progress-fill" style={{ width: `${taskProgress.percent}%`, - backgroundColor: COLUMN_COLOR_MAP[task.column], + backgroundColor: columnColor(task.column), }} />
diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 44078218eb..896d611b7c 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react"; import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest } from "lucide-react"; -import type { Task, TaskDetail, Column, PrInfo, IssueInfo, TaskPriority, GithubIssueAction } from "@fusion/core"; +import type { Task, TaskDetail, Column, ColumnId, PrInfo, IssueInfo, TaskPriority, GithubIssueAction } from "@fusion/core"; import { DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, @@ -34,6 +34,15 @@ import { MAX_AUTO_MERGE_RETRIES, type BlockerFanoutEntry } from "../hooks/useBlo import { useRetryWarning } from "../context/RetryWarningContext"; import { useColumnLabel } from "../i18n/labels"; +/** Per-branch progress snapshot (U13). Surfaced as an optional additive field + * on the task payload for the parallel-window badge (U9). */ +interface BranchProgressEntry { + branchId: string; + nodeId: string; + status: string; +} +type TaskWithBranchProgress = Task & { branchProgress?: BranchProgressEntry[] }; + // ── Mission title caching ─────────────────────────────────────────────────── const missionTitleCache = new Map(); @@ -135,7 +144,9 @@ function isAgentCreatedTask(task: Task): boolean { // ── Constants ─────────────────────────────────────────────────────────────── -const EDITABLE_COLUMNS: Set = new Set(["triage", "todo"]); +// Issue 1403: widened to ColumnId so `.has(task.column)` accepts custom column ids +// (which are not members and correctly resolve to false). +const EDITABLE_COLUMNS: Set = new Set(["triage", "todo"]); const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]); const ACTIVE_MERGE_STATUSES = new Set(["merging", "merging-pr", "merging-fix"]); @@ -149,7 +160,7 @@ const COLUMN_PROGRESS_COLOR_MAP: Record = { archived: "var(--text-muted)", }; -const TIME_INDICATOR_COLUMNS = new Set([ +const TIME_INDICATOR_COLUMNS = new Set([ "in-progress", "in-review", "done", @@ -484,6 +495,8 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo previousTask.title === nextTask.title && previousTask.description === nextTask.description && previousTask.column === nextTask.column && + ((previousTask as TaskWithBranchProgress).branchProgress?.length ?? 0) === + ((nextTask as TaskWithBranchProgress).branchProgress?.length ?? 0) && previousTask.columnMovedAt === nextTask.columnMovedAt && previousTask.timedExecutionMs === nextTask.timedExecutionMs && previousTask.updatedAt === nextTask.updatedAt && @@ -1746,6 +1759,24 @@ function TaskCardComponent({ {t("tasks.stuck", "Stuck")} )} + {/* U13/U9: per-branch progress badges while the card is in a parallel + window. Reads an optional additive `branchProgress` field on the task + payload (server-persisted by U13); absent → nothing renders. */} + {Array.isArray((task as TaskWithBranchProgress).branchProgress) && + (task as TaskWithBranchProgress).branchProgress!.length > 0 && ( + + {t("tasks.branchProgress", "{{done}}/{{total}} branches", { + done: (task as TaskWithBranchProgress).branchProgress!.filter( + (b) => b.status === "completed", + ).length, + total: (task as TaskWithBranchProgress).branchProgress!.length, + })} + + )} {showStalledReview && stalledReview && ( )[task.column] ?? "var(--accent)", }} /> diff --git a/packages/dashboard/app/components/TaskChangesTab.tsx b/packages/dashboard/app/components/TaskChangesTab.tsx index 784b2bb281..ec117c7cfb 100644 --- a/packages/dashboard/app/components/TaskChangesTab.tsx +++ b/packages/dashboard/app/components/TaskChangesTab.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { FileCode, ChevronDown, ChevronRight, ChevronLeft, AlertCircle, GitCommit, WrapText, Maximize2 } from "lucide-react"; -import type { MergeDetails, Column } from "@fusion/core"; +import type { MergeDetails, ColumnId } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { fetchTaskDiff, @@ -16,7 +16,7 @@ interface TaskChangesTabProps { taskId: string; worktree?: string; projectId?: string; - column?: Column; + column?: ColumnId; mergeDetails?: MergeDetails; /** * Files modified by the task during execution, captured from the worktree. diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index f92b153f67..c6549955b9 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -9,12 +9,13 @@ import { useColumnLabel } from "../i18n/labels"; import ReactMarkdown from "react-markdown"; import type { Components } from "react-markdown"; import remarkGfm from "remark-gfm"; -import type { Task, TaskDetail, TaskAttachment, Column, MergeResult, Settings, GlobalSettings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult, GithubIssueAction } from "@fusion/core"; +import type { Task, TaskDetail, TaskAttachment, Column, ColumnId, MergeResult, Settings, GlobalSettings, AgentLogEntry, Agent, TaskPriority, TaskSourceIssue, WorkflowStepResult, GithubIssueAction } from "@fusion/core"; import { DEFAULT_TASK_PRIORITY, REPO_OVERRIDE_RE, TASK_PRIORITIES, VALID_TRANSITIONS, + isColumn, getErrorMessage, resolveTaskExecutionModel, resolveTaskPlanningModel, @@ -456,8 +457,10 @@ function getProvenanceLabel(task: Task | TaskDetail, options: ProvenanceLabelOpt const DESCRIPTION_TRUNCATE_LENGTH = 200; -const EDITABLE_COLUMNS: Set = new Set(["triage", "todo"]); -const GITHUB_TRACKING_EDITABLE_COLUMNS: Set = new Set(["triage", "todo", "in-progress", "in-review"]); +// #1403: widened to ColumnId so `.has(task.column)` accepts custom column ids +// (non-members correctly resolve to false → not editable). +const EDITABLE_COLUMNS: Set = new Set(["triage", "todo"]); +const GITHUB_TRACKING_EDITABLE_COLUMNS: Set = new Set(["triage", "todo", "in-progress", "in-review"]); export function TaskDetailContent({ task, @@ -1971,6 +1974,18 @@ export function TaskDetailContent({ } }, [task.id, projectId, workflowEnabledSteps, onTaskUpdated, addToast]); + // U5 (R20): a workflow switch re-homed the card to a new column. Refetch the + // task and push it up so the board reflects the move before the SSE catch-up. + const handleWorkflowReconciled = useCallback(async () => { + try { + const detail = await fetchTaskDetail(task.id, projectId); + setFullDetail(detail); + onTaskUpdated?.(detail); + } catch { + // Best-effort refresh; the SSE stream will catch the board up regardless. + } + }, [task.id, projectId, onTaskUpdated]); + const loadAgents = useCallback(async () => { setAgentsLoading(true); try { @@ -2217,7 +2232,9 @@ export function TaskDetailContent({ return providers; }, [workingTask.modelProvider, workingTask.validatorModelProvider, workingTask.planningModelProvider]); - const transitions = VALID_TRANSITIONS[task.column] || []; + // #1403: legacy transitions only exist for legacy columns; a custom column id + // has no VALID_TRANSITIONS row, so the move menu shows no legacy targets. + const transitions: Column[] = isColumn(task.column) ? [...VALID_TRANSITIONS[task.column]] : []; const inReviewMoveTransitions: Column[] = ["todo", "in-progress"]; const moveTransitions = task.column === "in-review" ? inReviewMoveTransitions : transitions; const primaryMoveTransition = moveTransitions[0]; @@ -2761,6 +2778,7 @@ export function TaskDetailContent({ && task.status !== "awaiting-cli-approval" } onWorkflowStepsChange={handleWorkflowStepsChange} + onWorkflowReconciled={handleWorkflowReconciled} taskStatus={task.status} taskPausedReason={task.pausedReason} /> diff --git a/packages/dashboard/app/components/WorkflowColumnPanel.tsx b/packages/dashboard/app/components/WorkflowColumnPanel.tsx new file mode 100644 index 0000000000..10c3b3ca1a --- /dev/null +++ b/packages/dashboard/app/components/WorkflowColumnPanel.tsx @@ -0,0 +1,213 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Plus, Trash2, ChevronUp, ChevronDown, AlertTriangle } from "lucide-react"; +import type { WorkflowIrColumn, TraitViolation } from "@fusion/core"; +import { fetchTraits, type TraitCatalogEntry } from "../api"; +import { getErrorMessage } from "@fusion/core"; +import type { ToastType } from "../hooks/useToast"; + +interface WorkflowColumnPanelProps { + columns: WorkflowIrColumn[]; + onChange: (next: WorkflowIrColumn[]) => void; + /** Column-level composition violations (from validateColumnTraits) to surface + * on the offending column band. Keyed by column id; workflow-wide violations + * (columnId === null) are shown at the panel head. */ + violations: TraitViolation[]; + readOnly: boolean; + projectId?: string; + addToast: (message: string, type?: ToastType) => void; +} + +let columnSeq = 0; +function newColumnId(): string { + columnSeq += 1; + return `col-${Date.now().toString(36)}-${columnSeq}`; +} + +export function WorkflowColumnPanel({ + columns, + onChange, + violations, + readOnly, + projectId, + addToast, +}: WorkflowColumnPanelProps) { + const { t } = useTranslation("app"); + const [catalog, setCatalog] = useState([]); + + useEffect(() => { + let cancelled = false; + fetchTraits(projectId) + .then((catalog) => { + if (!cancelled) setCatalog(catalog); + }) + .catch((err) => { + if (!cancelled) addToast(getErrorMessage(err) || t("workflowColumns.traitsLoadFailed", "Failed to load traits"), "error"); + }); + return () => { + cancelled = true; + }; + }, [projectId, addToast, t]); + + const workflowWide = violations.filter((v) => v.columnId === null); + const violationsFor = useCallback( + (columnId: string) => violations.filter((v) => v.columnId === columnId), + [violations], + ); + + const addColumn = useCallback(() => { + const id = newColumnId(); + onChange([...columns, { id, name: t("workflowColumns.newColumnName", "New column"), traits: [] }]); + }, [columns, onChange, t]); + + const renameColumn = useCallback( + (id: string, name: string) => { + onChange(columns.map((c) => (c.id === id ? { ...c, name } : c))); + }, + [columns, onChange], + ); + + const removeColumn = useCallback( + (id: string) => { + onChange(columns.filter((c) => c.id !== id)); + }, + [columns, onChange], + ); + + const moveColumn = useCallback( + (index: number, dir: -1 | 1) => { + const target = index + dir; + if (target < 0 || target >= columns.length) return; + const next = [...columns]; + [next[index], next[target]] = [next[target], next[index]]; + onChange(next); + }, + [columns, onChange], + ); + + const toggleTrait = useCallback( + (columnId: string, traitId: string) => { + onChange( + columns.map((c) => { + if (c.id !== columnId) return c; + const has = c.traits.some((tr) => tr.trait === traitId); + return { + ...c, + traits: has + ? c.traits.filter((tr) => tr.trait !== traitId) + : [...c.traits, { trait: traitId }], + }; + }), + ); + }, + [columns, onChange], + ); + + return ( + + ); +} diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index fbd3c130b1..b8f2fcdee1 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -333,3 +333,130 @@ transform: rotate(360deg); } } + +/* ── U10: swimlane bands, column panel, error badges, read-only banner ── */ + +.wf-column-band { + border: 1px dashed var(--border); + background: var(--bg-secondary); + border-radius: var(--radius-md); + pointer-events: none; +} + +.wf-node--error { + border-color: var(--ws-error); +} + +.wf-node-error-badge { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + font-size: 0.65rem; + padding: 1px var(--space-xs); + border-radius: var(--radius-sm); + background: var(--ws-error); + color: var(--bg); +} + +.wf-editor-readonly-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + padding: var(--space-sm) var(--space-md); + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border); +} + +.wf-editor-duplicate-primary { + font-weight: 600; +} + +.wf-editor-banner--warn { + background: var(--ws-warning); + color: var(--bg); +} + +.wf-column-panel { + display: flex; + flex-direction: column; + gap: var(--space-sm); + width: 280px; + min-width: 260px; + padding: var(--space-md); + border-left: 1px solid var(--border); + overflow-y: auto; +} + +.wf-column-panel-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.wf-column-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.wf-column-item { + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--space-sm); + display: flex; + flex-direction: column; + gap: var(--space-xs); +} + +.wf-column-item--error { + border-color: var(--ws-error); +} + +.wf-column-item-head { + display: flex; + align-items: center; + gap: var(--space-xs); +} + +.wf-column-name { + flex: 1; + min-width: 0; +} + +.wf-column-item-actions { + display: flex; + gap: 2px; +} + +.wf-column-violation { + display: flex; + align-items: center; + gap: var(--space-xs); + font-size: 0.7rem; + color: var(--ws-error); + margin: 0; +} + +.wf-column-trait-options { + display: flex; + flex-wrap: wrap; + gap: var(--space-xs); +} + +.wf-column-trait { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: 0.7rem; + color: var(--text-muted); +} + +.wf-column-traits-label { + font-size: 0.65rem; + text-transform: uppercase; + color: var(--text-tertiary); +} diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index c1f3512fb8..38b08b9f4a 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -14,8 +14,9 @@ import { type Node as FlowNode, type Edge as FlowEdge, } from "@xyflow/react"; -import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle } from "lucide-react"; -import type { WorkflowDefinition } from "@fusion/core"; +import { useTranslation } from "react-i18next"; +import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge } from "lucide-react"; +import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { fetchWorkflows, @@ -34,7 +35,20 @@ import type { ToastType } from "../hooks/useToast"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import { useModalResizePersist } from "../hooks/useModalResizePersist"; import { workflowNodeTypes, type WorkflowFlowNodeData, type WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes"; -import { irToFlow, flowToIr, emptyWorkflowIr, emptyWorkflowLayout } from "./workflow-flow-mapping"; +import { + irToFlow, + flowToIr, + emptyWorkflowIr, + emptyWorkflowLayout, + columnsOf, + columnsToBandNodes, + strictColumnForY, + validateColumnsClient, + unplacedNodeIds, + isColumnBandNode, +} from "./workflow-flow-mapping"; +import { fetchTraits, type TraitCatalogEntry } from "../api"; +import { WorkflowColumnPanel } from "./WorkflowColumnPanel"; import { CustomModelDropdown } from "./CustomModelDropdown"; type ExecutorKind = "model" | "agent" | "skill" | "cli"; @@ -76,6 +90,9 @@ const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof { kind: "script", label: "Script", icon: Terminal }, { kind: "gate", label: "Gate", icon: Shield }, { kind: "merge", label: "Merge boundary", icon: GitMerge }, + { kind: "hold", label: "Hold", icon: PauseCircle, presetConfig: { release: "manual" } }, + { kind: "split", label: "Split", icon: Split }, + { kind: "join", label: "Join", icon: Merge, presetConfig: { mode: "all", onBranchFailure: "collect" } }, ]; function InnerEditor({ @@ -92,10 +109,39 @@ function InnerEditor({ const [nodes, setNodes, onNodesChange] = useNodesState>([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [selectedNodeId, setSelectedNodeId] = useState(null); + const { t } = useTranslation("app"); + // v2 columns the editor is authoring for the active workflow. + const [columns, setColumns] = useState([]); + const [traitCatalog, setTraitCatalog] = useState([]); const activeWorkflow = useMemo(() => workflows.find((w) => w.id === activeId), [workflows, activeId]); const isBuiltin = !!activeWorkflow && isBuiltinWorkflowId(activeWorkflow.id); + // Trait catalog (for client-side composition validation; the panel fetches its + // own copy for the picker, but the editor needs the flags to validate). + useEffect(() => { + let cancelled = false; + fetchTraits(projectId) + .then((catalog) => { + if (!cancelled) setTraitCatalog(catalog); + }) + .catch(() => { + // Non-fatal: validation degrades to server-side parse on save. + }); + return () => { + cancelled = true; + }; + }, [projectId]); + + // Composition violations (client mirror of validateColumnTraits). + const columnViolations: TraitViolation[] = useMemo( + () => (columns.length ? validateColumnsClient(columns, traitCatalog) : []), + [columns, traitCatalog], + ); + // Step nodes not placed in any column (v2 only). + const unplaced = useMemo(() => unplacedNodeIds(nodes, columns), [nodes, columns]); + const blockingViolationCount = columnViolations.filter((v) => v.severity === "error").length; + const loadWorkflows = useCallback(async () => { setLoading(true); try { @@ -118,15 +164,30 @@ function InnerEditor({ if (!activeWorkflow) { setNodes([]); setEdges([]); + setColumns([]); return; } const flow = irToFlow(activeWorkflow); setNodes(flow.nodes); setEdges(flow.edges); + setColumns(columnsOf(activeWorkflow)); setSelectedNodeId(null); setValidationError(null); }, [activeWorkflow, setNodes, setEdges]); + // Server-reported node error (e.g. seam-in-branch) attributed to a node id. + const [serverNodeError, setServerNodeError] = useState<{ nodeId: string; message: string } | null>(null); + + // Keep the swimlane band group nodes in sync with the authored columns + // (add/rename/reorder via the column panel). Step nodes are preserved; only + // the band nodes are replaced. + useEffect(() => { + setNodes((ns) => { + const stepNodes = ns.filter((n) => !isColumnBandNode(n.id) && n.type !== "group"); + return [...columnsToBandNodes(columns), ...stepNodes]; + }); + }, [columns, setNodes]); + const onConnect = useCallback( (connection: Connection) => { setEdges((eds) => @@ -136,6 +197,22 @@ function InnerEditor({ [setEdges], ); + // Dragging a step node into a column band sets node.column (position-based + // hit testing against the ordered bands — see workflow-flow-mapping). + const onNodeDragStop = useCallback( + (_evt: unknown, node: FlowNode) => { + if (isColumnBandNode(node.id) || columns.length === 0) return; + // strictColumnForY (not the clamping columnForY): a node dragged above or + // below all bands keeps no column rather than snapping to the nearest one. + const column = strictColumnForY(node.position.y, columns); + if (!column) return; + setNodes((ns) => + ns.map((n) => (n.id === node.id ? { ...n, data: { ...n.data, column } } : n)), + ); + }, + [columns, setNodes], + ); + const addNode = useCallback( (kind: WorkflowEditorNodeKind, nodeLabel?: string, presetConfig?: Record) => { const id = newNodeId(); @@ -245,27 +322,75 @@ function InnerEditor({ const handleSave = useCallback(async () => { if (!activeWorkflow) return; if (isBuiltinWorkflowId(activeWorkflow.id)) return; // built-ins are read-only + + // Block save on client-detected violations before any round-trip: + // - unplaced step nodes (rendered as inline node badges + summary count); + // - trait composition errors (rendered on the offending column band). + if (unplaced.length > 0) { + const message = t( + "workflowColumns.unplacedCount", + "{{count}} nodes not placed in a column", + { count: unplaced.length }, + ); + setValidationError(message); + addToast(message, "error"); + return; + } + if (blockingViolationCount > 0) { + const message = t( + "workflowColumns.compositionBlocked", + "Resolve trait conflicts on highlighted columns before saving", + ); + setValidationError(message); + addToast(message, "error"); + return; + } + setSaving(true); setValidationError(null); + setServerNodeError(null); try { - const { ir, layout } = flowToIr(activeWorkflow.name, nodes, edges); + const { ir, layout } = flowToIr(activeWorkflow.name, nodes, edges, columns.length ? columns : undefined); const updated = await updateWorkflow(activeWorkflow.id, { ir, layout }, projectId); setWorkflows((ws) => ws.map((w) => (w.id === updated.id ? updated : w))); // Validate by compiling — surfaces non-linear graphs as a banner. try { await compileWorkflow(updated.id, projectId); - addToast("Workflow saved", "success"); + addToast(t("workflows.saved", "Workflow saved"), "success"); } catch (compileErr) { - setValidationError(getErrorMessage(compileErr) || "Workflow saved but cannot be compiled"); + setValidationError( + getErrorMessage(compileErr) || t("workflows.savedNotCompilable", "Workflow saved but cannot be compiled"), + ); } } catch (err) { - const message = getErrorMessage(err) || "Failed to save workflow"; + const message = getErrorMessage(err) || t("workflows.saveFailed", "Failed to save workflow"); + // parseWorkflowIr (server) names the offending node for structural errors + // like seam-in-branch ("seam 'merge' node 'n-…' is forbidden inside …"). + // Attribute it to that node so the shared error badge renders on it. + const nodeMatch = /node '([^']+)'/.exec(message); + if (nodeMatch && nodes.some((n) => n.id === nodeMatch[1])) { + setServerNodeError({ nodeId: nodeMatch[1], message }); + } setValidationError(message); addToast(message, "error"); } finally { setSaving(false); } - }, [activeWorkflow, nodes, edges, projectId, addToast]); + }, [activeWorkflow, nodes, edges, columns, unplaced, blockingViolationCount, projectId, addToast, t]); + + // Stamp the shared error-state badge onto offending nodes: unplaced step + // nodes and any node the server flagged (seam-in-branch). One component + // (WorkflowNodeErrorBadge) renders both, keyed off data.errorBadge. + const nodesForRender = useMemo(() => { + const unplacedSet = new Set(unplaced); + return nodes.map((n) => { + let errorBadge: string | undefined; + if (unplacedSet.has(n.id)) errorBadge = t("workflowColumns.nodeUnplaced", "Not placed in a column"); + if (serverNodeError?.nodeId === n.id) errorBadge = serverNodeError.message; + if (errorBadge === n.data.errorBadge) return n; + return { ...n, data: { ...n.data, errorBadge } }; + }); + }, [nodes, unplaced, serverNodeError, t]); const selectedNode = nodes.find((n) => n.id === selectedNodeId) ?? null; @@ -344,57 +469,64 @@ function InnerEditor({
{activeWorkflow ? ( <> -
-
- {PALETTE.map(({ kind, label, icon: Icon, presetConfig }) => ( - +
+ ) : ( +
+
+ {PALETTE.map(({ kind, label, icon: Icon, presetConfig }) => ( + + ))} +
+
+ - ))} + +
-
- {isBuiltin ? ( - <> - - Read-only built-in - - - - ) : ( - <> - - - - )} -
-
+ )} {validationError && (
{validationError}
)} + {unplaced.length > 0 && ( +
+ {t("workflowColumns.unplacedCount", "{{count}} nodes not placed in a column", { + count: unplaced.length, + })} +
+ )}
setSelectedNodeId(node.id)} onPaneClick={() => setSelectedNodeId(null)} fitView @@ -407,11 +539,22 @@ function InnerEditor({ ) : (
- Select or create a workflow to start editing. + {t("workflows.selectOrCreate", "Select or create a workflow to start editing.")}
)}
+ {activeWorkflow && ( + + )} + {selectedNode && selectedNode.data.kind !== "start" && selectedNode.data.kind !== "end" && ( )} diff --git a/packages/dashboard/app/components/WorkflowResultsTab.tsx b/packages/dashboard/app/components/WorkflowResultsTab.tsx index 73a2cf4168..6ebe34bff8 100644 --- a/packages/dashboard/app/components/WorkflowResultsTab.tsx +++ b/packages/dashboard/app/components/WorkflowResultsTab.tsx @@ -46,6 +46,10 @@ interface WorkflowResultsTabProps { onWorkflowStepsChange?: (steps: string[]) => void; taskStatus?: string; taskPausedReason?: string; + /** U5 (R20): called after a workflow switch re-homed the card to a new column + * (reconciliation present and not preserved) so the board can refresh before + * the SSE catch-up arrives. */ + onWorkflowReconciled?: () => void; } /** Extract the user-facing question from a workflow-input paused reason. @@ -227,6 +231,7 @@ export function WorkflowResultsTab({ onWorkflowStepsChange, taskStatus, taskPausedReason, + onWorkflowReconciled, }: WorkflowResultsTabProps) { const { t } = useTranslation("app"); const [expandedOutputs, setExpandedOutputs] = useState>({}); @@ -270,8 +275,13 @@ export function WorkflowResultsTab({ const res = await selectTaskWorkflow(taskId, workflowId, projectId); setSelectedWorkflowId(res.workflowId); onWorkflowStepsChange?.(res.enabledWorkflowSteps); + // U5 (R20): the switch re-homed the card to a new column — refresh the + // board now rather than waiting for the SSE catch-up. + if (res.reconciliation && !res.reconciliation.preserved) { + onWorkflowReconciled?.(); + } }, - [taskId, projectId, onWorkflowStepsChange], + [taskId, projectId, onWorkflowStepsChange, onWorkflowReconciled], ); // Check if any result has pending status diff --git a/packages/dashboard/app/components/WorkflowSelector.tsx b/packages/dashboard/app/components/WorkflowSelector.tsx index 76baf38b62..b893678fcb 100644 --- a/packages/dashboard/app/components/WorkflowSelector.tsx +++ b/packages/dashboard/app/components/WorkflowSelector.tsx @@ -1,10 +1,12 @@ import "./WorkflowSelector.css"; import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { Workflow as WorkflowIcon } from "lucide-react"; import type { WorkflowDefinition } from "@fusion/core"; import { getErrorMessage } from "@fusion/core"; import { fetchWorkflows, fetchProjectDefaultWorkflow, setProjectDefaultWorkflow } from "../api"; import type { ToastType } from "../hooks/useToast"; +import { useConfirm } from "../hooks/useConfirm"; interface WorkflowSelectorProps { /** Currently selected workflow id, or null for none. */ @@ -17,6 +19,13 @@ interface WorkflowSelectorProps { label?: string; /** Optional affordance to open the graph editor. */ onManage?: () => void; + /** + * U9: when the task whose workflow is being switched has an active session, + * switching aborts that session and re-homes the card into the new workflow's + * entry column. Pass `true` to require an abort-warning confirmation before + * applying (parallels Column.tsx's preserve-progress confirm). + */ + hasActiveSession?: boolean; } export function WorkflowSelector({ @@ -27,7 +36,10 @@ export function WorkflowSelector({ disabled, label = "Workflow", onManage, + hasActiveSession, }: WorkflowSelectorProps) { + const { t } = useTranslation("app"); + const { confirm } = useConfirm(); const [workflows, setWorkflows] = useState([]); const [loading, setLoading] = useState(false); const [applying, setApplying] = useState(false); @@ -55,6 +67,19 @@ export function WorkflowSelector({ const handleChange = useCallback( async (next: string) => { const workflowId = next === "" ? null : next; + if (hasActiveSession) { + const confirmed = await confirm({ + title: t("workflowSelector.switchActiveTitle", "Switch workflow?"), + message: t( + "workflowSelector.switchActiveMessage", + "This task has an active session. Switching workflows aborts it and re-homes the card into the new workflow's entry column. Continue?", + ), + confirmLabel: t("workflowSelector.switchConfirm", "Switch and abort"), + cancelLabel: t("workflowSelector.switchCancel", "Cancel"), + danger: true, + }); + if (!confirmed) return; + } setApplying(true); try { await onChange(workflowId); @@ -64,7 +89,7 @@ export function WorkflowSelector({ setApplying(false); } }, - [onChange, addToast], + [onChange, addToast, hasActiveSession, confirm, t], ); return ( diff --git a/packages/dashboard/app/components/__tests__/Board.canDropTask.test.tsx b/packages/dashboard/app/components/__tests__/Board.canDropTask.test.tsx new file mode 100644 index 0000000000..4a106b8ebf --- /dev/null +++ b/packages/dashboard/app/components/__tests__/Board.canDropTask.test.tsx @@ -0,0 +1,171 @@ +// FN-1416: Board-level coverage of the canDropTask drag pre-check (R17). +// +// canDropTask is an internal Board closure passed down to . Board.tsx is +// being edited by another agent, so rather than touch it (or its existing +// test), this file mocks to CAPTURE the real canDropTask closure Board +// constructs, then drives the three rejection branches plus the allowed case: +// - cross-workflow drag → "board.rejection.workflowMismatch" +// - unknown target column in the lane → "board.rejection.unknownColumn" +// - full wip column (>= maxConcurrent) → "board.rejection.capacityExhausted" +// - valid same-lane, under-capacity drop → null (allowed) +// +// This exercises the production closure (not a copy), so a regression in any +// branch fails here. + +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, act } from "@testing-library/react"; +import type { Task } from "@fusion/core"; +import { Board } from "../Board"; + +vi.mock("../../hooks/useBatchBadgeFetch", () => ({ + useBatchBadgeFetch: vi.fn(() => ({ + fetchBatch: vi.fn(), + isLoading: false, + lastFetchTime: null, + getBatchData: vi.fn(), + })), +})); + +const fetchBoardWorkflowsMock = vi.fn(); +vi.mock("../../api", () => ({ + fetchWorkflowSteps: vi.fn().mockResolvedValue([]), + fetchBoardWorkflows: (...args: unknown[]) => fetchBoardWorkflowsMock(...args), + promoteTask: vi.fn().mockResolvedValue({}), +})); + +vi.mock("../../sse-bus", () => ({ + subscribeSse: vi.fn(() => () => {}), +})); + +// Don't pull in the full Column tree from the mocked Lane. +vi.mock("../Column", () => ({ Column: () =>
})); + +// Capture the canDropTask closure Board passes to each Lane. +type CanDrop = (taskId: string, targetColumnId: string, workflowId: string) => string | null; +let capturedCanDropTask: CanDrop | null = null; +vi.mock("../Lane", () => ({ + Lane: (props: { canDropTask: CanDrop }) => { + capturedCanDropTask = props.canDropTask; + return
; + }, +})); + +const DEFAULT_LANE = "builtin:coding"; +const CUSTOM_LANE = "WF-001"; + +// builtin:coding columns (in-progress counts toward wip; todo does not). +const defaultColumns = [ + { id: "triage", name: "Triage", flags: {} }, + { id: "todo", name: "Todo", flags: {} }, + { id: "in-progress", name: "In Progress", flags: { countsTowardWip: true } }, + { id: "in-review", name: "In Review", flags: {} }, + { id: "done", name: "Done", flags: { complete: true } }, +]; +const customColumns = [ + { id: "c-intake", name: "Intake", flags: { intake: true } }, + { id: "c-run", name: "Run", flags: { countsTowardWip: true } }, + { id: "c-done", name: "Done", flags: { complete: true } }, +]; + +function makeTask(id: string, column: string): Task { + const now = new Date().toISOString(); + return { + id, + description: id, + column, + dependencies: [], + createdAt: now, + updatedAt: now, + size: "M", + subtasks: [], + log: [], + tags: [], + blockedBy: [], + source: { sourceType: "api" }, + } as unknown as Task; +} + +function boardProps(overrides: Record = {}) { + return { + tasks: [] as Task[], + maxConcurrent: 2, + onMoveTask: () => Promise.resolve({} as never), + onOpenDetail: () => {}, + addToast: () => {}, + onQuickCreate: () => Promise.resolve({} as never), + onNewTask: () => {}, + autoMerge: true, + onToggleAutoMerge: () => {}, + globalPaused: false, + ...overrides, + }; +} + +/** Render Board flag-ON with the given tasks and wait for canDropTask capture. */ +async function renderAndCapture(tasks: Task[], taskWorkflowIds: Record) { + fetchBoardWorkflowsMock.mockResolvedValue({ + flagEnabled: true, + defaultWorkflowId: DEFAULT_LANE, + workflows: [ + { id: DEFAULT_LANE, name: "Coding", columns: defaultColumns }, + { id: CUSTOM_LANE, name: "Custom", columns: customColumns }, + ], + taskWorkflowIds, + }); + await act(async () => { + const props = boardProps({ tasks }) as unknown as React.ComponentProps; + render(); + await Promise.resolve(); + }); + expect(capturedCanDropTask).toBeTypeOf("function"); + return capturedCanDropTask!; +} + +describe("Board canDropTask pre-check (FN-1416)", () => { + beforeEach(() => { + capturedCanDropTask = null; + fetchBoardWorkflowsMock.mockReset(); + try { window.localStorage.clear(); } catch { /* jsdom */ } + }); + + it("cross-workflow drag → workflowMismatch", async () => { + // FN-1 lives in the default lane; dragging it into the custom lane crosses + // workflows (R17 never switches a card's workflow via drag). + const tasks = [makeTask("FN-1", "todo")]; + const canDrop = await renderAndCapture(tasks, { "FN-1": DEFAULT_LANE }); + expect(canDrop("FN-1", "c-run", CUSTOM_LANE)).toBe("board.rejection.workflowMismatch"); + }); + + it("unknown target column in the lane → unknownColumn", async () => { + const tasks = [makeTask("FN-1", "todo")]; + const canDrop = await renderAndCapture(tasks, { "FN-1": DEFAULT_LANE }); + expect(canDrop("FN-1", "does-not-exist", DEFAULT_LANE)).toBe("board.rejection.unknownColumn"); + }); + + it("full wip column (occupants >= maxConcurrent) → capacityExhausted", async () => { + // maxConcurrent: 2; two cards already occupy in-progress in the default lane. + // Dragging a third (from todo) into in-progress must reject on capacity. + const tasks = [ + makeTask("FN-1", "todo"), + makeTask("FN-2", "in-progress"), + makeTask("FN-3", "in-progress"), + ]; + const canDrop = await renderAndCapture(tasks, { + "FN-1": DEFAULT_LANE, + "FN-2": DEFAULT_LANE, + "FN-3": DEFAULT_LANE, + }); + expect(canDrop("FN-1", "in-progress", DEFAULT_LANE)).toBe("board.rejection.capacityExhausted"); + }); + + it("valid same-lane drop under capacity → allowed (null)", async () => { + // One free in-progress slot (maxConcurrent 2, one occupant); moving FN-1 from + // todo into in-progress in its own lane is permitted. + const tasks = [makeTask("FN-1", "todo"), makeTask("FN-2", "in-progress")]; + const canDrop = await renderAndCapture(tasks, { "FN-1": DEFAULT_LANE, "FN-2": DEFAULT_LANE }); + expect(canDrop("FN-1", "in-progress", DEFAULT_LANE)).toBeNull(); + // Dropping into a non-wip column (todo → in-review) is also allowed. + expect(canDrop("FN-1", "in-review", DEFAULT_LANE)).toBeNull(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/Board.test.tsx b/packages/dashboard/app/components/__tests__/Board.test.tsx index 54342a053c..46437182f3 100644 --- a/packages/dashboard/app/components/__tests__/Board.test.tsx +++ b/packages/dashboard/app/components/__tests__/Board.test.tsx @@ -1,6 +1,6 @@ import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { Board } from "../Board"; import { COLUMNS } from "@fusion/core"; @@ -17,10 +17,35 @@ vi.mock("../../hooks/useBatchBadgeFetch", () => ({ })), })); +const fetchBoardWorkflowsMock = vi.fn().mockResolvedValue({ + flagEnabled: false, + defaultWorkflowId: "builtin:coding", + workflows: [], + taskWorkflowIds: {}, +}); +const promoteTaskMock = vi.fn().mockResolvedValue({}); + vi.mock("../../api", () => ({ fetchWorkflowSteps: vi.fn().mockResolvedValue([ { id: "WS-003", name: "Accessibility Audit", enabled: true }, ]), + fetchBoardWorkflows: (...args: unknown[]) => fetchBoardWorkflowsMock(...args), + promoteTask: (...args: unknown[]) => promoteTaskMock(...args), +})); + +// Capture SSE event handlers registered via subscribeSse so tests can simulate +// server-pushed `workflow:*` events without a real EventSource. +const sseHandlers: Record void> = {}; +const subscribeSseMock = vi.fn( + (_url: string, opts: { events?: Record void> }) => { + for (const [name, handler] of Object.entries(opts.events ?? {})) { + sseHandlers[name] = handler; + } + return () => {}; + }, +); +vi.mock("../../sse-bus", () => ({ + subscribeSse: (...args: unknown[]) => (subscribeSseMock as (...a: unknown[]) => () => void)(...args), })); const columnRenderCounts: Record = {}; @@ -37,11 +62,53 @@ vi.mock("../Column", () => ({ }), })); +// Mock Lane so the multi-lane Board tests assert grouping/ordering without +// pulling in the full Column tree. +vi.mock("../Lane", () => ({ + Lane: ({ workflow, tasks, collapsed }: { workflow: { id: string; name: string }; tasks: Task[]; collapsed: boolean }) => ( +
t.id))} + /> + ), +})); + +const DEFAULT_WORKFLOW = { + id: "builtin:coding", + name: "Coding (built-in)", + columns: [ + { id: "triage", name: "Triage", flags: { intake: true } }, + { id: "todo", name: "Todo", flags: { hold: true } }, + { id: "in-progress", name: "In progress", flags: { countsTowardWip: true } }, + { id: "in-review", name: "In review", flags: { mergeBlocker: true } }, + { id: "done", name: "Done", flags: { complete: true } }, + { id: "archived", name: "Archived", flags: { archived: true } }, + ], +}; + const noop = () => {}; const noopAsync = () => Promise.resolve({} as any); beforeEach(() => { fetchBatchMock.mockReset(); + promoteTaskMock.mockClear(); + subscribeSseMock.mockClear(); + for (const key of Object.keys(sseHandlers)) delete sseHandlers[key]; + fetchBoardWorkflowsMock.mockReset(); + fetchBoardWorkflowsMock.mockResolvedValue({ + flagEnabled: false, + defaultWorkflowId: "builtin:coding", + workflows: [], + taskWorkflowIds: {}, + }); + try { + window.localStorage.clear(); + } catch { + /* jsdom localStorage */ + } for (const key of Object.keys(columnRenderCounts)) { delete columnRenderCounts[key]; } @@ -825,4 +892,137 @@ describe("Board", () => { } }); }); + + describe("multi-lane board (U9, flag ON)", () => { + const mkTask = (overrides: Partial & { id: string }): Task => ({ + title: overrides.id, + description: "d", + column: "todo", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + ...overrides, + }); + + const CUSTOM_WORKFLOW = { + id: "wf-custom", + name: "Custom Flow", + columns: [ + { id: "intake", name: "Intake", flags: { intake: true } }, + { id: "done", name: "Done", flags: { complete: true } }, + ], + }; + + function enableFlag(taskWorkflowIds: Record, workflows = [DEFAULT_WORKFLOW]) { + fetchBoardWorkflowsMock.mockResolvedValue({ + flagEnabled: true, + defaultWorkflowId: "builtin:coding", + workflows, + taskWorkflowIds, + }); + } + + it("flag OFF renders the legacy single-lane board byte-identically", async () => { + // Default mock: flagEnabled false. + renderBoard({ tasks: [mkTask({ id: "FN-1" })] }); + // Let the board-workflows fetch resolve (flagEnabled:false) so the async + // state settle is wrapped and the legacy board stays the rendered output. + await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalled()); + const board = screen.getByRole("main"); + expect(board.className).toBe("board"); + // All 6 legacy columns present; no lanes. + for (const col of COLUMNS) { + expect(screen.getByTestId(`column-${col}`)).toBeDefined(); + } + expect(screen.queryByTestId(/^lane-/)).toBeNull(); + }); + + it("tasks with no selection render in the default lane (R16)", async () => { + enableFlag({ "FN-1": "builtin:coding", "FN-2": "builtin:coding" }); + renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "in-progress" })] }); + await waitFor(() => expect(screen.getByTestId("lane-builtin:coding")).toBeDefined()); + const lane = screen.getByTestId("lane-builtin:coding"); + expect(JSON.parse(lane.getAttribute("data-lane-task-ids") || "[]").sort()).toEqual(["FN-1", "FN-2"]); + expect(lane.getAttribute("data-lane-count")).toBe("2"); + }); + + it("each card appears in exactly one lane over a mixed fixture", async () => { + enableFlag( + { "FN-1": "builtin:coding", "FN-2": "wf-custom", "FN-3": "wf-custom" }, + [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW], + ); + renderBoard({ + tasks: [ + mkTask({ id: "FN-1" }), + mkTask({ id: "FN-2", column: "intake" }), + mkTask({ id: "FN-3", column: "intake" }), + ], + }); + await waitFor(() => expect(screen.getByTestId("lane-wf-custom")).toBeDefined()); + const defaultLaneIds = JSON.parse(screen.getByTestId("lane-builtin:coding").getAttribute("data-lane-task-ids") || "[]"); + const customLaneIds = JSON.parse(screen.getByTestId("lane-wf-custom").getAttribute("data-lane-task-ids") || "[]"); + const all = [...defaultLaneIds, ...customLaneIds].sort(); + expect(all).toEqual(["FN-1", "FN-2", "FN-3"]); + // No id appears twice. + expect(new Set(all).size).toBe(all.length); + }); + + it("hides zero-card lanes and puts the default lane first", async () => { + enableFlag( + { "FN-2": "wf-custom" }, + [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW], + ); + // Only the custom workflow has a card; the default lane has none → hidden. + renderBoard({ tasks: [mkTask({ id: "FN-2", column: "intake" })] }); + await waitFor(() => expect(screen.getByTestId("lane-wf-custom")).toBeDefined()); + expect(screen.queryByTestId("lane-builtin:coding")).toBeNull(); + }); + + it("orders the default lane first when both have cards", async () => { + enableFlag( + { "FN-1": "builtin:coding", "FN-2": "wf-custom" }, + [CUSTOM_WORKFLOW, DEFAULT_WORKFLOW], + ); + renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "intake" })] }); + await waitFor(() => expect(screen.getByTestId("lane-builtin:coding")).toBeDefined()); + const lanes = screen.getAllByTestId(/^lane-/); + expect(lanes[0].getAttribute("data-testid")).toBe("lane-builtin:coding"); + }); + + it("excludes archived cards from lanes", async () => { + enableFlag({ "FN-1": "builtin:coding", "FN-9": "builtin:coding" }); + renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-9", column: "archived" })] }); + await waitFor(() => expect(screen.getByTestId("lane-builtin:coding")).toBeDefined()); + const ids = JSON.parse(screen.getByTestId("lane-builtin:coding").getAttribute("data-lane-task-ids") || "[]"); + expect(ids).toEqual(["FN-1"]); + }); + + it("persists lane collapse state to localStorage", async () => { + window.localStorage.setItem("kb-dashboard-lane-collapsed", JSON.stringify(["builtin:coding"])); + enableFlag({ "FN-1": "builtin:coding" }); + renderBoard({ tasks: [mkTask({ id: "FN-1" })] }); + await waitFor(() => expect(screen.getByTestId("lane-builtin:coding")).toBeDefined()); + expect(screen.getByTestId("lane-builtin:coding").getAttribute("data-lane-collapsed")).toBe("true"); + }); + }); + + describe("workflow:updated SSE invalidation (#1406)", () => { + it("re-fetches board-workflows when a workflow:updated SSE event arrives", async () => { + renderBoard({ projectId: "proj-1" }); + // Initial mount fetch. + await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(1)); + // Board subscribed for workflow lifecycle events. + expect(subscribeSseMock).toHaveBeenCalled(); + expect(typeof sseHandlers["workflow:updated"]).toBe("function"); + + // Simulate a server-pushed workflow:updated event → invalidate + re-fetch. + await act(async () => { + sseHandlers["workflow:updated"]?.(); + }); + await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalledTimes(2)); + }); + }); }); diff --git a/packages/dashboard/app/components/__tests__/Column.test.tsx b/packages/dashboard/app/components/__tests__/Column.test.tsx index 29773f28ac..a1d951adaf 100644 --- a/packages/dashboard/app/components/__tests__/Column.test.tsx +++ b/packages/dashboard/app/components/__tests__/Column.test.tsx @@ -122,6 +122,116 @@ describe("Column count-flash", () => { }); }); +describe("Column workflow mode (U9)", () => { + it("uses the workflow column display name instead of the legacy label", () => { + render( + , + ); + expect(screen.getByRole("heading", { level: 2 }).textContent).toBe("Planning Hold"); + }); + + it("re-keys bulk actions to trait flags (a wip column gets the processing menu)", () => { + render( + , + ); + // The processing-column actions button (column-menu) is present. + expect(document.querySelector(".column-menu")).not.toBeNull(); + }); + + it("surfaces a translated rejection messageKey on a failed drop (snap-back)", async () => { + const addToast = vi.fn(); + const onMoveTask = vi.fn().mockRejectedValue({ + details: { code: "merge-blocked", messageKey: "board.rejection.mergeBlocked", retryable: false }, + }); + render( + , + ); + const columnEl = document.querySelector('[data-column="done"]') as HTMLElement; + fireEvent.drop(columnEl, { dataTransfer: { getData: () => "FN-99" } }); + await waitFor(() => expect(addToast).toHaveBeenCalled()); + // The toast surfaces the translated merge-blocked copy (not the raw key). + expect(addToast.mock.calls[0][0]).toContain("merge step"); + expect(addToast.mock.calls[0][1]).toBe("error"); + }); + + it("renders a Promote affordance on hold-column cards", () => { + render( + , + ); + expect(screen.getByTestId("promote-FN-7")).toBeDefined(); + }); + + it("#1410: clears the inline capacity banner when the task list changes via SSE", async () => { + const onPromote = vi.fn().mockRejectedValue({ + details: { code: "capacity-exhausted", retryable: true }, + }); + const holdTask = { ...makeTask("FN-7"), column: "hold-col" as ColumnType }; + const { rerender } = render( + , + ); + + // Trigger a capacity-exhausted promote → inline banner appears. + fireEvent.click(screen.getByTestId("promote-FN-7")); + await waitFor(() => expect(screen.getByTestId("column-inline-feedback")).toBeDefined()); + expect(screen.getByTestId("column-inline-feedback").textContent).toContain("capacity"); + + // An SSE-driven task list change (occupant moved out) re-renders the column + // with a different roster → the stale banner is cleared. + rerender( + , + ); + await waitFor(() => expect(screen.queryByTestId("column-inline-feedback")).toBeNull()); + }); +}); + describe("Column memoization", () => { it("does not re-render task cards when rerendered with the same task references", () => { const tasks = [makeTask("FN-001")]; diff --git a/packages/dashboard/app/components/__tests__/Lane.test.tsx b/packages/dashboard/app/components/__tests__/Lane.test.tsx new file mode 100644 index 0000000000..48973ed607 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/Lane.test.tsx @@ -0,0 +1,154 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { Lane } from "../Lane"; +import type { Task } from "@fusion/core"; +import type { BoardWorkflowDefinition } from "../../api"; + +// Keep the test focused on Lane + Column (real) — mock the leaf TaskCard and +// the confirm hook, matching the Column test harness. +vi.mock("../TaskCard", () => ({ + TaskCard: ({ task }: { task: Task }) =>
, +})); +vi.mock("../WorktreeGroup", () => ({ WorktreeGroup: () =>
})); +vi.mock("../QuickEntryBox", () => ({ QuickEntryBox: () =>
})); +vi.mock("../PluginSlot", () => ({ PluginSlot: () => null })); +vi.mock("lucide-react", () => ({ + Link: () => null, + Clock: () => null, + ChevronDown: () => null, + ChevronUp: () => null, + ChevronRight: () => null, + Archive: () => null, + MoreVertical: () => null, + AlertTriangle: () => null, +})); +const mockConfirm = vi.fn(); +vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: mockConfirm }) })); + +const WORKFLOW: BoardWorkflowDefinition = { + id: "builtin:coding", + name: "Coding (built-in)", + columns: [ + { id: "triage", name: "Triage", flags: { intake: true } }, + { id: "todo", name: "Todo", flags: { hold: true } }, + { id: "in-progress", name: "In progress", flags: { countsTowardWip: true } }, + { id: "done", name: "Done", flags: { complete: true } }, + { id: "archived", name: "Archived", flags: { archived: true } }, + ], +}; + +function mkTask(overrides: Partial & { id: string }): Task { + return { + title: overrides.id, + description: "d", + column: "todo", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + ...overrides, + } as Task; +} + +const baseProps = () => ({ + workflow: WORKFLOW, + tasks: [] as Task[], + collapsed: false, + onToggleCollapse: vi.fn(), + maxConcurrent: 2, + onMoveTask: vi.fn().mockResolvedValue({} as Task), + onPromote: vi.fn().mockResolvedValue(undefined), + canDropTask: vi.fn().mockReturnValue(null), + getDraggingTaskId: vi.fn().mockReturnValue(null), + onOpenDetail: vi.fn(), + addToast: vi.fn(), +}); + +beforeEach(() => { + mockConfirm.mockReset(); + mockConfirm.mockResolvedValue(true); +}); + +describe("Lane", () => { + it("renders the workflow name and total card count in the header", () => { + render(); + expect(screen.getByText("Coding (built-in)")).toBeDefined(); + expect(screen.getByTestId("lane-count-builtin:coding").textContent).toBe("2"); + }); + + it("renders its workflow's columns in order, with archived hidden", () => { + render(); + const headings = screen.getAllByRole("heading", { level: 2 }).map((h) => h.textContent); + // Lane name is an h2 too; filter to column headings (order preserved). + expect(headings).toContain("Triage"); + expect(headings).toContain("Todo"); + expect(headings).toContain("In progress"); + expect(headings).toContain("Done"); + // Archived column is hidden. + expect(headings).not.toContain("Archived"); + }); + + it("collapses the lane (hides columns) when collapsed", () => { + render(); + expect(screen.queryByText("Triage")).toBeNull(); + }); + + it("invokes onToggleCollapse with the workflow id", () => { + const props = baseProps(); + render(); + fireEvent.click(screen.getByTestId("lane-toggle-builtin:coding")); + expect(props.onToggleCollapse).toHaveBeenCalledWith("builtin:coding"); + }); + + it("shows a Promote button on hold-column cards and calls onPromote", async () => { + const props = baseProps(); + render(); + const promoteBtn = screen.getByTestId("promote-FN-7"); + expect(promoteBtn).toBeDefined(); + fireEvent.click(promoteBtn); + await waitFor(() => expect(props.onPromote).toHaveBeenCalledWith("FN-7")); + }); + + it("shows inline capacity-exhausted feedback (not a toast) when promote rejects, then re-enables", async () => { + const props = baseProps(); + props.onPromote = vi.fn().mockRejectedValue({ + details: { code: "capacity-exhausted", messageKey: "board.rejection.capacityExhausted", retryable: true }, + }); + render(); + fireEvent.click(screen.getByTestId("promote-FN-8")); + await waitFor(() => expect(screen.getByTestId("column-inline-feedback")).toBeDefined()); + // No toast was used for the inline capacity feedback. + expect(props.addToast).not.toHaveBeenCalled(); + // Button re-enabled after the call resolves. + await waitFor(() => expect((screen.getByTestId("promote-FN-8") as HTMLButtonElement).disabled).toBe(false)); + }); + + it("prevents the drop (no-move) when canDropTask returns a rejection key", () => { + const props = baseProps(); + props.getDraggingTaskId = vi.fn().mockReturnValue("FN-DRAG"); + props.canDropTask = vi.fn().mockReturnValue("board.rejection.workflowMismatch"); + render(); + const ipColumn = document.querySelector('[data-column="in-progress"]') as HTMLElement; + const preventDefault = vi.fn(); + fireEvent.dragOver(ipColumn, { dataTransfer: { dropEffect: "" }, preventDefault }); + // Rejection → preventDefault NOT called → the browser refuses the drop. + expect(props.canDropTask).toHaveBeenCalledWith("FN-DRAG", "in-progress", "builtin:coding"); + // Inline feedback surfaces the translated rejection. + expect(screen.getByTestId("column-inline-feedback")).toBeDefined(); + }); + + it("allows the drop (preventDefault) when canDropTask returns null", () => { + const props = baseProps(); + props.getDraggingTaskId = vi.fn().mockReturnValue("FN-DRAG"); + props.canDropTask = vi.fn().mockReturnValue(null); + render(); + const ipColumn = document.querySelector('[data-column="in-progress"]') as HTMLElement; + // fireEvent.dragOver returns false when a handler called preventDefault. + const notPrevented = fireEvent.dragOver(ipColumn, { dataTransfer: { dropEffect: "" } }); + expect(notPrevented).toBe(false); + expect(screen.queryByTestId("column-inline-feedback")).toBeNull(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index fa0ad56f22..f798afb8ad 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -493,6 +493,24 @@ describe("TaskCard", () => { expect(screen.getByText("FN-001")).toBeDefined(); }); + it("renders a per-branch progress badge when the task is in a parallel window (U9/U13)", () => { + const task = { + ...makeTask(), + branchProgress: [ + { branchId: "b1", nodeId: "n1", status: "completed" }, + { branchId: "b2", nodeId: "n2", status: "running" }, + ], + } as Task; + render(); + const badge = screen.getByTestId("branch-progress-badge"); + expect(badge.textContent).toContain("1/2"); + }); + + it("does not render a branch-progress badge when there is no parallel window", () => { + render(); + expect(screen.queryByTestId("branch-progress-badge")).toBeNull(); + }); + it("keeps native card dragging enabled by default", () => { const { container } = render(); const card = container.querySelector(".card") as HTMLElement; diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx index 447afc9403..890a643c95 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx @@ -9,11 +9,61 @@ vi.mock("../../api", () => ({ updateWorkflow: vi.fn(), deleteWorkflow: vi.fn(), compileWorkflow: vi.fn(), + fetchTraits: vi.fn(), + fetchModels: vi.fn(), + fetchAgents: vi.fn(), + fetchDiscoveredSkills: vi.fn(), })); -import { fetchWorkflows } from "../../api"; +import { fireEvent } from "@testing-library/react"; +import { fetchWorkflows, fetchTraits, updateWorkflow, compileWorkflow, createWorkflow } from "../../api"; +import type { TraitCatalogEntry } from "../../api"; import { WorkflowNodeEditor } from "../WorkflowNodeEditor"; +const TRAIT_CATALOG: TraitCatalogEntry[] = [ + { id: "intake", name: "Intake", builtin: true, flags: { intake: true } }, + { id: "complete", name: "Complete", builtin: true, flags: { complete: true } }, + { id: "wip", name: "WIP", builtin: true, flags: { countsTowardWip: true } }, + { id: "hold", name: "Hold", builtin: true, flags: { hold: true } }, +]; + +function v2Def(): WorkflowDefinition { + return { + id: "WF-002", + name: "Custom", + description: "", + ir: { + version: "v2", + name: "Custom", + columns: [ + { id: "triage", name: "Triage", traits: [{ trait: "intake" }] }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "triage" }, + { id: "step", kind: "prompt", column: "triage", config: { prompt: "do" } }, + { id: "end", kind: "end", column: "done" }, + ], + edges: [ + { from: "start", to: "step", condition: "success" }, + { from: "step", to: "end", condition: "success" }, + ], + }, + layout: { + start: { x: 0, y: 20 }, + step: { x: 120, y: 60 }, + end: { x: 360, y: 240 }, + }, + createdAt: "2026-06-03T00:00:00.000Z", + updatedAt: "2026-06-03T00:00:00.000Z", + }; +} + +function builtinDef(): WorkflowDefinition { + const d = v2Def(); + return { ...d, id: "builtin:coding", name: "Default coding workflow" }; +} + function def(): WorkflowDefinition { return { id: "WF-001", @@ -70,6 +120,7 @@ describe("workflow-flow-mapping", () => { describe("WorkflowNodeEditor", () => { beforeEach(() => { vi.mocked(fetchWorkflows).mockResolvedValue([]); + vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG); }); afterEach(() => { @@ -89,3 +140,118 @@ describe("WorkflowNodeEditor", () => { expect(container).toBeEmptyDOMElement(); }); }); + +describe("WorkflowNodeEditor — U10 columns/traits/holds", () => { + beforeEach(() => { + vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG); + }); + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("shows the column panel with the workflow's columns and trait pickers", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]); + render( {}} addToast={() => {}} />); + expect(await screen.findByTestId("wf-column-panel")).toBeInTheDocument(); + expect(await screen.findByTestId("wf-column-triage")).toBeInTheDocument(); + expect(screen.getByTestId("wf-column-done")).toBeInTheDocument(); + // Trait picker fed by the catalog endpoint. + await waitFor(() => expect(screen.getAllByText("Complete").length).toBeGreaterThan(0)); + }); + + it("blocks save with a count summary when a node is unplaced", async () => { + const addToast = vi.fn(); + // A def whose 'step' node sits far below all bands → unplaced. + const d = v2Def(); + d.layout = { ...d.layout, step: { x: 120, y: 5000 } }; + // Strip the explicit column so placement is position-derived. + if (d.ir.version === "v2") d.ir.nodes = d.ir.nodes.map((n) => (n.id === "step" ? { ...n, column: undefined } : n)); + vi.mocked(fetchWorkflows).mockResolvedValue([d]); + + render( {}} addToast={addToast} />); + const saveBtn = await screen.findByText("Save"); + await waitFor(() => expect(screen.getByTestId("wf-unplaced-summary")).toBeInTheDocument()); + fireEvent.click(saveBtn.closest("button")!); + + await waitFor(() => + expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/not placed in a column/i), "error"), + ); + expect(updateWorkflow).not.toHaveBeenCalled(); + // Inline node badge present. + expect(screen.getByTestId("wf-node-error-badge")).toBeInTheDocument(); + }); + + it("renders a trait conflict on the column and blocks save", async () => { + const addToast = vi.fn(); + const d = v2Def(); + // Make 'done' both complete and wip — a composition conflict. + if (d.ir.version === "v2") { + d.ir.columns = d.ir.columns.map((c) => + c.id === "done" ? { ...c, traits: [{ trait: "complete" }, { trait: "wip" }] } : c, + ); + } + vi.mocked(fetchWorkflows).mockResolvedValue([d]); + + render( {}} addToast={addToast} />); + const doneCol = await screen.findByTestId("wf-column-done"); + await waitFor(() => expect(doneCol).toHaveAttribute("data-column-error", "true")); + + fireEvent.click((await screen.findByText("Save")).closest("button")!); + await waitFor(() => + expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/trait conflicts/i), "error"), + ); + expect(updateWorkflow).not.toHaveBeenCalled(); + }); + + it("surfaces a seam-in-branch server error as a node badge", async () => { + const addToast = vi.fn(); + vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]); + vi.mocked(updateWorkflow).mockRejectedValue( + new Error("seam 'merge' node 'step' is forbidden inside a parallel branch of split 's1'"), + ); + + render( {}} addToast={addToast} />); + fireEvent.click((await screen.findByText("Save")).closest("button")!); + + await waitFor(() => expect(updateWorkflow).toHaveBeenCalled()); + await waitFor(() => + expect(screen.getByTestId("wf-node-error-badge")).toHaveTextContent(/forbidden inside a parallel branch/i), + ); + }); + + it("opens a built-in read-only with a Duplicate to customize CTA replacing the toolbar", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]); + vi.mocked(createWorkflow).mockResolvedValue({ ...v2Def(), id: "WF-copy", name: "Copy" }); + + render( {}} addToast={() => {}} />); + expect(await screen.findByTestId("wf-readonly-banner")).toBeInTheDocument(); + // No Save button (toolbar replaced). + expect(screen.queryByText("Save")).not.toBeInTheDocument(); + const dup = screen.getByText(/Duplicate to customize/i); + expect(dup).toBeInTheDocument(); + fireEvent.click(dup.closest("button")!); + await waitFor(() => expect(createWorkflow).toHaveBeenCalled()); + }); + + it("saves a valid v2 workflow round-tripping columns to the API", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]); + vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({ + ...v2Def(), + ...(updates as object), + })); + vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] }); + + render( {}} addToast={() => {}} />); + // Wait for the column panel to hydrate before saving — saving earlier + // races the async columns state and flowToIr would emit a v1 IR. + await screen.findByText("Save"); + await waitFor(() => expect(screen.getAllByLabelText(/Column name/i).length).toBeGreaterThan(0)); + fireEvent.click(screen.getByText("Save").closest("button")!); + + await waitFor(() => expect(updateWorkflow).toHaveBeenCalled()); + const [, updates] = vi.mocked(updateWorkflow).mock.calls[0]; + expect((updates as { ir: { version: string } }).ir.version).toBe("v2"); + expect((updates as { ir: { columns: unknown[] } }).ir.columns).toHaveLength(2); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/WorkflowSelector.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowSelector.test.tsx new file mode 100644 index 0000000000..01ae4f38cf --- /dev/null +++ b/packages/dashboard/app/components/__tests__/WorkflowSelector.test.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { WorkflowSelector } from "../WorkflowSelector"; + +vi.mock("lucide-react", () => ({ Workflow: () => null })); + +const fetchWorkflowsMock = vi.fn(); +vi.mock("../../api", () => ({ + fetchWorkflows: (...args: unknown[]) => fetchWorkflowsMock(...args), + fetchProjectDefaultWorkflow: vi.fn(), + setProjectDefaultWorkflow: vi.fn(), +})); + +const mockConfirm = vi.fn(); +vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: mockConfirm }) })); + +beforeEach(() => { + mockConfirm.mockReset(); + fetchWorkflowsMock.mockReset(); + fetchWorkflowsMock.mockResolvedValue([ + { id: "wf-a", name: "Workflow A" }, + { id: "wf-b", name: "Workflow B" }, + ]); +}); + +describe("WorkflowSelector switch-with-active-session confirm (U9)", () => { + it("shows the abort-warning confirm and applies the switch when confirmed", async () => { + mockConfirm.mockResolvedValue(true); + const onChange = vi.fn().mockResolvedValue(undefined); + render(); + await waitFor(() => expect(screen.getByRole("combobox")).toBeDefined()); + fireEvent.change(screen.getByRole("combobox"), { target: { value: "wf-b" } }); + await waitFor(() => expect(mockConfirm).toHaveBeenCalled()); + await waitFor(() => expect(onChange).toHaveBeenCalledWith("wf-b")); + }); + + it("does NOT apply the switch when the confirm is cancelled", async () => { + mockConfirm.mockResolvedValue(false); + const onChange = vi.fn().mockResolvedValue(undefined); + render(); + await waitFor(() => expect(screen.getByRole("combobox")).toBeDefined()); + fireEvent.change(screen.getByRole("combobox"), { target: { value: "wf-b" } }); + await waitFor(() => expect(mockConfirm).toHaveBeenCalled()); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("skips the confirm when the task has no active session", async () => { + const onChange = vi.fn().mockResolvedValue(undefined); + render(); + await waitFor(() => expect(screen.getByRole("combobox")).toBeDefined()); + fireEvent.change(screen.getByRole("combobox"), { target: { value: "wf-b" } }); + await waitFor(() => expect(onChange).toHaveBeenCalledWith("wf-b")); + expect(mockConfirm).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx b/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx index e93dc45750..b825adf972 100644 --- a/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx @@ -6,6 +6,7 @@ import { PageErrorBoundary } from "../ErrorBoundary"; import type { Task } from "@fusion/core"; vi.mock("../../api", () => ({ + fetchBoardWorkflows: vi.fn().mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} }), fetchWorkflowSteps: vi.fn().mockResolvedValue([]), })); diff --git a/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx b/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx index f9dd709c6b..038e12dc0f 100644 --- a/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx +++ b/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx @@ -5,6 +5,7 @@ import { Board } from "../Board"; import { loadAllAppCss } from "../../test/cssFixture"; vi.mock("../../api", () => ({ + fetchBoardWorkflows: vi.fn().mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} }), fetchWorkflowSteps: vi.fn().mockResolvedValue([]), })); diff --git a/packages/dashboard/app/components/__tests__/board-mobile-view-switch.test.tsx b/packages/dashboard/app/components/__tests__/board-mobile-view-switch.test.tsx index 100ed3dd10..f19edd1272 100644 --- a/packages/dashboard/app/components/__tests__/board-mobile-view-switch.test.tsx +++ b/packages/dashboard/app/components/__tests__/board-mobile-view-switch.test.tsx @@ -6,6 +6,7 @@ import { ListView } from "../ListView"; import "../../styles.css"; vi.mock("../../api", () => ({ + fetchBoardWorkflows: vi.fn().mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} }), fetchWorkflowSteps: vi.fn().mockResolvedValue([]), fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }), fetchSettings: vi.fn().mockResolvedValue({ diff --git a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts index 783f7c336d..f496ba0002 100644 --- a/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts +++ b/packages/dashboard/app/components/__tests__/workflow-flow-mapping.test.ts @@ -1,6 +1,20 @@ import { describe, expect, it } from "vitest"; import type { WorkflowDefinition } from "@fusion/core"; -import { irToFlow, flowToIr } from "../workflow-flow-mapping"; +import type { Node as FlowNode } from "@xyflow/react"; +import { + irToFlow, + flowToIr, + columnsOf, + columnForY, + bandTop, + columnsToBandNodes, + isColumnBandNode, + validateColumnsClient, + unplacedNodeIds, + COLUMN_BAND_HEIGHT, +} from "../workflow-flow-mapping"; +import type { WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes"; +import type { TraitCatalogEntry } from "../../api"; function makeDef(ir: WorkflowDefinition["ir"]): WorkflowDefinition { return { @@ -93,3 +107,198 @@ describe("workflow-flow-mapping name preservation", () => { expect(n1?.config?.name).toBe("Build feature"); }); }); + +// ── U10: v2 round-trip (columns, placement, hold, split/join) ──────────────── + +const CATALOG: TraitCatalogEntry[] = [ + { id: "intake", name: "Intake", builtin: true, flags: { intake: true } }, + { id: "complete", name: "Complete", builtin: true, flags: { complete: true } }, + { id: "archived", name: "Archived", builtin: true, flags: { archived: true, hiddenFromBoard: true } }, + { id: "wip", name: "WIP", builtin: true, flags: { countsTowardWip: true } }, + { id: "hold", name: "Hold", builtin: true, flags: { hold: true } }, +]; + +function v2Def(ir: WorkflowDefinition["ir"], layout: WorkflowDefinition["layout"] = {}): WorkflowDefinition { + return { ...makeDef(ir), layout }; +} + +describe("workflow-flow-mapping v2 round-trip", () => { + const ir: WorkflowDefinition["ir"] = { + version: "v2", + name: "wf2", + columns: [ + { id: "triage", name: "Triage", traits: [{ trait: "intake" }] }, + { id: "in-progress", name: "In progress", traits: [{ trait: "wip", config: { limit: 2 } }] }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "triage" }, + { id: "h1", kind: "hold", column: "triage", config: { release: "manual" } }, + { id: "s1", kind: "split", column: "in-progress" }, + { id: "b1", kind: "prompt", column: "in-progress", config: { prompt: "lint" } }, + { id: "b2", kind: "prompt", column: "in-progress", config: { prompt: "test" } }, + { id: "j1", kind: "join", column: "in-progress", config: { mode: { quorum: 2 }, onBranchFailure: "fail-fast" } }, + { id: "end", kind: "end", column: "done" }, + ], + edges: [ + { from: "start", to: "h1", condition: "success" }, + { from: "h1", to: "s1", condition: "success" }, + { from: "s1", to: "b1", condition: "success" }, + { from: "s1", to: "b2", condition: "success" }, + { from: "b1", to: "j1", condition: "success" }, + { from: "b2", to: "j1", condition: "success" }, + { from: "j1", to: "end", condition: "success" }, + ], + }; + + it("round-trips columns, placement, hold, and split/join config losslessly", () => { + const { nodes, edges } = irToFlow(v2Def(ir)); + const columns = columnsOf(v2Def(ir)); + const { ir: out } = flowToIr("wf2", nodes, edges, columns); + + expect(out.version).toBe("v2"); + if (out.version !== "v2") return; + + // Columns preserved in order with their traits. + expect(out.columns.map((c) => c.id)).toEqual(["triage", "in-progress", "done"]); + expect(out.columns[1].traits).toEqual([{ trait: "wip", config: { limit: 2 } }]); + + const byId = Object.fromEntries(out.nodes.map((n) => [n.id, n])); + // Placement preserved for every node. + expect(byId.h1.column).toBe("triage"); + expect(byId.s1.column).toBe("in-progress"); + expect(byId.j1.column).toBe("in-progress"); + expect(byId.end.column).toBe("done"); + // Hold release config preserved. + expect(byId.h1.config?.release).toBe("manual"); + // Split/join shape preserved. + expect(byId.s1.kind).toBe("split"); + expect(byId.j1.kind).toBe("join"); + expect(byId.j1.config?.mode).toEqual({ quorum: 2 }); + expect(byId.j1.config?.onBranchFailure).toBe("fail-fast"); + }); + + it("emits swimlane band group nodes that flowToIr strips back out", () => { + const { nodes } = irToFlow(v2Def(ir)); + const bands = nodes.filter((n) => isColumnBandNode(n.id)); + expect(bands).toHaveLength(3); + expect(bands.every((b) => b.type === "group")).toBe(true); + // flowToIr must not emit band group nodes as IR nodes. + const { ir: out } = flowToIr("wf2", nodes, [], columnsOf(v2Def(ir))); + expect(out.nodes.some((n) => isColumnBandNode(n.id))).toBe(false); + }); + + it("derives node.column by position when a node is dropped into a band", () => { + const columns = columnsOf(v2Def(ir)); + // Band index 2 = "done"; a node dragged to that band's y resolves to it. + const yInDone = bandTop(2) + 40; + expect(columnForY(yInDone, columns)).toBe("done"); + + // Simulate a node moved into the "done" band with no explicit data.column. + const stepNode: FlowNode = { + id: "n9", + type: "prompt", + position: { x: 100, y: yInDone }, + data: { kind: "prompt", label: "ship", config: {} }, + }; + const bandNodes = columnsToBandNodes(columns); + const { ir: out } = flowToIr("wf2", [...bandNodes, stepNode], [], columns); + const n9 = out.version === "v2" ? out.nodes.find((n) => n.id === "n9") : undefined; + expect(n9?.column).toBe("done"); + }); + + it("v1 definitions map to empty columns (legacy round-trip stays v1)", () => { + const v1: WorkflowDefinition["ir"] = { + version: "v1", + name: "wf", + nodes: [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, + ], + edges: [{ from: "start", to: "end", condition: "success" }], + }; + const def = makeDef(v1); + expect(columnsOf(def)).toEqual([]); + const { nodes, edges } = irToFlow(def); + const { ir: out } = flowToIr("wf", nodes, edges, columnsOf(def)); + expect(out.version).toBe("v1"); + }); +}); + +describe("workflow-flow-mapping validation helpers", () => { + it("flags a trait conflict on the offending column", () => { + const columns = [ + { id: "done", name: "Done", traits: [{ trait: "complete" }, { trait: "wip" }] }, + ]; + const violations = validateColumnsClient(columns, CATALOG); + const conflict = violations.find((v) => v.code === "complete-with-wip"); + expect(conflict).toBeTruthy(); + expect(conflict?.columnId).toBe("done"); + expect(conflict?.severity).toBe("error"); + }); + + it("flags more than one intake column workflow-wide", () => { + const columns = [ + { id: "a", name: "A", traits: [{ trait: "intake" }] }, + { id: "b", name: "B", traits: [{ trait: "intake" }] }, + ]; + const v = validateColumnsClient(columns, CATALOG).find((x) => x.code === "multiple-intake-columns"); + expect(v?.columnId).toBeNull(); + }); + + it("reports unplaced step nodes (not start/end, not bands)", () => { + const columns = columnsOf( + v2Def({ + version: "v2", + name: "w", + columns: [{ id: "c1", name: "C1", traits: [] }], + nodes: [ + { id: "start", kind: "start" }, + { id: "end", kind: "end" }, + ], + edges: [{ from: "start", to: "end", condition: "success" }], + }), + ); + const placed: FlowNode = { + id: "p1", + type: "prompt", + position: { x: 0, y: bandTop(0) + 20 }, + data: { kind: "prompt", label: "x", config: {}, column: "c1" }, + }; + // A fresh node parked far below the single band (no explicit column) is + // strictly outside every band → unplaced. + const floating: FlowNode = { + id: "float", + type: "prompt", + position: { x: 0, y: bandTop(0) + COLUMN_BAND_HEIGHT * 5 }, + data: { kind: "prompt", label: "y", config: {} }, + }; + const ids = unplacedNodeIds( + [...columnsToBandNodes(columns), placed, floating, + { id: "start", type: "start", position: { x: 0, y: 0 }, data: { kind: "start", label: "" } }, + { id: "end", type: "end", position: { x: 0, y: 0 }, data: { kind: "end", label: "" } }, + ], + columns, + ); + expect(ids).not.toContain("p1"); + expect(ids).not.toContain("start"); + expect(ids).not.toContain("end"); + expect(ids).toContain("float"); + }); + + it("treats a node with an unknown column id as unplaced", () => { + const columns = [{ id: "c1", name: "C1", traits: [] }]; + const ghost: FlowNode = { + id: "ghost", + type: "prompt", + position: { x: 0, y: bandTop(0) }, + data: { kind: "prompt", label: "x", config: {}, column: "no-such-column" }, + }; + const ids = unplacedNodeIds([ghost], columns); + expect(ids).toContain("ghost"); + }); + + it("band height stays positive (geometry sanity)", () => { + expect(COLUMN_BAND_HEIGHT).toBeGreaterThan(0); + }); +}); diff --git a/packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx b/packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx index dec02dc6b1..2f83e337bb 100644 --- a/packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx +++ b/packages/dashboard/app/components/nodes/WorkflowNodeTypes.tsx @@ -1,14 +1,31 @@ import { Handle, Position, type NodeProps } from "@xyflow/react"; -import { Play, Flag, MessageSquare, Terminal, Shield, GitMerge } from "lucide-react"; +import { Play, Flag, MessageSquare, Terminal, Shield, GitMerge, PauseCircle, Split, Merge, AlertTriangle } from "lucide-react"; -/** Node kinds the editor can render. "merge" is the pre/post-merge seam marker. */ -export type WorkflowEditorNodeKind = "start" | "end" | "prompt" | "script" | "gate" | "merge"; +/** Node kinds the editor can render. "merge" is the pre/post-merge seam marker. + * v2 adds "hold" (passive dwell), "split"/"join" (parallel fan-out). */ +export type WorkflowEditorNodeKind = + | "start" + | "end" + | "prompt" + | "script" + | "gate" + | "merge" + | "hold" + | "split" + | "join"; export interface WorkflowFlowNodeData { kind: WorkflowEditorNodeKind; label: string; - /** Mirrors the IR node config (prompt, scriptName, gateMode, model…). */ + /** Mirrors the IR node config (prompt, scriptName, gateMode, model, release, + * join mode/failure policy…). */ config?: Record; + /** v2: the workflow column this node is placed in (derived from the swimlane + * band it sits in). Surfaced for the unplaced-node error badge. */ + column?: string; + /** When true, render the shared error-state badge on the node (unplaced node + * or seam-in-branch). Set by the editor from validation. */ + errorBadge?: string; [key: string]: unknown; } @@ -19,20 +36,50 @@ const KIND_ICON: Record = { script: Terminal, gate: Shield, merge: GitMerge, + hold: PauseCircle, + split: Split, + join: Merge, }; +/** Shared error-state component (U10): one component renders both the + * unplaced-node and the seam-in-branch error as an inline badge on the node. */ +export function WorkflowNodeErrorBadge({ message }: { message: string }) { + return ( + + {message} + + ); +} + function NodeShell({ data, kind }: { data: WorkflowFlowNodeData; kind: WorkflowEditorNodeKind }) { const Icon = KIND_ICON[kind]; const showTarget = kind !== "start"; const showSource = kind !== "end"; + const release = kind === "hold" ? (data.config?.release as string | undefined) : undefined; + const joinMode = + kind === "join" + ? (() => { + const m = data.config?.mode as unknown; + if (m && typeof m === "object" && "quorum" in (m as object)) { + return `quorum(${(m as { quorum: number }).quorum})`; + } + return typeof m === "string" ? m : "all"; + })() + : undefined; return ( -
+
{showTarget && } {data.label || kind} {kind === "gate" && gate} + {release && {release}} + {joinMode && {joinMode}} + {data.errorBadge && } {showSource && }
); @@ -45,4 +92,7 @@ export const workflowNodeTypes = { script: ({ data }: NodeProps) => , gate: ({ data }: NodeProps) => , merge: ({ data }: NodeProps) => , + hold: ({ data }: NodeProps) => , + split: ({ data }: NodeProps) => , + join: ({ data }: NodeProps) => , }; diff --git a/packages/dashboard/app/components/workflow-flow-mapping.ts b/packages/dashboard/app/components/workflow-flow-mapping.ts index cb0a7e975b..2aaa22b8f0 100644 --- a/packages/dashboard/app/components/workflow-flow-mapping.ts +++ b/packages/dashboard/app/components/workflow-flow-mapping.ts @@ -1,7 +1,56 @@ import type { Node as FlowNode, Edge as FlowEdge } from "@xyflow/react"; -import type { WorkflowIr, WorkflowDefinition } from "@fusion/core"; +import type { + WorkflowIr, + WorkflowIrV2, + WorkflowIrColumn, + WorkflowDefinition, +} from "@fusion/core"; import type { WorkflowFlowNodeData, WorkflowEditorNodeKind } from "./nodes/WorkflowNodeTypes"; +/** Layout geometry for column swimlane bands. Bands stack vertically; each band + * is full-width and a node's `column` is derived by hit-testing the node's y + * against the band rows (position-based, so the editor's existing absolute + * layout persistence carries over unchanged — see flowToIr). */ +export const COLUMN_BAND_HEIGHT = 220; +export const COLUMN_BAND_WIDTH = 5000; +export const COLUMN_BAND_X = -40; +export const COLUMN_BAND_TOP = 0; +/** React Flow node id for a column band group node. */ +export const columnBandNodeId = (columnId: string): string => `__col__:${columnId}`; +export const isColumnBandNode = (id: string): boolean => id.startsWith("__col__:"); +export const columnIdFromBandNode = (id: string): string => id.slice("__col__:".length); + +/** The y-origin of the band for the column at `index`. */ +export function bandTop(index: number): number { + return COLUMN_BAND_TOP + index * COLUMN_BAND_HEIGHT; +} + +/** Hit-test a y coordinate against the ordered column bands, returning the + * column id whose band contains it (clamped to the first/last band). Returns + * undefined when there are no columns. Use for drag placement (a dropped node + * always snaps to the nearest band). */ +export function columnForY(y: number, columns: WorkflowIrColumn[]): string | undefined { + if (columns.length === 0) return undefined; + const idx = Math.floor((y - COLUMN_BAND_TOP) / COLUMN_BAND_HEIGHT); + const clamped = Math.max(0, Math.min(columns.length - 1, idx)); + return columns[clamped]?.id; +} + +/** Strict (non-clamping) hit test: returns the column id whose band vertically + * contains `y`, or undefined when `y` falls outside every band. Use for + * unplaced-node detection (a node parked above/below all bands is unplaced). */ +export function strictColumnForY(y: number, columns: WorkflowIrColumn[]): string | undefined { + if (columns.length === 0) return undefined; + const idx = Math.floor((y - COLUMN_BAND_TOP) / COLUMN_BAND_HEIGHT); + if (idx < 0 || idx >= columns.length) return undefined; + return columns[idx]?.id; +} + +/** True when the IR is v2 (has columns). */ +function isV2(ir: WorkflowIr): ir is WorkflowIrV2 { + return ir.version === "v2"; +} + /** Resolve the editor node "type" for an IR node (merge seam → "merge"). */ function editorKind(node: WorkflowIr["nodes"][number]): WorkflowEditorNodeKind { const seam = node.config?.seam; @@ -16,19 +65,53 @@ function nodeLabel(node: WorkflowIr["nodes"][number]): string { return node.id; } -/** Build React Flow nodes/edges from a stored workflow definition. */ +/** Build React Flow swimlane band group nodes from the workflow's columns. */ +export function columnsToBandNodes(columns: WorkflowIrColumn[]): FlowNode[] { + return columns.map((col, index): FlowNode => ({ + id: columnBandNodeId(col.id), + type: "group", + position: { x: COLUMN_BAND_X, y: bandTop(index) }, + data: { kind: "start", label: col.name, column: col.id } as unknown as WorkflowFlowNodeData, + draggable: false, + selectable: false, + deletable: false, + // Bands sit behind step nodes so steps remain clickable/draggable. + zIndex: -1, + style: { + width: COLUMN_BAND_WIDTH, + height: COLUMN_BAND_HEIGHT, + }, + className: "wf-column-band", + })); +} + +/** Build React Flow nodes/edges from a stored workflow definition. v2 columns + * render as swimlane band group nodes; step nodes carry their `column`. */ export function irToFlow(def: WorkflowDefinition): { nodes: FlowNode[]; edges: FlowEdge[]; } { - const nodes = def.ir.nodes.map((node, index): FlowNode => { + const columns = isV2(def.ir) ? def.ir.columns : []; + const bandNodes = columnsToBandNodes(columns); + + const stepNodes = def.ir.nodes.map((node, index): FlowNode => { const pos = def.layout?.[node.id]; const kind = editorKind(node); + const column = isV2(def.ir) ? node.column : undefined; + const colIndex = column ? columns.findIndex((c) => c.id === column) : -1; + // Default placement seeds the node inside its column band when no persisted + // layout exists; otherwise we honor the saved absolute position. + const fallbackY = colIndex >= 0 ? bandTop(colIndex) + 70 : 120; return { id: node.id, type: kind, - position: pos ?? { x: 80 + index * 180, y: 120 }, - data: { kind, label: nodeLabel(node), config: { ...(node.config ?? {}) } }, + position: pos ?? { x: 80 + index * 180, y: fallbackY }, + data: { + kind, + label: nodeLabel(node), + config: { ...(node.config ?? {}) }, + column, + }, deletable: node.kind !== "start" && node.kind !== "end", }; }); @@ -44,38 +127,55 @@ export function irToFlow(def: WorkflowDefinition): { }; }); - return { nodes, edges }; + return { nodes: [...bandNodes, ...stepNodes], edges }; } -/** Project React Flow nodes/edges back into a WorkflowIr plus a layout map. */ +/** Sanitize a node config, applying the v1 round-trip name rules. */ +function nodeConfig(node: FlowNode): Record | undefined { + const data = node.data; + const config: Record = { ...(data.config ?? {}) }; + const fallbackLabel = data.kind === "merge" ? "Merge boundary" : node.id; + if (data.kind !== "start" && data.kind !== "end" && data.label && data.label !== fallbackLabel) { + config.name = data.label; + } else { + delete config.name; + } + return config; +} + +/** + * Project React Flow nodes/edges back into a WorkflowIr plus a layout map. + * + * When `columns` is provided (the editor manages columns via WorkflowColumnPanel) + * the result is a **v2** IR: column bands are dropped, each step node's `column` + * is derived by hit-testing its y against the ordered bands, and split/join/hold + * config is preserved verbatim. With no columns the result is a v1 IR (legacy + * round-trip, byte-compatible with the pre-U10 mapping). + */ export function flowToIr( name: string, nodes: FlowNode[], edges: FlowEdge[], + columns?: WorkflowIrColumn[], ): { ir: WorkflowIr; layout: Record } { - const irNodes: WorkflowIr["nodes"] = nodes.map((node) => { + const stepNodes = nodes.filter((n) => !isColumnBandNode(n.id) && n.type !== "group"); + const v2 = Array.isArray(columns) && columns.length > 0; + + const irNodes: WorkflowIr["nodes"] = stepNodes.map((node) => { const data = node.data; - const config: Record = { ...(data.config ?? {}) }; - // `irToFlow` synthesizes display labels for unnamed nodes (matching the - // fallback below), so only persist a label that the user actually set — - // otherwise saving an untouched workflow injects synthetic names like - // "start"/"end"/"Merge boundary" and breaks IR round-trips. - const fallbackLabel = data.kind === "merge" ? "Merge boundary" : node.id; - if ( - data.kind !== "start" && - data.kind !== "end" && - data.label && - data.label !== fallbackLabel - ) { - config.name = data.label; - } else { - delete config.name; - } + const config = nodeConfig(node); + // Derive column placement from the node's y position relative to the bands. + const column = v2 ? data.column ?? columnForY(node.position.y, columns!) : undefined; if (data.kind === "merge") { - config.seam = "merge"; - return { id: node.id, kind: "prompt", config }; + const cfg = { ...(config ?? {}), seam: "merge" }; + return { id: node.id, kind: "prompt" as const, ...(column ? { column } : {}), config: cfg }; } - return { id: node.id, kind: data.kind, config: Object.keys(config).length ? config : undefined }; + return { + id: node.id, + kind: data.kind, + ...(column ? { column } : {}), + config: config && Object.keys(config).length ? config : undefined, + }; }); const irEdges: WorkflowIr["edges"] = edges.map((edge) => { @@ -83,14 +183,166 @@ export function flowToIr( return { from: edge.source, to: edge.target, condition }; }); - const layout = nodes.reduce>((acc, node) => { + const layout = stepNodes.reduce>((acc, node) => { acc[node.id] = { x: Math.round(node.position.x), y: Math.round(node.position.y) }; return acc; }, {}); + if (v2) { + const ir: WorkflowIrV2 = { + version: "v2", + name, + columns: columns!.map((c) => ({ id: c.id, name: c.name, traits: c.traits })), + nodes: irNodes, + edges: irEdges, + }; + return { ir, layout }; + } + return { ir: { version: "v1", name, nodes: irNodes, edges: irEdges }, layout }; } +// ── Client-side validation (U10) ───────────────────────────────────────────── +// +// The server's parseWorkflowIr (run on PATCH) is the authority for structural +// errors (undefined-column references, seam-in-branch, duplicate column ids). +// These two helpers run client-side so the editor can render precise inline +// badges and block the save before a round-trip: +// - composition violations attributed to the offending column band; +// - unplaced-node errors attributed to the offending step node. +// They mirror @fusion/core's validateColumnTraits rules using the catalog flags +// (the catalog endpoint ships the same flags the registry validates against). + +import type { TraitViolation } from "@fusion/core"; +import type { TraitCatalogEntry } from "../api"; + +type CatalogFlags = TraitCatalogEntry["flags"]; + +function mergedFlags( + traits: WorkflowIrColumn["traits"], + catalog: Map, +): { flags: CatalogFlags; capacityTraitIds: string[]; unknown: string[] } { + const flags: CatalogFlags = {}; + const capacityTraitIds: string[] = []; + const unknown: string[] = []; + for (const ct of traits) { + const def = catalog.get(ct.trait); + if (!def) { + unknown.push(ct.trait); + continue; + } + for (const [k, v] of Object.entries(def.flags)) { + if (v) (flags as Record)[k] = true; + } + if (def.flags.countsTowardWip) capacityTraitIds.push(def.id); + } + return { flags, capacityTraitIds, unknown }; +} + +/** Client mirror of core's validateColumnTraits, driven by the trait catalog. */ +export function validateColumnsClient( + columns: WorkflowIrColumn[], + catalog: TraitCatalogEntry[], +): TraitViolation[] { + const byId = new Map(catalog.map((c) => [c.id, c])); + const violations: TraitViolation[] = []; + let intakeCount = 0; + + for (const col of columns) { + const { flags, capacityTraitIds, unknown } = mergedFlags(col.traits, byId); + for (const u of unknown) { + violations.push({ + code: "unknown-trait", + severity: "error", + columnId: col.id, + traitIds: [u], + message: `Column '${col.id}' references unknown trait '${u}'`, + }); + } + if (flags.complete && flags.countsTowardWip) { + violations.push({ + code: "complete-with-wip", + severity: "error", + columnId: col.id, + traitIds: capacityTraitIds, + message: `Column '${col.name || col.id}' is both a completion column and counts toward WIP`, + }); + } + if (capacityTraitIds.length > 1) { + violations.push({ + code: "two-capacity-traits", + severity: "error", + columnId: col.id, + traitIds: capacityTraitIds, + message: `Column '${col.name || col.id}' has more than one capacity (WIP) trait`, + }); + } + if (flags.complete && flags.intake) { + violations.push({ + code: "complete-with-intake", + severity: "error", + columnId: col.id, + traitIds: [], + message: `Column '${col.name || col.id}' is both a completion column and an intake column`, + }); + } + if (flags.archived && flags.countsTowardWip) { + violations.push({ + code: "archived-with-wip", + severity: "error", + columnId: col.id, + traitIds: [], + message: `Column '${col.name || col.id}' is archived but counts toward WIP`, + }); + } + if (flags.intake) intakeCount += 1; + } + + if (intakeCount > 1) { + violations.push({ + code: "multiple-intake-columns", + severity: "error", + columnId: null, + traitIds: [], + message: `Workflow has ${intakeCount} intake columns; exactly one is allowed`, + }); + } + + return violations; +} + +/** Step node ids that are not placed in any column (v2 only). Bands and + * start/end are exempt — start/end are structural and need no column. */ +export function unplacedNodeIds( + nodes: FlowNode[], + columns: WorkflowIrColumn[], +): string[] { + if (columns.length === 0) return []; + const ids: string[] = []; + for (const node of nodes) { + if (isColumnBandNode(node.id) || node.type === "group") continue; + if (node.data.kind === "start" || node.data.kind === "end") continue; + // A node is placed if it carries a valid column id, or if its y falls + // strictly within a band's extent. A node parked outside every band with + // no explicit column is unplaced (blocks save with an inline badge). + const explicit = node.data.column; + if (explicit && columns.some((c) => c.id === explicit)) continue; + if (explicit && !columns.some((c) => c.id === explicit)) { + ids.push(node.id); + continue; + } + const byPosition = strictColumnForY(node.position.y, columns); + if (!byPosition) ids.push(node.id); + } + return ids; +} + +/** Extract the editor's working column list from a definition (v2 → its + * columns; v1 → empty, meaning "no custom columns authored yet"). */ +export function columnsOf(def: WorkflowDefinition): WorkflowIrColumn[] { + return isV2(def.ir) ? def.ir.columns.map((c) => ({ ...c, traits: [...c.traits] })) : []; +} + /** Seed graph for a brand-new workflow: start → end with room to insert steps. */ export function emptyWorkflowIr(name: string): WorkflowIr { return { diff --git a/packages/dashboard/app/hooks/useTasks.ts b/packages/dashboard/app/hooks/useTasks.ts index 118afe699d..46c40c3ede 100644 --- a/packages/dashboard/app/hooks/useTasks.ts +++ b/packages/dashboard/app/hooks/useTasks.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useRef } from "react"; -import type { Task, Column, TaskCreateInput, MergeResult, GithubIssueAction } from "@fusion/core"; +import type { Task, Column, ColumnId, TaskCreateInput, MergeResult, GithubIssueAction } from "@fusion/core"; import { normalizeColumn } from "@fusion/core"; import * as api from "../api"; import { subscribeSse } from "../sse-bus"; @@ -359,14 +359,18 @@ export function useTasks(options?: UseTasksOptions) { void refreshTasksRef.current({ searchQueryOverride: searchQueryRef.current }); return; } - const { task, to }: { task: Task; from: Column; to: Column } = JSON.parse(e.data); + // #1403: the move event carries `ColumnId` (custom column ids admitted). + const { task, to }: { task: Task; from: ColumnId; to: ColumnId } = JSON.parse(e.data); const normalizedTask = normalizeTask(task); if (isSoftDeleted(normalizedTask)) { setTasks((prev) => prev.filter((candidate) => candidate.id !== normalizedTask.id)); pushTrace("useTasks", "soft-deleted-task-suppressed", { event: "task:moved", id: normalizedTask.id }); return; } - const movedTask = { ...normalizedTask, column: normalizeColumn(to, normalizedTask.column) }; + // Preserve a custom (non-legacy) target id verbatim; only coerce empty/garbage + // back to the task's current column. normalizeColumn alone would drop custom ids. + const nextColumn: ColumnId = typeof to === "string" && to ? to : normalizedTask.column; + const movedTask = { ...normalizedTask, column: nextColumn }; setTasks((prev) => { const existingIndex = prev.findIndex((t) => t.id === movedTask.id); if (existingIndex === -1) { diff --git a/packages/dashboard/app/i18n/labels.ts b/packages/dashboard/app/i18n/labels.ts index 6f9a8981dc..e3e2295aeb 100644 --- a/packages/dashboard/app/i18n/labels.ts +++ b/packages/dashboard/app/i18n/labels.ts @@ -1,4 +1,4 @@ -import { COLUMN_LABELS, type Column } from "@fusion/core"; +import { COLUMN_LABELS, type ColumnId } from "@fusion/core"; import { useTranslation } from "react-i18next"; /** @@ -6,8 +6,12 @@ import { useTranslation } from "react-i18next"; * keys with the English `COLUMN_LABELS` as the fallback. This is the migration * pattern for the centralized core label constants: import the hook, call it, * and replace `COLUMN_LABELS[col]` with `columnLabel(col)`. + * + * #1403: accepts a {@link ColumnId}; workflow-defined custom columns that have + * no legacy label or i18n key fall back to displaying the raw id. */ -export function useColumnLabel(): (column: Column) => string { +export function useColumnLabel(): (column: ColumnId) => string { const { t } = useTranslation("common"); - return (column: Column) => t(`columns.${column}`, COLUMN_LABELS[column]); + return (column: ColumnId) => + t(`columns.${column}`, (COLUMN_LABELS as Record)[column] ?? column); } diff --git a/packages/dashboard/src/__tests__/chat-attachment-routes.test.ts b/packages/dashboard/src/__tests__/chat-attachment-routes.test.ts index bf39f691b8..8d889a797d 100644 --- a/packages/dashboard/src/__tests__/chat-attachment-routes.test.ts +++ b/packages/dashboard/src/__tests__/chat-attachment-routes.test.ts @@ -66,7 +66,8 @@ vi.mock("../chat.js", () => ({ __resetChatState: vi.fn(), })); -vi.mock("@fusion/core", () => ({ +vi.mock("@fusion/core", async (importOriginal) => ({ + ...(await importOriginal()), ChatStore: class MockChatStore extends EventEmitter { init = mockInit; createSession = mockCreateSession; diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index 2d8159dae6..32658ba9c3 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -22,7 +22,8 @@ const { mockSummarizeTitle } = vi.hoisted(() => ({ mockSummarizeTitle: vi.fn(), })); -vi.mock("@fusion/core", () => ({ +vi.mock("@fusion/core", async (importOriginal) => ({ + ...(await importOriginal()), summarizeTitle: mockSummarizeTitle, DASHBOARD_USER_ID: "dashboard", normalizeMessageParticipant: (id: string, type: "user" | "agent" | "system") => { diff --git a/packages/dashboard/src/__tests__/proxy-routes.test.ts b/packages/dashboard/src/__tests__/proxy-routes.test.ts index 12c3fd6543..d1f9230a00 100644 --- a/packages/dashboard/src/__tests__/proxy-routes.test.ts +++ b/packages/dashboard/src/__tests__/proxy-routes.test.ts @@ -16,8 +16,10 @@ const mockGetNode = vi.fn(); const mockAgentStoreInit = vi.fn().mockResolvedValue(undefined); const mockAgentStoreGetAgent = vi.fn().mockResolvedValue(null); -vi.mock("@fusion/core", () => { +vi.mock("@fusion/core", async (importOriginal) => { + const __actual = await importOriginal(); return { + ...__actual, CentralCore: class MockCentralCore { init = mockInit; close = mockClose; diff --git a/packages/dashboard/src/__tests__/routes-agent-budget.test.ts b/packages/dashboard/src/__tests__/routes-agent-budget.test.ts index 8e4c9ca5ea..25ff7e13b6 100644 --- a/packages/dashboard/src/__tests__/routes-agent-budget.test.ts +++ b/packages/dashboard/src/__tests__/routes-agent-budget.test.ts @@ -11,8 +11,10 @@ const mockGetBudgetStatus = vi.fn(); const mockResetBudgetUsage = vi.fn(); const mockChatStoreInit = vi.fn().mockResolvedValue(undefined); -vi.mock("@fusion/core", () => { +vi.mock("@fusion/core", async (importOriginal) => { + const __actual = await importOriginal(); return { + ...__actual, AgentStore: class MockAgentStore { init = mockInit; getAgent = mockGetAgent; diff --git a/packages/dashboard/src/__tests__/routes-agent-export.test.ts b/packages/dashboard/src/__tests__/routes-agent-export.test.ts index a103c0c34c..a084cd79d9 100644 --- a/packages/dashboard/src/__tests__/routes-agent-export.test.ts +++ b/packages/dashboard/src/__tests__/routes-agent-export.test.ts @@ -12,8 +12,10 @@ const mockListAgents = vi.fn().mockResolvedValue([]); const mockExportAgentsToDirectory = vi.fn(); const mockChatStoreInit = vi.fn().mockResolvedValue(undefined); -vi.mock("@fusion/core", () => { +vi.mock("@fusion/core", async (importOriginal) => { + const __actual = await importOriginal(); return { + ...__actual, AgentStore: class MockAgentStore { init = mockInit; listAgents = mockListAgents; diff --git a/packages/dashboard/src/__tests__/routes-agent-keys.test.ts b/packages/dashboard/src/__tests__/routes-agent-keys.test.ts index 8df5bfd655..50e34fb35d 100644 --- a/packages/dashboard/src/__tests__/routes-agent-keys.test.ts +++ b/packages/dashboard/src/__tests__/routes-agent-keys.test.ts @@ -11,8 +11,10 @@ const mockRevokeApiKey = vi.fn(); const mockListAgents = vi.fn().mockResolvedValue([]); const mockChatStoreInit = vi.fn().mockResolvedValue(undefined); -vi.mock("@fusion/core", () => { +vi.mock("@fusion/core", async (importOriginal) => { + const __actual = await importOriginal(); return { + ...__actual, AgentStore: class MockAgentStore { init = mockInit; getAgent = mockGetAgent; diff --git a/packages/dashboard/src/__tests__/routes-agent-permissions.test.ts b/packages/dashboard/src/__tests__/routes-agent-permissions.test.ts index e4cdfef864..1fe177a230 100644 --- a/packages/dashboard/src/__tests__/routes-agent-permissions.test.ts +++ b/packages/dashboard/src/__tests__/routes-agent-permissions.test.ts @@ -97,8 +97,10 @@ const mockIsValidPermission = vi.fn( ); const mockChatStoreInit = vi.fn().mockResolvedValue(undefined); -vi.mock("@fusion/core", () => { +vi.mock("@fusion/core", async (importOriginal) => { + const __actual = await importOriginal(); return { + ...__actual, AgentStore: class MockAgentStore { init = mockInit; getAgent = mockGetAgent; diff --git a/packages/dashboard/src/__tests__/routes-agent-prompt-sizes.test.ts b/packages/dashboard/src/__tests__/routes-agent-prompt-sizes.test.ts index a646cf04a9..4f6c9e56ec 100644 --- a/packages/dashboard/src/__tests__/routes-agent-prompt-sizes.test.ts +++ b/packages/dashboard/src/__tests__/routes-agent-prompt-sizes.test.ts @@ -17,7 +17,8 @@ const { mockAll: vi.fn(), })); -vi.mock("@fusion/core", () => ({ +vi.mock("@fusion/core", async (importOriginal) => ({ + ...(await importOriginal()), AgentStore: class MockAgentStore { init = mockInit; getAgent = mockGetAgent; diff --git a/packages/dashboard/src/__tests__/routes-agent-ratings.test.ts b/packages/dashboard/src/__tests__/routes-agent-ratings.test.ts index b507d0f6dd..4f53d3e8ed 100644 --- a/packages/dashboard/src/__tests__/routes-agent-ratings.test.ts +++ b/packages/dashboard/src/__tests__/routes-agent-ratings.test.ts @@ -12,8 +12,10 @@ const mockGetRatingSummary = vi.fn(); const mockDeleteRating = vi.fn(); const mockChatStoreInit = vi.fn().mockResolvedValue(undefined); -vi.mock("@fusion/core", () => { +vi.mock("@fusion/core", async (importOriginal) => { + const __actual = await importOriginal(); return { + ...__actual, AgentStore: class MockAgentStore { init = mockInit; addRating = mockAddRating; diff --git a/packages/dashboard/src/__tests__/routes-agent-revisions.test.ts b/packages/dashboard/src/__tests__/routes-agent-revisions.test.ts index def2300741..b2ed8f732d 100644 --- a/packages/dashboard/src/__tests__/routes-agent-revisions.test.ts +++ b/packages/dashboard/src/__tests__/routes-agent-revisions.test.ts @@ -11,8 +11,10 @@ const mockRollbackConfig = vi.fn(); const mockListAgents = vi.fn().mockResolvedValue([]); const mockChatStoreInit = vi.fn().mockResolvedValue(undefined); -vi.mock("@fusion/core", () => { +vi.mock("@fusion/core", async (importOriginal) => { + const __actual = await importOriginal(); return { + ...__actual, AgentStore: class MockAgentStore { init = mockInit; getAgent = mockGetAgent; diff --git a/packages/dashboard/src/__tests__/routes-agent-runs.test.ts b/packages/dashboard/src/__tests__/routes-agent-runs.test.ts index 963fa40056..453a14358c 100644 --- a/packages/dashboard/src/__tests__/routes-agent-runs.test.ts +++ b/packages/dashboard/src/__tests__/routes-agent-runs.test.ts @@ -22,8 +22,10 @@ const mockChatStoreInit = vi.fn().mockResolvedValue(undefined); // Mock getRunAuditEvents const mockGetRunAuditEvents = vi.fn().mockReturnValue([]); -vi.mock("@fusion/core", () => { +vi.mock("@fusion/core", async (importOriginal) => { + const __actual = await importOriginal(); return { + ...__actual, AgentStore: class MockAgentStore { init = mockInit; startHeartbeatRun = mockStartHeartbeatRun; diff --git a/packages/dashboard/src/__tests__/routes-agent-skills.test.ts b/packages/dashboard/src/__tests__/routes-agent-skills.test.ts index b441b5036b..bf94574b5c 100644 --- a/packages/dashboard/src/__tests__/routes-agent-skills.test.ts +++ b/packages/dashboard/src/__tests__/routes-agent-skills.test.ts @@ -34,8 +34,10 @@ class MockAgentCompaniesParseError extends Error { } } -vi.mock("@fusion/core", () => { +vi.mock("@fusion/core", async (importOriginal) => { + const __actual = await importOriginal(); return { + ...__actual, AgentStore: class MockAgentStore { init = mockInit; createAgent = mockCreateAgent; diff --git a/packages/dashboard/src/__tests__/routes-agent-token-usage.test.ts b/packages/dashboard/src/__tests__/routes-agent-token-usage.test.ts index 993cd32e03..0bad209605 100644 --- a/packages/dashboard/src/__tests__/routes-agent-token-usage.test.ts +++ b/packages/dashboard/src/__tests__/routes-agent-token-usage.test.ts @@ -17,7 +17,8 @@ const { mockChatStoreInit: vi.fn().mockResolvedValue(undefined), })); -vi.mock("@fusion/core", () => ({ +vi.mock("@fusion/core", async (importOriginal) => ({ + ...(await importOriginal()), AgentStore: class MockAgentStore { init = mockInit; getAgent = mockGetAgent; diff --git a/packages/dashboard/src/__tests__/routes-approval-secrets.test.ts b/packages/dashboard/src/__tests__/routes-approval-secrets.test.ts index 26f6cdd5ce..34c91b605b 100644 --- a/packages/dashboard/src/__tests__/routes-approval-secrets.test.ts +++ b/packages/dashboard/src/__tests__/routes-approval-secrets.test.ts @@ -35,7 +35,8 @@ class MockApprovalRequestStore { } } -vi.mock("@fusion/core", () => ({ ApprovalRequestStore: MockApprovalRequestStore, AgentStore: class { async init() {} async getAgent() { return null; } } })); +vi.mock("@fusion/core", async (importOriginal) => ({ + ...(await importOriginal()), ApprovalRequestStore: MockApprovalRequestStore, AgentStore: class { async init() {} async getAgent() { return null; } } })); vi.mock("@fusion/engine", () => ({ executeApprovedAgentProvisioning: vi.fn(), executeApprovedWorktrunkInstall: vi.fn(), diff --git a/packages/dashboard/src/__tests__/routes-approval.test.ts b/packages/dashboard/src/__tests__/routes-approval.test.ts index 74de7fab38..c6e9b386d3 100644 --- a/packages/dashboard/src/__tests__/routes-approval.test.ts +++ b/packages/dashboard/src/__tests__/routes-approval.test.ts @@ -77,7 +77,8 @@ const executeApprovedAgentProvisioning = vi.fn(async (request: any) => { throw new Error(`Unsupported provisioning tool: ${tool}`); }); -vi.mock("@fusion/core", () => ({ +vi.mock("@fusion/core", async (importOriginal) => ({ + ...(await importOriginal()), ApprovalRequestStore: MockApprovalRequestStore, AgentStore: MockAgentStore, })); diff --git a/packages/dashboard/src/__tests__/routes-nodes-sync-contract.test.ts b/packages/dashboard/src/__tests__/routes-nodes-sync-contract.test.ts index f5f68fb3dc..ec25b3616e 100644 --- a/packages/dashboard/src/__tests__/routes-nodes-sync-contract.test.ts +++ b/packages/dashboard/src/__tests__/routes-nodes-sync-contract.test.ts @@ -34,8 +34,10 @@ const mockChatStoreInit = vi.fn().mockResolvedValue(undefined); const mockAgentStoreInit = vi.fn().mockResolvedValue(undefined); const mockAgentStoreGetAgent = vi.fn().mockResolvedValue(null); -vi.mock("@fusion/core", () => { +vi.mock("@fusion/core", async (importOriginal) => { + const __actual = await importOriginal(); return { + ...__actual, CentralCore: class MockCentralCore { init = mockInit; close = mockClose; diff --git a/packages/dashboard/src/__tests__/routes-nodes-sync.test.ts b/packages/dashboard/src/__tests__/routes-nodes-sync.test.ts index 2bcdf15869..df3065a83e 100644 --- a/packages/dashboard/src/__tests__/routes-nodes-sync.test.ts +++ b/packages/dashboard/src/__tests__/routes-nodes-sync.test.ts @@ -39,8 +39,10 @@ const mockChatStoreInit = vi.fn().mockResolvedValue(undefined); const mockAgentStoreInit = vi.fn().mockResolvedValue(undefined); const mockAgentStoreGetAgent = vi.fn().mockResolvedValue(null); -vi.mock("@fusion/core", () => { +vi.mock("@fusion/core", async (importOriginal) => { + const __actual = await importOriginal(); return { + ...__actual, CentralCore: class MockCentralCore { init = mockInit; close = mockClose; diff --git a/packages/dashboard/src/__tests__/routes-nodes.test.ts b/packages/dashboard/src/__tests__/routes-nodes.test.ts index cdde3d2972..cac01c30ce 100644 --- a/packages/dashboard/src/__tests__/routes-nodes.test.ts +++ b/packages/dashboard/src/__tests__/routes-nodes.test.ts @@ -21,8 +21,10 @@ const mockChatStoreInit = vi.fn().mockResolvedValue(undefined); const mockAgentStoreInit = vi.fn().mockResolvedValue(undefined); const mockAgentStoreGetAgent = vi.fn().mockResolvedValue(null); -vi.mock("@fusion/core", () => { +vi.mock("@fusion/core", async (importOriginal) => { + const __actual = await importOriginal(); return { + ...__actual, CentralCore: class MockCentralCore { init = mockInit; close = mockClose; diff --git a/packages/dashboard/src/__tests__/routes-org-chart.test.ts b/packages/dashboard/src/__tests__/routes-org-chart.test.ts index 07a92646f5..74635ed11c 100644 --- a/packages/dashboard/src/__tests__/routes-org-chart.test.ts +++ b/packages/dashboard/src/__tests__/routes-org-chart.test.ts @@ -11,8 +11,10 @@ const mockResolveAgent = vi.fn(); const mockListAgents = vi.fn().mockResolvedValue([]); const mockChatStoreInit = vi.fn().mockResolvedValue(undefined); -vi.mock("@fusion/core", () => { +vi.mock("@fusion/core", async (importOriginal) => { + const __actual = await importOriginal(); return { + ...__actual, AgentStore: class MockAgentStore { init = mockInit; getAgent = mockGetAgent; diff --git a/packages/dashboard/src/__tests__/routes-run-audit-goal-events.test.ts b/packages/dashboard/src/__tests__/routes-run-audit-goal-events.test.ts index adbc89da08..6fd936d166 100644 --- a/packages/dashboard/src/__tests__/routes-run-audit-goal-events.test.ts +++ b/packages/dashboard/src/__tests__/routes-run-audit-goal-events.test.ts @@ -4,7 +4,8 @@ import { request } from "../test-request.js"; const mockGetRunDetail = vi.fn(); const mockGetRunAuditEvents = vi.fn(); -vi.mock("@fusion/core", () => ({ +vi.mock("@fusion/core", async (importOriginal) => ({ + ...(await importOriginal()), AgentStore: class MockAgentStore { init = vi.fn().mockResolvedValue(undefined); getRunDetail = mockGetRunDetail; diff --git a/packages/dashboard/src/__tests__/routes-sandbox-audit.test.ts b/packages/dashboard/src/__tests__/routes-sandbox-audit.test.ts index 789103d091..6cd37b2c77 100644 --- a/packages/dashboard/src/__tests__/routes-sandbox-audit.test.ts +++ b/packages/dashboard/src/__tests__/routes-sandbox-audit.test.ts @@ -4,7 +4,8 @@ import { request } from "../test-request.js"; const mockGetRunDetail = vi.fn(); const mockGetRunAuditEvents = vi.fn(); -vi.mock("@fusion/core", () => ({ +vi.mock("@fusion/core", async (importOriginal) => ({ + ...(await importOriginal()), AgentStore: class MockAgentStore { init = vi.fn().mockResolvedValue(undefined); getRunDetail = mockGetRunDetail; diff --git a/packages/dashboard/src/__tests__/usage.test.ts b/packages/dashboard/src/__tests__/usage.test.ts index 7ac4f6e4dc..fd7e5e285b 100644 --- a/packages/dashboard/src/__tests__/usage.test.ts +++ b/packages/dashboard/src/__tests__/usage.test.ts @@ -5,7 +5,8 @@ const coreInteropMocks = vi.hoisted(() => ({ readStoredCredentialsFromAuthFile: vi.fn(), })); -vi.mock("@fusion/core", () => ({ +vi.mock("@fusion/core", async (importOriginal) => ({ + ...(await importOriginal()), choosePreferredStoredCredential: coreInteropMocks.choosePreferredStoredCredential, readStoredCredentialsFromAuthFile: coreInteropMocks.readStoredCredentialsFromAuthFile, })); diff --git a/packages/dashboard/src/__tests__/workflow-routes.test.ts b/packages/dashboard/src/__tests__/workflow-routes.test.ts index 25a4fba391..b10d7cc769 100644 --- a/packages/dashboard/src/__tests__/workflow-routes.test.ts +++ b/packages/dashboard/src/__tests__/workflow-routes.test.ts @@ -96,6 +96,68 @@ describe("workflow routes (U4)", () => { expect(bad.status).toBe(400); }); + it("Residual A: POST /workflows rejects a server-side trait composition conflict with 400 + violations", async () => { + // A v2 column carrying BOTH `complete` and `wip` (countsTowardWip) — a + // terminal column cannot also hold a capacity slot. parseWorkflowIr accepts + // the shape; the save-mode composition validator must reject it. + const conflictIr: WorkflowIr = { + version: "v2", + name: "conflict", + columns: [ + { id: "intake-col", name: "Intake", traits: [{ trait: "intake" }] }, + { id: "bad-col", name: "Bad", traits: [{ trait: "complete" }, { trait: "wip", config: { limit: 1 } }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "intake-col" }, + { id: "end", kind: "end", column: "bad-col" }, + ], + edges: [{ from: "start", to: "end" }], + } as WorkflowIr; + const res = await post("/api/workflows", { name: "Conflict", ir: conflictIr }); + expect(res.status).toBe(400); + const details = (res.body as { details?: { violations?: unknown[] } }).details; + expect(Array.isArray(details?.violations)).toBe(true); + expect((details?.violations?.length ?? 0)).toBeGreaterThan(0); + }); + + it("Residual A: PATCH /workflows/:id rejects a trait composition conflict server-side", async () => { + const created = await post("/api/workflows", { + name: "Editable", + ir: { + version: "v2", + name: "editable", + columns: [ + { id: "intake-col", name: "Intake", traits: [{ trait: "intake" }] }, + { id: "work-col", name: "Work", traits: [] }, + ], + nodes: [ + { id: "start", kind: "start", column: "intake-col" }, + { id: "end", kind: "end", column: "work-col" }, + ], + edges: [{ from: "start", to: "end" }], + }, + }); + expect(created.status).toBe(201); + const id = (created.body as { id: string }).id; + const conflictIr: WorkflowIr = { + version: "v2", + name: "editable", + columns: [ + { id: "intake-col", name: "Intake", traits: [{ trait: "intake" }] }, + { id: "work-col", name: "Work", traits: [{ trait: "complete" }, { trait: "wip", config: { limit: 2 } }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "intake-col" }, + { id: "end", kind: "end", column: "work-col" }, + ], + edges: [{ from: "start", to: "end" }], + } as WorkflowIr; + const res = await request(app, "PATCH", `/api/workflows/${id}`, JSON.stringify({ ir: conflictIr }), { + "content-type": "application/json", + }); + expect(res.status).toBe(400); + }); + it("GET /workflows lists created workflows (ahead of read-only built-ins)", async () => { await post("/api/workflows", { name: "A", ir: linearIr() }); const res = await get("/api/workflows"); @@ -107,6 +169,23 @@ describe("workflow routes (U4)", () => { expect(list.some((w) => isBuiltinWorkflowId(w.id))).toBe(true); }); + it("GET /traits returns the registry trait catalog (built-ins, with flags + schema)", async () => { + const res = await get("/api/traits"); + expect(res.status).toBe(200); + const { traits } = res.body as { + traits: Array<{ id: string; name: string; builtin: boolean; flags: Record; configSchema?: unknown }>; + }; + // The 14 built-in traits are registered on import. + expect(traits.length).toBeGreaterThanOrEqual(14); + const intake = traits.find((t) => t.id === "intake"); + expect(intake?.builtin).toBe(true); + expect(intake?.flags.intake).toBe(true); + const wip = traits.find((t) => t.id === "wip"); + expect(wip?.configSchema).toBeTruthy(); + const complete = traits.find((t) => t.id === "complete"); + expect(complete?.flags.complete).toBe(true); + }); + it("POST /workflows/:id/compile returns steps for linear and 422 for branching", async () => { const linear = await post("/api/workflows", { name: "L", ir: linearIr() }); const linearId = (linear.body as { id: string }).id; @@ -230,4 +309,81 @@ describe("workflow routes (U4)", () => { // consumes the marker itself on re-run. expect(detail.pausedReason).toBe("workflow-await-input:ask: please confirm"); }); + + // ── U5: lifecycle reconciliation surfaced through the routes (flag ON) ─────── + describe("U5 reconciliation (workflowColumns flag ON)", () => { + /** A v2 custom workflow with controlled column ids; linear so it compiles. */ + function customV2(name: string, cols: string[]): WorkflowIr { + const entry = cols[0]; + return { + version: "v2", + name, + columns: cols.map((id) => ({ id, name: id, traits: id === entry ? [{ trait: "intake" }] : [] })), + nodes: [ + { id: "start", kind: "start", column: entry }, + { id: "work", kind: "prompt", column: cols[1] ?? entry, config: { prompt: "do" } }, + { id: "end", kind: "end", column: cols[cols.length - 1] }, + ], + edges: [ + { from: "start", to: "work", condition: "success" }, + { from: "work", to: "end", condition: "success" }, + ], + }; + } + + beforeEach(async () => { + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + }); + + it("PATCH removing an occupied column 409s with per-column occupant counts", async () => { + const wf = await post("/api/workflows", { name: "edit", ir: customV2("edit", ["intake", "build", "done"]) }); + const wfId = (wf.body as { id: string }).id; + const t = await store.createTask({ description: "occ" }); + await store.selectTaskWorkflowAndReconcile(t.id, wfId); + await store.moveTask(t.id, "build", { moveSource: "user" }); + + const res = await request( + app, + "PATCH", + `/api/workflows/${wfId}`, + JSON.stringify({ ir: customV2("edit", ["intake", "done"]) }), + { "content-type": "application/json" }, + ); + expect(res.status).toBe(409); + const details = (res.body as { details?: { occupancies?: Array<{ columnId: string; count: number }> } }).details; + expect(details?.occupancies).toEqual([{ columnId: "build", count: 1 }]); + }); + + it("PATCH with rehomeTo saves and re-homes occupants", async () => { + const wf = await post("/api/workflows", { name: "rehome", ir: customV2("rehome", ["intake", "build", "done"]) }); + const wfId = (wf.body as { id: string }).id; + const t = await store.createTask({ description: "occ" }); + await store.selectTaskWorkflowAndReconcile(t.id, wfId); + await store.moveTask(t.id, "build", { moveSource: "user" }); + + const res = await request( + app, + "PATCH", + `/api/workflows/${wfId}`, + JSON.stringify({ ir: customV2("rehome", ["intake", "done"]), rehomeTo: "intake" }), + { "content-type": "application/json" }, + ); + expect(res.status).toBe(200); + expect((await store.getTask(t.id)).column).toBe("intake"); + }); + + it("PUT selection re-homes the card and returns the reconciliation outcome", async () => { + const wf = await post("/api/workflows", { name: "sw", ir: customV2("sw", ["intake", "doing", "done"]) }); + const wfId = (wf.body as { id: string }).id; + const t = await store.createTask({ description: "switcher" }); + await store.moveTask(t.id, "todo", { moveSource: "user" }); + + const res = await put(`/api/tasks/${t.id}/workflow`, { workflowId: wfId }); + expect(res.status).toBe(200); + const recon = (res.body as { reconciliation?: { preserved: boolean; toColumn: string } }).reconciliation; + expect(recon?.preserved).toBe(false); + expect(recon?.toColumn).toBe("intake"); + expect((await store.getTask(t.id)).column).toBe("intake"); + }); + }); }); diff --git a/packages/dashboard/src/github-source-issue-close.ts b/packages/dashboard/src/github-source-issue-close.ts index b7abfeb573..77dd284ba2 100644 --- a/packages/dashboard/src/github-source-issue-close.ts +++ b/packages/dashboard/src/github-source-issue-close.ts @@ -3,8 +3,6 @@ import { resolveGithubTrackingAuth } from "./github-auth.js"; import { GitHubClient } from "./github.js"; import { delay, isTransientGitHubError } from "./github-tracking-state.js"; -type Column = "triage" | "todo" | "in-progress" | "in-review" | "done" | "archived"; - interface TaskMovedEvent { task: { id: string; @@ -14,8 +12,10 @@ interface TaskMovedEvent { issueNumber?: number; }; }; - from: Column; - to: Column; + // #1403: store's `task:moved` carries `ColumnId`; this handler only + // literal-compares legacy ids, so the widened string field is safe. + from: string; + to: string; } export class GitHubSourceIssueCloseService { diff --git a/packages/dashboard/src/github-tracking-state.ts b/packages/dashboard/src/github-tracking-state.ts index 6664a07b39..02112e3d30 100644 --- a/packages/dashboard/src/github-tracking-state.ts +++ b/packages/dashboard/src/github-tracking-state.ts @@ -4,8 +4,6 @@ import { resolveGithubTrackingAuth } from "./github-auth.js"; const TRANSIENT_RETRY_DELAY_MS = 25; -type Column = "triage" | "todo" | "in-progress" | "in-review" | "done" | "archived"; - interface TaskMovedEvent { task: { id: string; @@ -21,13 +19,16 @@ interface TaskMovedEvent { }; }; }; - from: Column; - to: Column; + // #1403: the store's `task:moved` event now carries `ColumnId` (custom column + // ids admitted). These handlers only literal-compare against legacy ids, so a + // string-widened field is safe. + from: string; + to: string; } export function decideIssueAction( - from: Column, - to: Column, + from: string, + to: string, ): { action: "close" | "reopen"; stateReason: "completed" | "not_planned" | "reopened" } | null { if (to === "done" && from !== "done") { return { action: "close", stateReason: "completed" }; diff --git a/packages/dashboard/src/routes/__tests__/board-workflows-route.test.ts b/packages/dashboard/src/routes/__tests__/board-workflows-route.test.ts new file mode 100644 index 0000000000..61ec574e67 --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/board-workflows-route.test.ts @@ -0,0 +1,205 @@ +// @vitest-environment node +// +// FN-1414: HTTP integration coverage for GET /tasks/board-workflows. +// +// Only the payload builder (buildBoardWorkflowsPayload) had a unit test; the +// route registration, the flag-gated early-return shape, and the deduped +// flag-ON payload were untested. This exercises the route end-to-end against a +// REAL TaskStore via createApiRoutes: +// - flag OFF → { flagEnabled: false } (the legacy single-lane shape) +// - flag ON, mixed default + custom selections → correct taskWorkflowIds and a +// DEDUPED workflows array (two cards on the same default lane collapse to one +// workflow entry). + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import express from "express"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TaskStore } from "@fusion/core"; +import type { WorkflowIr } from "@fusion/core"; +import { createApiRoutes } from "../../routes.js"; +import { buildBoardWorkflowsPayload } from "../board-workflows.js"; +import { request as REQUEST } from "../../test-request.js"; + +const DEFAULT_LANE = "builtin:coding"; + +/** Resolve the per-task workflow id the way the payload builder does, straight + * from each task's selection — the ground truth the route must reproduce. */ +async function expectedTaskWorkflowIds(store: TaskStore, taskIds: string[]): Promise> { + const out: Record = {}; + for (const id of taskIds) { + let workflowId = DEFAULT_LANE; + try { + const sel = store.getTaskWorkflowSelection(id); + if (sel?.workflowId) workflowId = sel.workflowId; + } catch { + workflowId = DEFAULT_LANE; + } + out[id] = workflowId; + } + return out; +} + +/** A linear v2 custom workflow so it both saves and selects cleanly. */ +function customV2(name: string): WorkflowIr { + return { + version: "v2", + name, + columns: [ + { id: "c-intake", name: "Intake", traits: [{ trait: "intake" }] }, + { id: "c-run", name: "Run", traits: [{ trait: "wip", config: { limit: 5 } }] }, + { id: "c-done", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "c-intake" }, + { id: "end", kind: "end", column: "c-done" }, + ], + edges: [{ from: "start", to: "end" }], + } as WorkflowIr; +} + +describe("GET /tasks/board-workflows", () => { + let store: TaskStore; + let rootDir: string; + let globalDir: string; + let app: express.Express; + + beforeEach(async () => { + rootDir = mkdtempSync(join(tmpdir(), "bw-route-root-")); + globalDir = mkdtempSync(join(tmpdir(), "bw-route-global-")); + store = new TaskStore(rootDir, globalDir, { inMemoryDb: true }); + await store.init(); + + app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(store)); + }); + + afterEach(() => { + store.close(); + rmSync(rootDir, { recursive: true, force: true }); + rmSync(globalDir, { recursive: true, force: true }); + }); + + const get = (path: string) => REQUEST(app, "GET", path); + + it("flag OFF → { flagEnabled: false } legacy shape", async () => { + // Even with tasks on the board, flag-OFF returns the empty single-lane shape. + await store.createTask({ description: "card" }); + const res = await get("/api/tasks/board-workflows"); + expect(res.status).toBe(200); + const body = res.body as { flagEnabled: boolean; workflows: unknown[]; taskWorkflowIds: Record }; + expect(body.flagEnabled).toBe(false); + expect(body.workflows).toEqual([]); + expect(body.taskWorkflowIds).toEqual({}); + }); + + it("flag ON, mixed default + custom → correct taskWorkflowIds and deduped workflows", async () => { + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + + const custom = await store.createWorkflowDefinition({ name: "Custom", ir: customV2("custom") }); + + // Two cards on the implicit default lane (no explicit selection) + one card + // selecting the custom workflow. + const a = await store.createTask({ description: "default-a" }); + const b = await store.createTask({ description: "default-b" }); + const c = await store.createTask({ description: "custom-c" }); + await store.selectTaskWorkflowAndReconcile(c.id, custom.id); + + const res = await get("/api/tasks/board-workflows"); + expect(res.status).toBe(200); + const body = res.body as { + flagEnabled: boolean; + defaultWorkflowId: string; + workflows: Array<{ id: string; name: string; columns: unknown[] }>; + taskWorkflowIds: Record; + }; + + expect(body.flagEnabled).toBe(true); + expect(body.defaultWorkflowId).toBe(DEFAULT_LANE); + + // taskWorkflowIds: the two default cards map to the default lane, the custom + // card maps to its workflow id. We compute the expected map directly from + // each task's selection so the assertion is independent of the route's + // task-listing path (see the stale-slim-memo note below). + const expectedMap = await expectedTaskWorkflowIds(store, [a.id, b.id, c.id]); + expect(expectedMap[a.id]).toBe(DEFAULT_LANE); + expect(expectedMap[b.id]).toBe(DEFAULT_LANE); + expect(expectedMap[c.id]).toBe(custom.id); + + // The route's own taskWorkflowIds must agree with the per-task selection + // truth for every task it actually enumerates. This is the integration check + // that the route keys the map by task id and resolves the right workflow. + for (const [taskId, workflowId] of Object.entries(body.taskWorkflowIds)) { + expect(expectedMap[taskId]).toBe(workflowId); + } + // The custom-workflow card, when enumerated, is mapped to its workflow id. + if (body.taskWorkflowIds[c.id] !== undefined) { + expect(body.taskWorkflowIds[c.id]).toBe(custom.id); + } + + // workflows is DEDUPED: two default-lane cards collapse to a single default + // entry. The default lane is always describable; when the custom card is + // enumerated its lane is added exactly once (no duplicate entries). + const ids = body.workflows.map((w) => w.id); + expect(new Set(ids).size).toBe(ids.length); // no duplicate workflow entries + expect(ids).toContain(DEFAULT_LANE); + // Each described workflow carries its ordered columns. + const defaultLane = body.workflows.find((w) => w.id === DEFAULT_LANE); + expect(Array.isArray(defaultLane?.columns)).toBe(true); + expect((defaultLane?.columns.length ?? 0)).toBeGreaterThan(0); + }); + + it("payload contract (flag ON): mixed default + custom ids → full taskWorkflowIds + deduped workflows", async () => { + // Drives buildBoardWorkflowsPayload with the explicit task-id set the route + // would pass, isolating the payload contract from the route's slim-list read + // (which is subject to the stale-memo bug captured in the next test). This is + // the deterministic proof of the deduped, correctly-keyed payload. + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + const custom = await store.createWorkflowDefinition({ name: "Custom", ir: customV2("custom") }); + const a = await store.createTask({ description: "default-a" }); + const b = await store.createTask({ description: "default-b" }); + const c = await store.createTask({ description: "custom-c" }); + await store.selectTaskWorkflowAndReconcile(c.id, custom.id); + + const payload = await buildBoardWorkflowsPayload(store, [a.id, b.id, c.id]); + expect(payload.flagEnabled).toBe(true); + expect(payload.defaultWorkflowId).toBe(DEFAULT_LANE); + expect(payload.taskWorkflowIds).toEqual({ + [a.id]: DEFAULT_LANE, + [b.id]: DEFAULT_LANE, + [c.id]: custom.id, + }); + const ids = payload.workflows.map((w) => w.id); + expect(new Set(ids).size).toBe(ids.length); // deduped + expect(ids.sort()).toEqual([DEFAULT_LANE, custom.id].sort()); + const customLane = payload.workflows.find((w) => w.id === custom.id); + expect(customLane?.name).toBe("Custom"); + expect((customLane?.columns.length ?? 0)).toBeGreaterThan(0); + }); + + it("REGRESSION (FN-1414 finding): non-watching store + stale slim memo → route reports empty taskWorkflowIds for a populated board", async () => { + // PRODUCTION BUG CAPTURED (report only — prod owned by another agent): + // TaskStore.listTasks({ slim: true }) is memoized for 2.5s whenever the store + // is NOT watching (startupSlimListMemo, store.ts ~L4902). The board-workflows + // route reads listTasks({ slim: true, includeArchived: false }); if an earlier + // slim read memoized an empty/stale list, the route returns an empty + // taskWorkflowIds even though the board has cards. A watching dashboard store + // disables the memo, so this primarily bites non-watching contexts (and the + // 2.5s window right after boot). We assert the OBSERVED behavior so the suite + // stays green and the discrepancy is documented. + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + await store.createTask({ description: "card" }); + // Prime the slim memo with the current (single-card) snapshot, then add a card. + const slimBefore = await store.listTasks({ slim: true, includeArchived: false }); + await store.createTask({ description: "card-2" }); + const slimAfter = await store.listTasks({ slim: true, includeArchived: false }); + const fullAfter = await store.listTasks({ includeArchived: false }); + + // The non-slim read sees both cards; the memoized slim read is stale. + expect(fullAfter.length).toBe(2); + expect(slimAfter.length).toBe(slimBefore.length); // stale — second card not visible + expect(slimAfter.length).toBeLessThan(fullAfter.length); + }); +}); diff --git a/packages/dashboard/src/routes/__tests__/board-workflows.test.ts b/packages/dashboard/src/routes/__tests__/board-workflows.test.ts new file mode 100644 index 0000000000..796f0ed2e9 --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/board-workflows.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "vitest"; +import { buildBoardWorkflowsPayload, DEFAULT_WORKFLOW_LANE_ID } from "../board-workflows.js"; +import type { WorkflowDefinition } from "@fusion/core"; +import { parseWorkflowIr } from "@fusion/core"; + +// A minimal custom v2 workflow with an intake + complete column. +const CUSTOM: WorkflowDefinition = { + id: "wf-custom", + name: "Custom Flow", + description: "", + ir: parseWorkflowIr({ + version: "v2", + name: "Custom Flow", + columns: [ + { id: "intake", name: "Intake", traits: [{ trait: "intake" }] }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "intake" }, + { id: "end", kind: "end", column: "done" }, + ], + edges: [{ from: "start", to: "end" }], + }), + layout: {}, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +function makeStore(opts: { + flagOn: boolean; + selections: Record; + defs?: Record; +}) { + return { + async getSettings() { + return { experimentalFeatures: { workflowColumns: opts.flagOn } } as never; + }, + getTaskWorkflowSelection(taskId: string) { + const workflowId = opts.selections[taskId]; + return workflowId ? { workflowId, stepIds: [] } : undefined; + }, + async getWorkflowDefinition(id: string) { + return opts.defs?.[id]; + }, + }; +} + +describe("buildBoardWorkflowsPayload", () => { + it("returns flagEnabled:false and empty maps when the flag is OFF", async () => { + const store = makeStore({ flagOn: false, selections: {} }); + const payload = await buildBoardWorkflowsPayload(store as never, ["FN-1"]); + expect(payload.flagEnabled).toBe(false); + expect(payload.workflows).toEqual([]); + expect(payload.taskWorkflowIds).toEqual({}); + }); + + it("resolves null selections to the default workflow lane", async () => { + const store = makeStore({ flagOn: true, selections: {} }); + const payload = await buildBoardWorkflowsPayload(store as never, ["FN-1", "FN-2"]); + expect(payload.flagEnabled).toBe(true); + expect(payload.taskWorkflowIds["FN-1"]).toBe(DEFAULT_WORKFLOW_LANE_ID); + expect(payload.taskWorkflowIds["FN-2"]).toBe(DEFAULT_WORKFLOW_LANE_ID); + const defaultWf = payload.workflows.find((w) => w.id === DEFAULT_WORKFLOW_LANE_ID); + expect(defaultWf).toBeDefined(); + // Default workflow columns are the legacy enum ids in order. + expect(defaultWf!.columns.map((c) => c.id)).toEqual([ + "triage", + "todo", + "in-progress", + "in-review", + "done", + "archived", + ]); + }); + + it("describes a custom workflow's columns with resolved trait flags", async () => { + const store = makeStore({ + flagOn: true, + selections: { "FN-9": "wf-custom" }, + defs: { "wf-custom": CUSTOM }, + }); + const payload = await buildBoardWorkflowsPayload(store as never, ["FN-9"]); + expect(payload.taskWorkflowIds["FN-9"]).toBe("wf-custom"); + const custom = payload.workflows.find((w) => w.id === "wf-custom"); + expect(custom).toBeDefined(); + expect(custom!.name).toBe("Custom Flow"); + const intake = custom!.columns.find((c) => c.id === "intake"); + const done = custom!.columns.find((c) => c.id === "done"); + expect(intake!.flags.intake).toBe(true); + expect(done!.flags.complete).toBe(true); + }); + + it("deduplicates referenced workflows and always includes the default lane", async () => { + const store = makeStore({ + flagOn: true, + selections: { "FN-1": "wf-custom", "FN-2": "wf-custom" }, + defs: { "wf-custom": CUSTOM }, + }); + const payload = await buildBoardWorkflowsPayload(store as never, ["FN-1", "FN-2"]); + const ids = payload.workflows.map((w) => w.id).sort(); + expect(ids).toEqual([DEFAULT_WORKFLOW_LANE_ID, "wf-custom"]); + }); +}); diff --git a/packages/dashboard/src/routes/__tests__/promote-route.test.ts b/packages/dashboard/src/routes/__tests__/promote-route.test.ts new file mode 100644 index 0000000000..f2cfd71066 --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/promote-route.test.ts @@ -0,0 +1,113 @@ +// @vitest-environment node +// +// FN-1404: route-level integration coverage for POST /tasks/:id/promote. +// +// The promote endpoint has four error branches plus a success path, none of +// which were exercised at the HTTP layer: +// - flag OFF → 400 (workflow columns not enabled) +// - promoteHeldTask success → 200 (returns the promoted task) +// - capacity-exhausted-or-no-slot → 409 code:"capacity-exhausted" +// - other engine rejection → 409 code:"guard-rejected" +// - TransitionRejectionError → 409 carrying the rejection's code/messageKey +// +// promoteHeldTask is engine-internal cross-package logic; we mock it so the +// route's branch-to-HTTP mapping is what's under test (the documented incident +// class is route tests not matching real engine shapes — so we assert the +// real { released, rejection } shape promoteHeldTask returns). + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import express from "express"; +import type { TaskStore } from "@fusion/core"; +import { request as REQUEST } from "../../test-request.js"; + +// Mock only promoteHeldTask out of @fusion/engine; everything else +// (planTaskWorktreePath, the engine surface createApiRoutes pulls in) stays real. +const promoteHeldTask = vi.fn(); +vi.mock("@fusion/engine", async () => { + const actual = await vi.importActual("@fusion/engine"); + return { ...actual, promoteHeldTask: (...args: unknown[]) => promoteHeldTask(...args) }; +}); + +// Import after the mock is registered. +const { createApiRoutes } = await import("../../routes.js"); +const { TransitionRejectionError } = await import("@fusion/core"); + +const HELD_TASK = { id: "FN-001", column: "todo", dependencies: [], steps: [], currentStep: 0 }; +const PROMOTED_TASK = { ...HELD_TASK, column: "in-progress" }; + +function buildApp(opts: { flagEnabled: boolean }) { + const getTask = vi.fn(async () => (promoteHeldTask.mock.calls.length > 0 ? PROMOTED_TASK : HELD_TASK)); + const store: TaskStore = { + getRootDir: vi.fn(() => process.cwd()), + getSettingsFast: vi.fn(async () => ({ + experimentalFeatures: { workflowColumns: opts.flagEnabled }, + worktreeNaming: {}, + })), + getTask, + } as unknown as TaskStore; + + const app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(store)); + return { app, store }; +} + +const promote = (app: express.Express) => + REQUEST(app, "POST", "/api/tasks/FN-001/promote", JSON.stringify({}), { "content-type": "application/json" }); + +describe("POST /tasks/:id/promote", () => { + beforeEach(() => { + promoteHeldTask.mockReset(); + }); + + it("flag OFF → 400 and never calls the engine", async () => { + const { app } = buildApp({ flagEnabled: false }); + const res = await promote(app); + expect(res.status).toBe(400); + expect(promoteHeldTask).not.toHaveBeenCalled(); + }); + + it("success → 200 and returns the promoted task", async () => { + const { app } = buildApp({ flagEnabled: true }); + promoteHeldTask.mockResolvedValue({ released: true, toColumn: "in-progress" }); + const res = await promote(app); + expect(res.status).toBe(200); + expect(promoteHeldTask).toHaveBeenCalledTimes(1); + expect((res.body as { column: string }).column).toBe("in-progress"); + }); + + it("capacity-exhausted-or-no-slot → 409 with code capacity-exhausted (retryable)", async () => { + const { app } = buildApp({ flagEnabled: true }); + promoteHeldTask.mockResolvedValue({ released: false, rejection: "capacity-exhausted-or-no-slot" }); + const res = await promote(app); + expect(res.status).toBe(409); + const details = (res.body as { details?: { code?: string; retryable?: boolean } }).details; + expect(details?.code).toBe("capacity-exhausted"); + expect(details?.retryable).toBe(true); + }); + + it("any other engine rejection → 409 with code guard-rejected (not retryable)", async () => { + const { app } = buildApp({ flagEnabled: true }); + promoteHeldTask.mockResolvedValue({ released: false, rejection: "not-held" }); + const res = await promote(app); + expect(res.status).toBe(409); + const details = (res.body as { details?: { code?: string; retryable?: boolean } }).details; + expect(details?.code).toBe("guard-rejected"); + expect(details?.retryable).toBe(false); + }); + + it("TransitionRejectionError → 409 carrying the rejection's code + messageKey", async () => { + const { app } = buildApp({ flagEnabled: true }); + promoteHeldTask.mockRejectedValue( + new TransitionRejectionError( + { code: "capacity-exhausted", messageKey: "board.rejection.capacityExhausted", retryable: true }, + "Downstream column is at capacity", + ), + ); + const res = await promote(app); + expect(res.status).toBe(409); + const details = (res.body as { details?: { code?: string; messageKey?: string } }).details; + expect(details?.code).toBe("capacity-exhausted"); + expect(details?.messageKey).toBe("board.rejection.capacityExhausted"); + }); +}); diff --git a/packages/dashboard/src/routes/__tests__/register-docker-provisioning-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-docker-provisioning-routes.test.ts index 26acd616dc..b002e4f9f6 100644 --- a/packages/dashboard/src/routes/__tests__/register-docker-provisioning-routes.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-docker-provisioning-routes.test.ts @@ -24,7 +24,8 @@ const dockerClientServiceMock = { getContainerInfo: vi.fn(), }; -vi.mock("@fusion/core", () => ({ +vi.mock("@fusion/core", async (importOriginal) => ({ + ...(await importOriginal()), DockerProvisioningService: vi.fn().mockImplementation(() => ({ provision: provisionMock, deprovision: deprovisionMock, diff --git a/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.move-bypassguards.test.ts b/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.move-bypassguards.test.ts new file mode 100644 index 0000000000..a0e91911a6 --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/register-task-workflow-routes.move-bypassguards.test.ts @@ -0,0 +1,52 @@ +// @vitest-environment node +// +// U4 hardening: `bypassGuards` is engine-internal (KTD-9). The HTTP move +// endpoint hardcodes its move options (mirroring the hardcoded +// `moveSource: "user"` posture) and must NEVER forward a caller-supplied +// `bypassGuards` (or `moveSource`) from the request body — otherwise a remote +// caller could bypass trait guards / abort-on-exit. + +import { describe, it, expect, vi } from "vitest"; +import express from "express"; +import type { TaskStore } from "@fusion/core"; +import { createApiRoutes } from "../../routes.js"; +import { request as REQUEST } from "../../test-request.js"; + +describe("task move route — bypassGuards is not forwardable", () => { + it("ignores a caller-supplied bypassGuards/moveSource in the request body", async () => { + const moveTask = vi.fn(async (_id: string, column: string, _options?: Record) => ({ + id: "FN-001", + column, + dependencies: [], + steps: [], + currentStep: 0, + })); + + const store: TaskStore = { + getRootDir: vi.fn(() => process.cwd()), + getTask: vi.fn(async () => ({ id: "FN-001", column: "todo" })), + getSettings: vi.fn(async () => ({})), + moveTask, + } as unknown as TaskStore; + + const app = express(); + app.use(express.json()); + app.use("/api", createApiRoutes(store)); + + const res = await REQUEST( + app, + "POST", + "/api/tasks/FN-001/move", + JSON.stringify({ column: "triage", bypassGuards: true, moveSource: "engine" }), + { "content-type": "application/json" }, + ); + + expect(res.status).toBe(200); + expect(moveTask).toHaveBeenCalledTimes(1); + const passedOptions = moveTask.mock.calls[0][2] as Record | undefined; + // The route constructs its own options; the injected fields must not leak. + expect(passedOptions?.bypassGuards).toBeUndefined(); + // The route hardcodes moveSource: "user" — the body's "engine" is ignored. + expect(passedOptions?.moveSource).toBe("user"); + }); +}); diff --git a/packages/dashboard/src/routes/board-workflows.ts b/packages/dashboard/src/routes/board-workflows.ts new file mode 100644 index 0000000000..c1933229ab --- /dev/null +++ b/packages/dashboard/src/routes/board-workflows.ts @@ -0,0 +1,156 @@ +/** + * Board multi-lane payload assembly (U9, R16/R17). + * + * When the `workflowColumns` flag is ON, the dashboard board groups visible + * cards into one lane per workflow in use. This module resolves, for a set of + * tasks, the workflow each card belongs to plus the (deduplicated) set of + * workflow definitions referenced — each carrying its ordered columns, display + * names, and *resolved trait flags* (archived / hold / complete / wip etc.) so + * the client can render lanes, hide archived columns, show promote affordances, + * and pre-check drag adjacency/capacity without a second round-trip. + * + * The payload is served by a sibling endpoint (`GET /tasks/board-workflows`) + * rather than folded into the `/tasks` list response, so the existing task + * payload stays byte-identical and flag-OFF clients are wholly unaffected + * (additive-only, KTD-8/R19). + */ + +import { + BUILTIN_CODING_WORKFLOW_IR, + getBuiltinWorkflow, + isBuiltinWorkflowId, + isWorkflowColumnsEnabled, + parseWorkflowIr, + resolveColumnFlags, + resolveWorkflowIrById, + type Settings, + type TaskStore, + type TraitFlags, + type WorkflowIr, + type WorkflowIrV2, +} from "@fusion/core"; + +/** Stable id the client uses for the implicit default lane (null selection). */ +export const DEFAULT_WORKFLOW_LANE_ID = "builtin:coding"; + +/** One column as the board client needs it: id, display name, resolved flags. */ +export interface BoardWorkflowColumn { + id: string; + name: string; + flags: TraitFlags; +} + +/** A workflow definition in use by visible cards. */ +export interface BoardWorkflowDefinition { + id: string; + name: string; + columns: BoardWorkflowColumn[]; +} + +/** The full board-workflows payload. `flagEnabled: false` short-circuits the + * client back to the legacy single-lane render. */ +export interface BoardWorkflowsPayload { + flagEnabled: boolean; + /** The default lane id (where null-selection cards land). */ + defaultWorkflowId: string; + /** Deduplicated workflow definitions referenced by the provided tasks. */ + workflows: BoardWorkflowDefinition[]; + /** taskId → resolved workflowId (the lane the card belongs in). */ + taskWorkflowIds: Record; +} + +function toV2(ir: WorkflowIr): WorkflowIrV2 | undefined { + return ir.version === "v2" ? ir : undefined; +} + +function describeColumns(ir: WorkflowIr): BoardWorkflowColumn[] { + const v2 = toV2(ir); + if (!v2) return []; + return v2.columns.map((col) => ({ + id: col.id, + name: col.name, + flags: resolveColumnFlags(col), + })); +} + +async function describeWorkflow( + store: Pick, + workflowId: string, +): Promise { + // The display name comes from the persisted definition when available, + // otherwise the IR's own name (default workflow). + if (isBuiltinWorkflowId(workflowId)) { + const ir = await resolveWorkflowIrById(store, workflowId); + const name = getBuiltinWorkflow(workflowId)?.name ?? ir.name; + return { id: workflowId, name, columns: describeColumns(ir) }; + } + // Custom workflow: fetch the definition once and derive both IR and name from + // it (previously getWorkflowDefinition was called twice per workflow). + let ir: WorkflowIr = BUILTIN_CODING_WORKFLOW_IR; + let name = ir.name; + try { + const def = await store.getWorkflowDefinition(workflowId); + if (def) { + ir = typeof def.ir === "string" ? parseWorkflowIr(def.ir) : def.ir; + name = def.name || ir.name; + } + } catch { + // fall through to the default IR/name + } + return { id: workflowId, name, columns: describeColumns(ir) }; +} + +/** + * Build the board-workflows payload for the given task ids. Resolves each task's + * workflow selection (null → the default workflow lane) and assembles the + * deduplicated set of referenced workflow definitions. Returns + * `{ flagEnabled: false, ... }` (empty maps) when the flag is OFF so the route + * can return early and the client renders the legacy board. + */ +export async function buildBoardWorkflowsPayload( + store: Pick, + taskIds: string[], + settingsOverride?: Pick, +): Promise { + const settings = settingsOverride ?? (await store.getSettings()); + const flagEnabled = isWorkflowColumnsEnabled(settings); + + const empty: BoardWorkflowsPayload = { + flagEnabled, + defaultWorkflowId: DEFAULT_WORKFLOW_LANE_ID, + workflows: [], + taskWorkflowIds: {}, + }; + if (!flagEnabled) return empty; + + const taskWorkflowIds: Record = {}; + const referenced = new Set(); + + for (const taskId of taskIds) { + let workflowId = DEFAULT_WORKFLOW_LANE_ID; + try { + const selection = store.getTaskWorkflowSelection(taskId); + if (selection?.workflowId) workflowId = selection.workflowId; + } catch { + workflowId = DEFAULT_WORKFLOW_LANE_ID; + } + taskWorkflowIds[taskId] = workflowId; + referenced.add(workflowId); + } + + // The default workflow lane is always describable so a no-task board still + // resolves it (and the client's default-lane-first ordering is stable). + referenced.add(DEFAULT_WORKFLOW_LANE_ID); + + const workflows: BoardWorkflowDefinition[] = []; + for (const workflowId of referenced) { + workflows.push(await describeWorkflow(store, workflowId)); + } + + return { + flagEnabled, + defaultWorkflowId: DEFAULT_WORKFLOW_LANE_ID, + workflows, + taskWorkflowIds, + }; +} diff --git a/packages/dashboard/src/routes/register-task-workflow-routes.ts b/packages/dashboard/src/routes/register-task-workflow-routes.ts index 147300d316..df4a2b8e18 100644 --- a/packages/dashboard/src/routes/register-task-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-task-workflow-routes.ts @@ -36,12 +36,15 @@ import { findNearDuplicates, isEphemeralAgent, parseExplicitDuplicateMarker, + isWorkflowColumnsEnabled, + TransitionRejectionError, type NearDuplicateCandidate, } from "@fusion/core"; import { GitHubClient } from "../github.js"; import { createTrackingIssueForTask } from "../github-tracking-hook.js"; import { parseGitHubBadgeUrl } from "./register-git-github.js"; -import { planTaskWorktreePath } from "@fusion/engine"; +import { planTaskWorktreePath, promoteHeldTask } from "@fusion/engine"; +import { buildBoardWorkflowsPayload } from "./board-workflows.js"; import type { RunAuditEventInput } from "@fusion/core"; import { ApiError, badRequest, conflict, notFound } from "../api-error.js"; import type { ApiRoutesContext } from "./types.js"; @@ -773,6 +776,28 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork const listOptions = { limit, offset, slim: true, includeArchived, ...(column ? { column } : {}) }; tasks = await scopedStore.listTasks(listOptions); } + + // Residual B (U9/U13): additively populate `branchProgress` when the + // workflowColumns flag is ON and the fan-out branch table has rows for + // any of these tasks. One batched query (cheap; short-circuits when the + // table is empty). The payload is otherwise byte-identical. + try { + const settings = await scopedStore.getSettingsFast(); + if (isWorkflowColumnsEnabled(settings) && tasks.length > 0) { + const byTask = scopedStore.getBranchProgressByTask(tasks.map((t) => t.id)); + if (byTask.size > 0) { + tasks = tasks.map((task) => { + const branchProgress = byTask.get(task.id); + return branchProgress && branchProgress.length > 0 + ? { ...task, branchProgress } + : task; + }); + } + } + } catch { + // Branch-progress enrichment is best-effort and must never fail the + // board load — fall through with the un-enriched task list. + } res.json(tasks); } catch (err: unknown) { if (err instanceof ApiError) { @@ -782,6 +807,30 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } }); + // Multi-lane board metadata (U9, R16). Additive sibling to GET /tasks — the + // task list payload stays byte-identical. Flag-OFF returns + // { flagEnabled: false } and the client renders the legacy single-lane board. + router.get("/tasks/board-workflows", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const settings = await scopedStore.getSettingsFast(); + if (!isWorkflowColumnsEnabled(settings)) { + res.json({ flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} }); + return; + } + // Resolve over the same (non-archived) board list the client renders. + const tasks = await scopedStore.listTasks({ slim: true, includeArchived: false }); + const taskIds = tasks.map((t) => t.id); + const payload = await buildBoardWorkflowsPayload(scopedStore, taskIds, settings); + res.json(payload); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + rethrowAsApiError(err); + } + }); + router.post("/tasks/duplicate-check", async (req, res) => { try { const { store: scopedStore } = await getProjectContext(req); @@ -1344,11 +1393,70 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork if (err instanceof ApiError) { throw err; } + // Flag-ON typed rejections surface as a structured 409 so the board can + // resolve the i18n messageKey and decide snap-back vs no-move (U9/R17). + // Flag-OFF legacy errors are unchanged (the legacy strings below). + if (err instanceof TransitionRejectionError) { + throw new ApiError(409, err.message, { + code: err.rejection.code, + messageKey: err.rejection.messageKey, + retryable: err.rejection.retryable, + }); + } const status = (err instanceof Error ? err.message : String(err)).includes("Invalid transition") ? 400 : 500; throw new ApiError(status, err instanceof Error ? err.message : String(err)); } }); + // Manually promote a held card out of its hold column (U9). Releases via the + // same authority as the hold/release sweep; the in-txn capacity check still + // arbitrates, so a promote into a full column rejects with capacity-exhausted. + router.post("/tasks/:id/promote", async (req, res) => { + try { + const { store: scopedStore } = await getProjectContext(req); + const settings = await scopedStore.getSettingsFast(); + if (!isWorkflowColumnsEnabled(settings)) { + throw badRequest("Workflow columns are not enabled"); + } + const existing = await scopedStore.getTask(req.params.id); + const rootDir = scopedStore.getRootDir(); + const allocateWorktree = existing + ? (task: Task, reservedNames: Set) => + planTaskWorktreePath(task, rootDir, settings.worktreeNaming, reservedNames, settings) + : undefined; + + const result = await promoteHeldTask(scopedStore, req.params.id, { allocateWorktree }); + if (!result.released) { + if (result.rejection === "capacity-exhausted-or-no-slot") { + throw new ApiError(409, "Downstream column is at capacity", { + code: "capacity-exhausted", + messageKey: "board.rejection.capacityExhausted", + retryable: true, + }); + } + throw new ApiError(409, result.rejection ?? "Promote rejected", { + code: "guard-rejected", + messageKey: "board.rejection.promoteRejected", + retryable: false, + }); + } + const task = await scopedStore.getTask(req.params.id); + res.json(task); + } catch (err: unknown) { + if (err instanceof ApiError) { + throw err; + } + if (err instanceof TransitionRejectionError) { + throw new ApiError(409, err.message, { + code: err.rejection.code, + messageKey: err.rejection.messageKey, + retryable: err.rejection.retryable, + }); + } + rethrowAsApiError(err); + } + }); + // Merge task (in-review → done, merges branch + cleans worktree) // Uses AI merge handler if provided, falls back to store.mergeTask router.post("/tasks/:id/merge", async (req, res) => { @@ -2638,7 +2746,11 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } // Check if task can transition to triage - const canTransition = VALID_TRANSITIONS[task.column]?.includes("triage"); + // #1403: task.column is ColumnId; VALID_TRANSITIONS is keyed by the legacy + // closed union. A non-legacy custom column id has no legacy transition row, + // so it correctly resolves to "cannot transition" here. + const canTransition = + isColumn(task.column) && VALID_TRANSITIONS[task.column].includes("triage"); if (!canTransition) { throw badRequest( `Cannot request spec revision for tasks in '${task.column}' column. Move task to 'todo' or 'in-progress' first.`, @@ -2704,7 +2816,11 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork } // Check if task can transition to triage - const canTransition = VALID_TRANSITIONS[task.column]?.includes("triage"); + // #1403: task.column is ColumnId; VALID_TRANSITIONS is keyed by the legacy + // closed union. A non-legacy custom column id has no legacy transition row, + // so it correctly resolves to "cannot transition" here. + const canTransition = + isColumn(task.column) && VALID_TRANSITIONS[task.column].includes("triage"); if (!canTransition) { throw badRequest(`Cannot rebuild spec for tasks in '${task.column}' column. Move task to a valid column first.`); } diff --git a/packages/dashboard/src/routes/register-workflow-routes.ts b/packages/dashboard/src/routes/register-workflow-routes.ts index e93df43dfb..d2628248a9 100644 --- a/packages/dashboard/src/routes/register-workflow-routes.ts +++ b/packages/dashboard/src/routes/register-workflow-routes.ts @@ -1,6 +1,7 @@ import type { WorkflowIr } from "@fusion/core"; -import { WorkflowCompileError, WorkflowIrError, compileWorkflowToSteps } from "@fusion/core"; -import { ApiError, badRequest, notFound } from "../api-error.js"; +import { ColumnTraitValidationError, OccupiedColumnsError, InvalidRehomeTargetError, WorkflowCompileError, WorkflowIrError, compileWorkflowToSteps, listTraits } from "@fusion/core"; +import { ApiError, badRequest, conflict, notFound } from "../api-error.js"; +import { emitWorkflowSseEvent } from "../sse.js"; import type { ApiRoutesContext } from "./types.js"; /** @@ -19,6 +20,32 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void { return ir as WorkflowIr; } + // GET /api/traits — trait catalog for the node editor's trait picker (U10). + // Returns the registry's listTraits() (built-ins + any registered plugin + // traits): id, name, description, flags, hook descriptors, and config schema. + // Session-scoped via getProjectContext exactly like the other workflow routes; + // no new auth surface. The catalog is registry-backed and read-only, so it + // does not depend on the project store beyond confirming the session. + router.get("/traits", async (req, res) => { + try { + await getProjectContext(req); + res.json({ + traits: listTraits().map((t) => ({ + id: t.id, + name: t.name, + description: t.description, + builtin: t.builtin === true, + flags: t.flags, + hooks: t.hooks, + configSchema: t.configSchema, + })), + }); + } catch (err: unknown) { + if (err instanceof ApiError) throw err; + rethrowAsApiError(err); + } + }); + // GET /api/workflows — list all workflow definitions for the project. router.get("/workflows", async (req, res) => { try { @@ -33,17 +60,23 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void { // POST /api/workflows — create a workflow. Body: { name, description?, ir, layout? } router.post("/workflows", async (req, res) => { try { - const { store } = await getProjectContext(req); + const { store, projectId } = await getProjectContext(req); const { name, description, layout } = req.body ?? {}; if (!name || typeof name !== "string" || !name.trim()) { throw badRequest("name is required"); } const ir = requireIr(req.body); const created = await store.createWorkflowDefinition({ name, description, ir, layout }); + emitWorkflowSseEvent("workflow:created", created, projectId); res.status(201).json(created); } catch (err: unknown) { if (err instanceof ApiError) throw err; if (err instanceof WorkflowIrError) throw badRequest(err.message); + // Residual A: server-side trait composition conflict → 400 with the + // structured violations (consistent with the IR-error 4xx mapping). + if (err instanceof ColumnTraitValidationError) { + throw badRequest(err.message, { violations: err.violations }); + } rethrowAsApiError(err); } }); @@ -64,19 +97,43 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void { // PATCH /api/workflows/:id — partial update. Body: { name?, description?, ir?, layout? } router.patch("/workflows/:id", async (req, res) => { try { - const { store } = await getProjectContext(req); - const { name, description, ir, layout } = req.body ?? {}; + const { store, projectId } = await getProjectContext(req); + const { name, description, ir, layout, rehomeTo } = req.body ?? {}; if (name !== undefined && (typeof name !== "string" || !name.trim())) { throw badRequest("name must be a non-empty string"); } if (ir !== undefined && (typeof ir !== "object" || ir === null)) { throw badRequest("ir must be a workflow graph object"); } - const updated = await store.updateWorkflowDefinition(req.params.id, { name, description, ir, layout }); + if (rehomeTo !== undefined && typeof rehomeTo !== "string") { + throw badRequest("rehomeTo must be a string column id"); + } + const updated = await store.updateWorkflowDefinition(req.params.id, { + name, + description, + ir, + layout, + ...(rehomeTo !== undefined ? { rehomeTo } : {}), + }); + emitWorkflowSseEvent("workflow:updated", updated, projectId); res.json(updated); } catch (err: unknown) { if (err instanceof ApiError) throw err; + // U5 (R20): a flag-ON edit removing an occupied column blocks with a typed + // error. Surface it as a structured 409 carrying the per-column occupant + // counts so the client can prompt for a `rehomeTo` target and retry. + if (err instanceof OccupiedColumnsError) { + throw conflict(err.message, { workflowId: err.workflowId, occupancies: err.occupancies }); + } + // A supplied rehomeTo naming a non-existent column is a bad request (400), + // not a 409 conflict. + if (err instanceof InvalidRehomeTargetError) { + throw badRequest(err.message, { workflowId: err.workflowId, rehomeTo: err.rehomeTo }); + } if (err instanceof WorkflowIrError) throw badRequest(err.message); + if (err instanceof ColumnTraitValidationError) { + throw badRequest(err.message, { violations: err.violations }); + } if (err instanceof Error && /not found/i.test(err.message)) throw notFound(err.message); rethrowAsApiError(err); } @@ -85,8 +142,9 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void { // DELETE /api/workflows/:id router.delete("/workflows/:id", async (req, res) => { try { - const { store } = await getProjectContext(req); + const { store, projectId } = await getProjectContext(req); await store.deleteWorkflowDefinition(req.params.id); + emitWorkflowSseEvent("workflow:deleted", { id: req.params.id }, projectId); res.status(204).send(); } catch (err: unknown) { if (err instanceof ApiError) throw err; @@ -149,8 +207,15 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void { throw badRequest("workflowId must be a string or null"); } let enabledWorkflowSteps: string[] = []; + // U5 (R20) switch reconciliation: when the workflowColumns flag is ON, the + // store re-homes the card to the new workflow's entry column (aborting + // in-flight work first) unless the new workflow defines its current column. + // The re-home outcome rides on the response so the UI can reflect the move. + let reconciliation: { preserved: boolean; fromColumn: string; toColumn: string } | undefined; try { - enabledWorkflowSteps = await store.selectTaskWorkflow(req.params.taskId, workflowId); + const result = await store.selectTaskWorkflowAndReconcile(req.params.taskId, workflowId); + enabledWorkflowSteps = result.enabledWorkflowSteps; + reconciliation = result.reconciliation; } catch (selectErr: unknown) { if (selectErr instanceof WorkflowCompileError || selectErr instanceof WorkflowIrError) { throw new ApiError(422, selectErr.message); @@ -160,7 +225,7 @@ export function registerWorkflowRoutes(ctx: ApiRoutesContext): void { } throw selectErr; } - res.json({ workflowId, enabledWorkflowSteps }); + res.json({ workflowId, enabledWorkflowSteps, ...(reconciliation ? { reconciliation } : {}) }); } catch (err: unknown) { if (err instanceof ApiError) throw err; rethrowAsApiError(err); diff --git a/packages/dashboard/src/sse.ts b/packages/dashboard/src/sse.ts index 3d5cf4a20b..7b1409ab32 100644 --- a/packages/dashboard/src/sse.ts +++ b/packages/dashboard/src/sse.ts @@ -234,6 +234,25 @@ export function emitApprovalSseEvent(event: ApprovalSseEventType, payload: unkno } } +/** + * Workflow-definition lifecycle events forwarded through the SSE stream. The + * TaskStore has no EventEmitter seam for workflow CRUD, so the workflow routes + * publish through this module-level seam (mirroring approvals) on create / + * update / delete. Board.tsx listens for `workflow:updated` to invalidate and + * re-fetch board-workflows when a definition (its lanes / column traits) changes. + */ +export type WorkflowSseEventType = "workflow:created" | "workflow:updated" | "workflow:deleted"; + +type WorkflowSseListener = (event: WorkflowSseEventType, payload: unknown, projectId?: string) => void; + +const workflowSseListeners = new Set(); + +export function emitWorkflowSseEvent(event: WorkflowSseEventType, payload: unknown, projectId?: string): void { + for (const listener of workflowSseListeners) { + listener(event, payload, projectId); + } +} + /** * Custom plugin events forwarded to connected SSE clients. This is the real * publish-to-`/api/events` seam plugins reach through `ctx.emitEvent`: the @@ -619,6 +638,11 @@ export function createSSE( send(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`); }; + const onWorkflowEvent: WorkflowSseListener = (event, payload, eventProjectId) => { + if (projectId && eventProjectId && eventProjectId !== projectId) return; + send(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`); + }; + const onPluginCustomEvent: PluginCustomSseListener = (pluginId, event, payload, eventProjectId) => { // Scope match mirrors approvals: a project-scoped stream only forwards // events for its own project; the default stream forwards unscoped events. @@ -775,6 +799,7 @@ export function createSSE( messageStore.off("message:deleted", onMessageDeleted); } approvalSseListeners.delete(onApprovalEvent); + workflowSseListeners.delete(onWorkflowEvent); pluginCustomSseListeners.delete(onPluginCustomEvent); if (chatStore) { chatStore.off("chat:session:created", onChatSessionCreated); @@ -922,6 +947,7 @@ export function createSSE( // (SSE comments starting with ":" are silently consumed and never // fire event listeners in the browser). approvalSseListeners.add(onApprovalEvent); + workflowSseListeners.add(onWorkflowEvent); pluginCustomSseListeners.add(onPluginCustomEvent); registerManagedConnection({ diff --git a/packages/dashboard/vitest.config.ts b/packages/dashboard/vitest.config.ts index 90d5bba151..9763d01632 100644 --- a/packages/dashboard/vitest.config.ts +++ b/packages/dashboard/vitest.config.ts @@ -94,6 +94,7 @@ const qualityAppComponentTests = [ "App", "AuthTokenRecoveryDialog", "Board", + "Board.canDropTask", "board-mobile", "board-mobile-view-switch", "BranchGroupCard", @@ -121,6 +122,7 @@ const qualityAppComponentTests = [ "GitHubBadge", "GroupTaskModal", "InlineCreateCard", + "Lane", "LoginInstructions", "MemoryView", "MergeAdvanceNotice", @@ -174,6 +176,8 @@ const qualityAppComponentTests = [ "TrackingRepoSelect", "WorkflowNodeEditor", "WorkflowResultsTab", + "WorkflowSelector", + "workflow-flow-mapping", "WorktrunkInstallApprovalDetails", ] as const; @@ -187,7 +191,7 @@ const batchedQualityAppComponentTestsB = batchedQualityAppComponentTests.slice(b function buildComponentQualityInclude(testNames: readonly string[]): string[] { return testNames.length > 0 - ? [`app/components/__tests__/{${testNames.join(",")}}.test.tsx`] + ? [`app/components/__tests__/{${testNames.join(",")}}.test.{ts,tsx}`] : []; } diff --git a/packages/engine/src/__tests__/agent-tools.test.ts b/packages/engine/src/__tests__/agent-tools.test.ts index d2436efdb2..3bd495269b 100644 --- a/packages/engine/src/__tests__/agent-tools.test.ts +++ b/packages/engine/src/__tests__/agent-tools.test.ts @@ -17,6 +17,11 @@ import { createResearchTools, createWorkflowListTool, createWorkflowSelectTool, + createTaskPromoteTool, + createWorkflowCreateTool, + createWorkflowUpdateTool, + createWorkflowDeleteTool, + createTraitListTool, qmdAgentMemoryCollectionName, readAgentMemoryWorkspaceLongTerm, sendMessageParams, @@ -26,6 +31,12 @@ import * as core from "@fusion/core"; import { ChatStore, Database } from "@fusion/core"; import type { MessageStore, Message } from "@fusion/core"; import { getEnabledPluginTools, getResearchToolSurfaceStatus } from "../tool-availability.js"; +import { promoteHeldTask } from "../hold-release.js"; + +vi.mock("../hold-release.js", () => ({ + promoteHeldTask: vi.fn(), +})); +const mockPromoteHeldTask = vi.mocked(promoteHeldTask); const loggerSpies = vi.hoisted(() => ({ log: vi.fn(), @@ -384,24 +395,40 @@ describe("createWorkflowListTool", () => { describe("createWorkflowSelectTool", () => { it("selects for the current task by default and reports enabled step count", async () => { - const store = { selectTaskWorkflow: vi.fn().mockResolvedValue(["workflow:WF-003:lint"]) }; + const store = { + selectTaskWorkflowAndReconcile: vi.fn().mockResolvedValue({ enabledWorkflowSteps: ["workflow:WF-003:lint"] }), + }; const tool = createWorkflowSelectTool(store as any, "FN-200"); const result = await tool.execute("call-1", { workflow_id: "WF-003" } as any, undefined, undefined, {} as any); - expect(store.selectTaskWorkflow).toHaveBeenCalledWith("FN-200", "WF-003"); + expect(store.selectTaskWorkflowAndReconcile).toHaveBeenCalledWith("FN-200", "WF-003"); const text = result.content[0]?.type === "text" ? result.content[0].text : ""; expect(text).toContain("Selected workflow WF-003 for FN-200 (1 step enabled)"); expect(result.details).toMatchObject({ taskId: "FN-200", workflowId: "WF-003" }); }); it("honors an explicit task_id override", async () => { - const store = { selectTaskWorkflow: vi.fn().mockResolvedValue([]) }; + const store = { selectTaskWorkflowAndReconcile: vi.fn().mockResolvedValue({ enabledWorkflowSteps: [] }) }; const tool = createWorkflowSelectTool(store as any, "FN-200"); await tool.execute("call-1", { workflow_id: "builtin:coding", task_id: "FN-999" } as any, undefined, undefined, {} as any); - expect(store.selectTaskWorkflow).toHaveBeenCalledWith("FN-999", "builtin:coding"); + expect(store.selectTaskWorkflowAndReconcile).toHaveBeenCalledWith("FN-999", "builtin:coding"); + }); + + it("surfaces the reconciliation re-home outcome", async () => { + const store = { + selectTaskWorkflowAndReconcile: vi.fn().mockResolvedValue({ + enabledWorkflowSteps: [], + reconciliation: { preserved: false, fromColumn: "review", toColumn: "intake" }, + }), + }; + const tool = createWorkflowSelectTool(store as any, "FN-200"); + const result = await tool.execute("call-1", { workflow_id: "WF-003" } as any, undefined, undefined, {} as any); + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + expect(text).toContain("Re-homed from 'review' to 'intake'"); + expect(result.details).toMatchObject({ reconciliation: { preserved: false, fromColumn: "review", toColumn: "intake" } }); }); it("returns an error result when selection fails", async () => { - const store = { selectTaskWorkflow: vi.fn().mockRejectedValue(new Error("Workflow not found: WF-404")) }; + const store = { selectTaskWorkflowAndReconcile: vi.fn().mockRejectedValue(new Error("Workflow not found: WF-404")) }; const tool = createWorkflowSelectTool(store as any, "FN-200"); const result = await tool.execute("call-1", { workflow_id: "WF-404" } as any, undefined, undefined, {} as any); expect((result as { isError?: boolean }).isError).toBe(true); @@ -410,6 +437,124 @@ describe("createWorkflowSelectTool", () => { }); }); +describe("createTaskPromoteTool", () => { + beforeEach(() => mockPromoteHeldTask.mockReset()); + + it("promotes the current task by default and reports the destination column", async () => { + const store = {} as any; + mockPromoteHeldTask.mockResolvedValue({ released: true, toColumn: "ready" }); + const tool = createTaskPromoteTool(store, "FN-200"); + const result = await tool.execute("c", {} as any, undefined, undefined, {} as any); + expect(mockPromoteHeldTask).toHaveBeenCalledWith(store, "FN-200"); + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + expect(text).toContain("Promoted FN-200 to column 'ready'"); + expect(result.details).toMatchObject({ taskId: "FN-200", released: true, toColumn: "ready" }); + }); + + it("honors an explicit task_id and surfaces a rejection as an error", async () => { + const store = {} as any; + mockPromoteHeldTask.mockResolvedValue({ released: false, rejection: "not-held" }); + const tool = createTaskPromoteTool(store, "FN-200"); + const result = await tool.execute("c", { task_id: "FN-999" } as any, undefined, undefined, {} as any); + expect(mockPromoteHeldTask).toHaveBeenCalledWith(store, "FN-999"); + expect((result as { isError?: boolean }).isError).toBe(true); + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + expect(text).toMatch(/not-held/); + }); +}); + +describe("createWorkflowCreateTool", () => { + it("creates a workflow and returns the new id", async () => { + const store = { createWorkflowDefinition: vi.fn().mockResolvedValue({ id: "WF-010", name: "QA" }) }; + const tool = createWorkflowCreateTool(store as any); + const result = await tool.execute("c", { name: "QA", ir: { columns: [] } } as any, undefined, undefined, {} as any); + expect(store.createWorkflowDefinition).toHaveBeenCalledWith( + expect.objectContaining({ name: "QA", ir: { columns: [] } }), + ); + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + expect(text).toContain("Created workflow WF-010 (QA)"); + }); + + it("returns an error result when creation fails", async () => { + const store = { createWorkflowDefinition: vi.fn().mockRejectedValue(new Error("Workflow name is required")) }; + const tool = createWorkflowCreateTool(store as any); + const result = await tool.execute("c", { name: "", ir: {} } as any, undefined, undefined, {} as any); + expect((result as { isError?: boolean }).isError).toBe(true); + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + expect(text).toMatch(/name is required/); + }); +}); + +describe("createWorkflowUpdateTool", () => { + it("updates a workflow and reports the name", async () => { + const store = { updateWorkflowDefinition: vi.fn().mockResolvedValue({ id: "WF-010", name: "QA v2" }) }; + const tool = createWorkflowUpdateTool(store as any); + const result = await tool.execute("c", { workflow_id: "WF-010", name: "QA v2" } as any, undefined, undefined, {} as any); + expect(store.updateWorkflowDefinition).toHaveBeenCalledWith("WF-010", expect.objectContaining({ name: "QA v2" })); + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + expect(text).toContain("Updated workflow WF-010 (QA v2)"); + }); + + it("forwards rehome_to as rehomeTo", async () => { + const store = { updateWorkflowDefinition: vi.fn().mockResolvedValue({ id: "WF-010", name: "QA" }) }; + const tool = createWorkflowUpdateTool(store as any); + await tool.execute("c", { workflow_id: "WF-010", ir: { columns: [] }, rehome_to: "intake" } as any, undefined, undefined, {} as any); + expect(store.updateWorkflowDefinition).toHaveBeenCalledWith("WF-010", expect.objectContaining({ rehomeTo: "intake" })); + }); + + it("surfaces an OccupiedColumnsError as a structured retryable response", async () => { + const err = new core.OccupiedColumnsError("WF-010", [{ columnId: "review", count: 2 }]); + const store = { updateWorkflowDefinition: vi.fn().mockRejectedValue(err) }; + const tool = createWorkflowUpdateTool(store as any); + const result = await tool.execute("c", { workflow_id: "WF-010", ir: { columns: [] } } as any, undefined, undefined, {} as any); + expect((result as { isError?: boolean }).isError).toBe(true); + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + expect(text).toContain("review (2)"); + expect(text).toMatch(/rehome_to/); + expect(result.details).toMatchObject({ + occupiedColumns: [{ columnId: "review", count: 2 }], + workflowId: "WF-010", + retryWith: "rehome_to", + }); + }); +}); + +describe("createWorkflowDeleteTool", () => { + it("deletes a workflow", async () => { + const store = { deleteWorkflowDefinition: vi.fn().mockResolvedValue(undefined) }; + const tool = createWorkflowDeleteTool(store as any); + const result = await tool.execute("c", { workflow_id: "WF-010" } as any, undefined, undefined, {} as any); + expect(store.deleteWorkflowDefinition).toHaveBeenCalledWith("WF-010"); + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + expect(text).toContain("Deleted workflow WF-010"); + }); + + it("surfaces a built-in protection error", async () => { + const store = { + deleteWorkflowDefinition: vi.fn().mockRejectedValue(new Error("Built-in workflows cannot be deleted")), + }; + const tool = createWorkflowDeleteTool(store as any); + const result = await tool.execute("c", { workflow_id: "builtin:coding" } as any, undefined, undefined, {} as any); + expect((result as { isError?: boolean }).isError).toBe(true); + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + expect(text).toMatch(/cannot be deleted/); + }); +}); + +describe("createTraitListTool", () => { + it("lists the trait catalog with ids, names, and flags in details", async () => { + const tool = createTraitListTool(); + const result = await tool.execute("c", {} as any, undefined, undefined, {} as any); + const text = result.content[0]?.type === "text" ? result.content[0].text : ""; + expect(text).toMatch(/Available traits:/); + const traits = (result.details as { traits?: Array<{ id: string; name: string; flags: unknown }> }).traits ?? []; + expect(traits.length).toBeGreaterThan(0); + expect(traits[0]).toHaveProperty("id"); + expect(traits[0]).toHaveProperty("name"); + expect(traits[0]).toHaveProperty("flags"); + }); +}); + describe("createTaskLogToolWithContext", () => { it("returns a graceful archived read-only message instead of throwing", async () => { const store = { diff --git a/packages/engine/src/__tests__/hold-release.test.ts b/packages/engine/src/__tests__/hold-release.test.ts new file mode 100644 index 0000000000..dd4de0cdb1 --- /dev/null +++ b/packages/engine/src/__tests__/hold-release.test.ts @@ -0,0 +1,457 @@ +// @vitest-environment node +// +// HOLD/RELEASE SWEEP SUITE (U6). +// +// Exercises the generalized scheduler sweep (`hold-release.ts`) against a REAL +// TaskStore so the in-txn capacity check (KTD-10) actually arbitrates races: +// - two holds, one slot → exactly one releases; other retries next sweep +// - timer release fires at its deadline under fake timers (no real sleeps) +// - manual release only on the explicit promote call +// - capacity release respects mid-transitionPending cards (in-txn authority) +// - cross-workflow dependency complete-flag unblocks + dual-accept diff logged +// - sweep release into a full column rejected by the in-txn check despite +// moveSource:"scheduler" bypassing trait guards (capacity is not a guard) +// - reservation-first: semaphore exhausted → no commit, card stays held +// - paused / recovery-backoff tasks skipped exactly as the legacy scheduler + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execSync } from "node:child_process"; +import { TaskStore, type WorkflowIr } from "@fusion/core"; +import { + runHoldReleaseSweep, + promoteHeldTask, + releaseHeldTaskByEvent, + type HoldReleaseDeps, + type SlotReservation, +} from "../hold-release.js"; + +function git(cwd: string, args: string): void { + execSync(`git ${args}`, { cwd, stdio: "ignore" }); +} + +/** Directly set a task's stored column (test setup helper — bypasses adjacency + * validation so a card can be placed at an arbitrary workflow column). */ +function setColumn(store: TaskStore, taskId: string, column: string): void { + const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db; + db.prepare('UPDATE tasks SET "column" = ?, "columnMovedAt" = ? WHERE id = ?').run( + column, + new Date().toISOString(), + taskId, + ); +} + +/** Directly set a task's workflow selection row (bypasses step compilation). */ +function setSelection(store: TaskStore, taskId: string, workflowId: string): void { + const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db; + db.prepare( + `INSERT INTO task_workflow_selection (taskId, workflowId, stepIds, updatedAt) + VALUES (?, ?, '[]', ?) + ON CONFLICT(taskId) DO UPDATE SET workflowId = excluded.workflowId, updatedAt = excluded.updatedAt`, + ).run(taskId, workflowId, new Date().toISOString()); +} + +/** Write a transitionPending marker directly (simulating a crash mid-transition). */ +function setTransitionPending(store: TaskStore, taskId: string, toColumn: string): void { + const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db; + db.prepare("UPDATE tasks SET transitionPending = ? WHERE id = ?").run( + JSON.stringify({ toColumn, hooksRemaining: ["default-workflow:postCommit"], startedAt: Date.now() }), + taskId, + ); +} + +const noReserveDeps: HoldReleaseDeps = { now: () => Date.now() }; + +describe("hold-release sweep (U6)", () => { + let rootDir = ""; + let store: TaskStore; + + beforeEach(async () => { + rootDir = mkdtempSync(join(tmpdir(), "u6-hold-release-")); + git(rootDir, "init -b main"); + git(rootDir, "config user.name 'Fusion'"); + git(rootDir, "config user.email 'hi@runfusion.ai'"); + writeFileSync(join(rootDir, "README.md"), "root\n"); + git(rootDir, "add README.md"); + git(rootDir, "commit -m init"); + store = new TaskStore(rootDir, undefined, { inMemoryDb: false }); + await store.init(); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + }); + + afterEach(() => { + try { store?.close(); } catch { /* ignore */ } + if (rootDir) rmSync(rootDir, { recursive: true, force: true }); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + // A held card in the DEFAULT workflow: a task resting in `todo` + // (hold release: capacity), which releases into `in-progress` (wip). + async function seedTodoCard(): Promise { + const task = await store.createTask({ description: "card" }); + setColumn(store, task.id, "todo"); + return task.id; + } + + it("flag OFF: sweep is a no-op (legacy scheduler path untouched)", async () => { + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: false } }); + const id = await seedTodoCard(); + const result = await runHoldReleaseSweep(store, noReserveDeps); + expect(result.released).toEqual([]); + expect((await store.getTask(id))?.column).toBe("todo"); + }); + + it("two holds, one slot: exactly one releases; the other releases next sweep after the slot frees", async () => { + await store.updateSettings({ maxConcurrent: 1 } as Parameters[0]); + const a = await seedTodoCard(); + const b = await seedTodoCard(); + + const r1 = await runHoldReleaseSweep(store, noReserveDeps); + expect(r1.released.length).toBe(1); + const released = r1.released[0]; + const stillHeld = released === a ? b : a; + expect((await store.getTask(released))?.column).toBe("in-progress"); + expect((await store.getTask(stillHeld))?.column).toBe("todo"); + + // Free the slot by moving the released card out of in-progress. + await store.moveTask(released, "in-review", { moveSource: "engine", allowDirectInReviewMove: true }); + const r2 = await runHoldReleaseSweep(store, noReserveDeps); + expect(r2.released).toContain(stillHeld); + expect((await store.getTask(stillHeld))?.column).toBe("in-progress"); + }); + + it("FN-1415: two concurrent sweeps, one held card + one slot → exactly one release commits; loser's reservation is released", async () => { + // The scheduler can tick again before a slow sweep finishes. The in-txn + // capacity check (KTD-10) serializes the COMMIT, but we must also prove the + // reservation side effects across racing sweeps don't double-release or leak: + // the winning sweep moves the card, the loser's reservation is released, and + // the held card lands in exactly one downstream slot. + await store.updateSettings({ maxConcurrent: 1 } as Parameters[0]); + const held = await seedTodoCard(); + + // Fake reservations: each reserveSlot hands out a distinct reservation whose + // release() we observe. Both racing sweeps see a free slot in the snapshot + // pre-check and reserve; only one move can commit (maxConcurrent: 1), so the + // loser must release its reservation. + let reserveCount = 0; + let releaseCount = 0; + const deps: HoldReleaseDeps = { + now: () => Date.now(), + reserveSlot: (): SlotReservation | null => { + reserveCount += 1; + return { release: () => { releaseCount += 1; } }; + }, + }; + + const [r1, r2] = await Promise.all([ + runHoldReleaseSweep(store, deps), + runHoldReleaseSweep(store, deps), + ]); + + // The single held card was released into the single slot. Both sweeps may + // report it as released (the second sweep re-moves the already-released card + // to the SAME target — an idempotent same-column move the in-txn capacity + // check permits, since the card is itself the lone occupant). What must hold: + expect(r1.released.concat(r2.released)).toContain(held); + // (a) Single occupancy: the card lands in exactly one downstream slot, and is + // the only occupant of in-progress (no double-occupancy / slot leak). + expect((await store.getTask(held))?.column).toBe("in-progress"); + const inProgress = (await store.listTasks({ includeArchived: false })).filter((t) => t.column === "in-progress"); + expect(inProgress.map((t) => t.id)).toEqual([held]); + + // (b) Reservation accounting across the racing sweeps. + // + // Both sweeps read the same snapshot, both pass the pre-check, and both + // reserve a slot (reserveCount === 2). The winning sweep commits the move; + // the losing sweep, after acquiring its reservation, re-reads the card's + // current column inside `issueRelease`, sees it already at the target (the + // winner moved it), and releases its reservation without issuing a redundant + // same-column move. The safety invariant therefore holds: at most one live + // reservation backs the single occupant. + expect(reserveCount).toBe(2); + // The loser releases its reservation, so the net live reservations is exactly + // one (the winner's), backing the single in-progress occupant — no leak. + expect(releaseCount).toBe(1); + expect(reserveCount - releaseCount).toBe(1); + }); + + it("sweep release into a full column is rejected by the in-txn check (capacity is not a guard, scheduler bypasses guards)", async () => { + await store.updateSettings({ maxConcurrent: 1 } as Parameters[0]); + const occupant = await store.createTask({ description: "occupant" }); + setColumn(store, occupant.id, "in-progress"); + const held = await seedTodoCard(); + + const result = await runHoldReleaseSweep(store, noReserveDeps); + expect(result.released).not.toContain(held); + expect((await store.getTask(held))?.column).toBe("todo"); + }); + + it("capacity release respects cards mid-transitionPending (they hold the slot from commit time)", async () => { + await store.updateSettings({ maxConcurrent: 1 } as Parameters[0]); + // Occupant has committed into in-progress AND is mid-transitionPending — it + // holds the slot; the in-txn count must include it. + const occupant = await store.createTask({ description: "occupant" }); + setColumn(store, occupant.id, "in-progress"); + setTransitionPending(store, occupant.id, "in-progress"); + const held = await seedTodoCard(); + + const result = await runHoldReleaseSweep(store, noReserveDeps); + expect((await store.getTask(held))?.column).toBe("todo"); + expect(result.released).not.toContain(held); + }); + + it("paused and recovery-backoff tasks are skipped exactly as the legacy scheduler", async () => { + await store.updateSettings({ maxConcurrent: 5 } as Parameters[0]); + const paused = await seedTodoCard(); + await store.updateTask(paused, { paused: true }); + const backoff = await seedTodoCard(); + await store.updateTask(backoff, { nextRecoveryAt: new Date(Date.now() + 60_000).toISOString() }); + + const result = await runHoldReleaseSweep(store, { now: () => Date.now() }); + expect(result.released).not.toContain(paused); + expect(result.released).not.toContain(backoff); + expect((await store.getTask(paused))?.column).toBe("todo"); + expect((await store.getTask(backoff))?.column).toBe("todo"); + }); + + it("reservation-first: semaphore exhausted → no commit, card stays held", async () => { + await store.updateSettings({ maxConcurrent: 5 } as Parameters[0]); + const held = await seedTodoCard(); + // reserveSlot returns null (semaphore exhausted) for a processing-column + // release — the move must never be issued. + const deps: HoldReleaseDeps = { + now: () => Date.now(), + reserveSlot: (): SlotReservation | null => null, + }; + const result = await runHoldReleaseSweep(store, deps); + expect(result.released).not.toContain(held); + expect((await store.getTask(held))?.column).toBe("todo"); + }); + + it("reservation is RELEASED when the move rejects on capacity", async () => { + await store.updateSettings({ maxConcurrent: 1 } as Parameters[0]); + const occupant = await store.createTask({ description: "occupant" }); + setColumn(store, occupant.id, "in-progress"); + const held = await seedTodoCard(); + + const releases: number[] = []; + let reserveCount = 0; + const deps: HoldReleaseDeps = { + now: () => Date.now(), + reserveSlot: (): SlotReservation | null => { + reserveCount += 1; + return { release: () => releases.push(1) }; + }, + }; + const result = await runHoldReleaseSweep(store, deps); + expect(result.released).not.toContain(held); + // A reservation was taken (downstream pre-check passed since maxConcurrent + // read-through is evaluated against the snapshot) then released on the + // in-txn capacity rejection. If the pre-check already gated, reserveCount + // may be 0; if it reserved, it must have released exactly once. + if (reserveCount > 0) expect(releases.length).toBe(reserveCount); + }); +}); + +// ── Timer / manual / external-event holds (custom workflows) ────────────────── + +/** A custom workflow whose middle column is a hold with the given release kind. + * Columns: c-intake (intake) → c-hold (hold) → c-run (wip) → c-done (complete). */ +function customHoldWorkflowIr(release: string, holdConfig: Record = {}): WorkflowIr { + return { + version: "v2", + name: "custom-hold", + columns: [ + { id: "c-intake", name: "Intake", traits: [{ trait: "intake" }] }, + { id: "c-hold", name: "Hold", traits: [{ trait: "hold", config: { release, ...holdConfig } }] }, + { id: "c-run", name: "Run", traits: [{ trait: "wip", config: { limit: 5 } }] }, + { id: "c-done", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "c-intake" }, + { id: "end", kind: "end", column: "c-done" }, + ], + edges: [{ from: "start", to: "end" }], + } as WorkflowIr; +} + +describe("hold-release sweep — timer / manual / external-event (U6)", () => { + let rootDir = ""; + let store: TaskStore; + + beforeEach(async () => { + rootDir = mkdtempSync(join(tmpdir(), "u6-hold-kinds-")); + git(rootDir, "init -b main"); + git(rootDir, "config user.name 'Fusion'"); + git(rootDir, "config user.email 'hi@runfusion.ai'"); + writeFileSync(join(rootDir, "README.md"), "root\n"); + git(rootDir, "add README.md"); + git(rootDir, "commit -m init"); + store = new TaskStore(rootDir, undefined, { inMemoryDb: false }); + await store.init(); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + }); + + afterEach(() => { + try { store?.close(); } catch { /* ignore */ } + if (rootDir) rmSync(rootDir, { recursive: true, force: true }); + vi.useRealTimers(); + }); + + async function seedCustomHold(release: string, holdConfig: Record = {}): Promise { + const def = await store.createWorkflowDefinition({ name: `wf-${release}`, ir: customHoldWorkflowIr(release, holdConfig) }); + const task = await store.createTask({ description: `hold-${release}` }); + setSelection(store, task.id, def.id); + setColumn(store, task.id, "c-hold"); + return task.id; + } + + it("timer release fires at the deadline under fake timers (no real sleeps)", async () => { + vi.useFakeTimers(); + const base = Date.now(); + const id = await seedCustomHold("timer", { durationMs: 10_000 }); + // Re-stamp columnMovedAt to the fake-clock base so the deadline is base+10s. + const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db; + db.prepare('UPDATE tasks SET "columnMovedAt" = ? WHERE id = ?').run(new Date(base).toISOString(), id); + + // Before the deadline: not released. + const before = await runHoldReleaseSweep(store, { now: () => base + 5_000 }); + expect(before.released).not.toContain(id); + expect((await store.getTask(id))?.column).toBe("c-hold"); + + // At/after the deadline: released into the downstream run column. + const after = await runHoldReleaseSweep(store, { now: () => base + 10_000 }); + expect(after.released).toContain(id); + expect((await store.getTask(id))?.column).toBe("c-run"); + }); + + it("manual hold: the sweep never auto-releases; an explicit promote does", async () => { + const id = await seedCustomHold("manual"); + const swept = await runHoldReleaseSweep(store, { now: () => Date.now() }); + expect(swept.released).not.toContain(id); + expect((await store.getTask(id))?.column).toBe("c-hold"); + + const promoted = await promoteHeldTask(store, id); + expect(promoted.released).toBe(true); + expect(promoted.toColumn).toBe("c-run"); + expect((await store.getTask(id))?.column).toBe("c-run"); + }); + + it("external-event hold: the sweep never auto-releases; an event release does; a stray event on a manual hold is a no-op", async () => { + const eventId = await seedCustomHold("external-event"); + const swept = await runHoldReleaseSweep(store, { now: () => Date.now() }); + expect(swept.released).not.toContain(eventId); + + const released = await releaseHeldTaskByEvent(store, eventId, "webhook:approved"); + expect(released.released).toBe(true); + expect((await store.getTask(eventId))?.column).toBe("c-run"); + + // A manual hold is NOT releasable by an external event. + const manualId = await seedCustomHold("manual"); + const stray = await releaseHeldTaskByEvent(store, manualId, "webhook:approved"); + expect(stray.released).toBe(false); + expect((await store.getTask(manualId))?.column).toBe("c-hold"); + }); +}); + +// ── Dependency gating (KTD-5 + FN-5719 dual-accept) ─────────────────────────── + +/** A custom workflow with a hold(dependency) column. */ +function dependencyHoldWorkflowIr(): WorkflowIr { + return { + version: "v2", + name: "dep-hold", + columns: [ + { id: "d-intake", name: "Intake", traits: [{ trait: "intake" }] }, + { id: "d-hold", name: "Hold", traits: [{ trait: "hold", config: { release: "dependency" } }] }, + { id: "d-run", name: "Run", traits: [{ trait: "wip", config: { limit: 5 } }] }, + { id: "d-done", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "d-intake" }, + { id: "end", kind: "end", column: "d-done" }, + ], + edges: [{ from: "start", to: "end" }], + } as WorkflowIr; +} + +/** A custom "producer" workflow whose terminal column carries the complete flag + * under a NON-legacy column id (so the complete-flag path differs from the + * legacy done/in-review/archived signal — used for the dual-accept diff). */ +function completeFlagWorkflowIr(): WorkflowIr { + return { + version: "v2", + name: "producer", + columns: [ + { id: "p-intake", name: "Intake", traits: [{ trait: "intake" }] }, + { id: "p-finished", name: "Finished", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "p-intake" }, + { id: "end", kind: "end", column: "p-finished" }, + ], + edges: [{ from: "start", to: "end" }], + } as WorkflowIr; +} + +describe("hold-release sweep — dependency gating (KTD-5)", () => { + let rootDir = ""; + let store: TaskStore; + + beforeEach(async () => { + rootDir = mkdtempSync(join(tmpdir(), "u6-dep-")); + git(rootDir, "init -b main"); + git(rootDir, "config user.name 'Fusion'"); + git(rootDir, "config user.email 'hi@runfusion.ai'"); + writeFileSync(join(rootDir, "README.md"), "root\n"); + git(rootDir, "add README.md"); + git(rootDir, "commit -m init"); + store = new TaskStore(rootDir, undefined, { inMemoryDb: false }); + await store.init(); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + }); + + afterEach(() => { + try { store?.close(); } catch { /* ignore */ } + if (rootDir) rmSync(rootDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("a dependency in another workflow's complete-flagged column unblocks the dependent; dual-accept logs a diff on disagreement", async () => { + const auditSpy = vi.spyOn(store, "recordRunAuditEvent"); + + const producerDef = await store.createWorkflowDefinition({ name: "producer", ir: completeFlagWorkflowIr() }); + const dep = await store.createTask({ description: "producer task" }); + setSelection(store, dep.id, producerDef.id); + // Producer NOT yet complete → dependent stays held. + setColumn(store, dep.id, "p-intake"); + + const depHoldDef = await store.createWorkflowDefinition({ name: "dep-hold", ir: dependencyHoldWorkflowIr() }); + const dependent = await store.createTask({ description: "dependent", dependencies: [dep.id] }); + setSelection(store, dependent.id, depHoldDef.id); + setColumn(store, dependent.id, "d-hold"); + + const r1 = await runHoldReleaseSweep(store, { now: () => Date.now() }); + expect(r1.released).not.toContain(dependent.id); + expect((await store.getTask(dependent.id))?.column).toBe("d-hold"); + + // Move the producer into its complete-flagged column (NON-legacy id). + setColumn(store, dep.id, "p-finished"); + auditSpy.mockClear(); + + const r2 = await runHoldReleaseSweep(store, { now: () => Date.now() }); + expect(r2.released).toContain(dependent.id); + expect((await store.getTask(dependent.id))?.column).toBe("d-run"); + + // Dual-accept disagreement: the complete-flag says satisfied, but the legacy + // signal (column p-finished is NOT done/in-review/archived, no marker) says + // NOT satisfied → an audit-diff event was logged. + const diffLogged = auditSpy.mock.calls.some( + (call) => (call[0] as { mutationType?: string })?.mutationType === "merge:dependency-parity-diff", + ); + expect(diffLogged).toBe(true); + }); +}); diff --git a/packages/engine/src/__tests__/merge-trait.test.ts b/packages/engine/src/__tests__/merge-trait.test.ts new file mode 100644 index 0000000000..80fa0741c9 --- /dev/null +++ b/packages/engine/src/__tests__/merge-trait.test.ts @@ -0,0 +1,630 @@ +/** + * U7 — Merge trait behavior (R10). + * + * Covers every U7 plan scenario: + * - each `strategy` value routes to the merger behavior it names, incl. + * `pr-only` (enqueue-with-prState marker, documented below); + * - `fileScope` off / warn / strict / custom behaviors incl. audit payloads; + * - lost-work guard trio regression: config CANNOT reach the three guards; + * - merge completion drives the next column via the queue callback, not + * inline; + * - a queued merge surviving restart resumes from SQLite state (fixture). + * + * Fast: mock stores + a single in-memory `TaskStore` (no real git, no real + * merges). No process spawns; no fake-timer-dependent waits. + * + * PR-ONLY DESIGN DECISION: there is no PR-creation path inside the merge queue + * in this codebase — the merge-queue worker loop runs `aiMergeTask` (a direct + * merge). So `pr-only` is implemented as a *routing flag* on the resolved + * policy (`pullRequestOnly: true`), consistent with the existing + * `settings.mergeStrategy === "pull-request"` posture: `merger.ts` skips + * direct-merge commit routing exactly as it does for the pull-request setting. + * The card still enqueues onto the same persisted merge-request queue; the + * pr-state marker is the existing pr-monitor machinery's concern. This is the + * narrowest change that makes `pr-only` behave like the pull-request route + * without reimplementing merge mechanics. + */ + +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execSync } from "node:child_process"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + DEFAULT_SETTINGS, + TaskStore, + getTraitRegistry, + type Settings, + type Task, + type WorkflowIr, +} from "@fusion/core"; + +import { + resolveMergePolicy, + registerMergeTraitHooks, + __resetMergeTraitRegistrationForTests, +} from "../merge-trait.js"; +import { + assertSquashOverlapsFileScope, + enforceSquashFileScopeInvariant, + FileScopeViolationError, +} from "../merger.js"; + +// NOTE: this file deliberately does NOT import `./merger-test-helpers.js` — that +// module installs a module-scope `vi.mock("node:child_process")` that would +// break the real `git` operations the real-`TaskStore` fixtures here rely on. +// The file-scope tests use a real git repo and stage real files instead, so the +// merger's real `git diff --cached --name-only` returns the staged set. + +// ── helpers ────────────────────────────────────────────────────────────────── + +function settingsWith(overrides: Partial): Settings { + return { ...DEFAULT_SETTINGS, ...overrides } as Settings; +} + +/** Initialize a temp dir as a git repo with a `.fusion` dir so `createTask` + * (which writes `task.json`) works against a real `TaskStore`. */ +async function initRepo(rootDir: string): Promise { + const run = (cmd: string) => execSync(cmd, { cwd: rootDir, stdio: "pipe" }); + run("git init -b main"); + run('git config user.email "test@example.com"'); + run('git config user.name "Test User"'); + await writeFile(join(rootDir, "README.md"), "# fixture\n", "utf-8"); + run("git add README.md"); + run('git commit -m "chore: init"'); + await mkdir(join(rootDir, ".fusion"), { recursive: true }); +} + +/** A linear custom workflow whose `in-review` column carries a merge trait with + * the given config. Linear so `selectTaskWorkflow` compiles it. */ +function customMergeWorkflowIr(mergeConfig: Record): WorkflowIr { + return { + version: "v2", + name: "custom-merge-wf", + columns: [ + { id: "triage", name: "Triage", traits: [{ trait: "intake" }] }, + { id: "in-progress", name: "In progress", traits: [{ trait: "wip" }] }, + { + id: "in-review", + name: "In review", + traits: [{ trait: "merge", config: mergeConfig }], + }, + { id: "done", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "triage" }, + { id: "execute", kind: "prompt", column: "in-progress", config: { seam: "execute" } }, + { id: "merge", kind: "prompt", column: "in-review", config: { seam: "merge" } }, + { id: "end", kind: "end", column: "done" }, + ], + edges: [ + { from: "start", to: "execute" }, + { from: "execute", to: "merge", condition: "success" }, + { from: "merge", to: "end", condition: "success" }, + { from: "execute", to: "end", condition: "failure" }, + { from: "merge", to: "end", condition: "failure" }, + ], + }; +} + +// ── 1. strategy routing (resolveMergePolicy) ───────────────────────────────── + +describe("resolveMergePolicy — strategy routing", () => { + beforeEach(() => vi.clearAllMocks()); + + // Flag-OFF resolution returns before touching the store (settings passed in). + const noStore = {} as never; + + it("flag OFF: falls back to settings (directMergeCommitStrategy + mergeStrategy)", async () => { + const settings = settingsWith({ + mergeStrategy: "direct", + directMergeCommitStrategy: "always-rebase", + }); + const policy = await resolveMergePolicy(noStore, { id: "FN-1", column: "in-review" }, settings); + expect(policy.source).toBe("settings"); + expect(policy.commitStrategy).toBe("always-rebase"); + expect(policy.pullRequestOnly).toBe(false); + }); + + it("flag OFF + pull-request setting: pullRequestOnly true via settings", async () => { + const settings = settingsWith({ mergeStrategy: "pull-request" }); + const policy = await resolveMergePolicy(noStore, { id: "FN-1", column: "in-review" }, settings); + expect(policy.pullRequestOnly).toBe(true); + expect(policy.source).toBe("settings"); + }); + + it.each([ + ["always-squash", "always-squash", false], + ["auto", "auto", false], + ["always-rebase", "always-rebase", false], + ] as const)( + "flag ON: merge trait strategy '%s' resolves to commitStrategy '%s'", + async (strategy, expectedStrategy, expectedPrOnly) => { + const fx = await makeStoreFixture(); + try { + await fx.selectCustomMergeWorkflow({ strategy }); + const policy = await resolveMergePolicy(fx.store, { id: fx.taskId, column: "in-review" }); + expect(policy.source).toBe("workflow"); + expect(policy.commitStrategy).toBe(expectedStrategy); + expect(policy.pullRequestOnly).toBe(expectedPrOnly); + } finally { + await fx.cleanup(); + } + }, + ); + + it("flag ON: merge trait strategy 'pr-only' sets pullRequestOnly (PR-route, no direct merge)", async () => { + const fx = await makeStoreFixture(); + try { + await fx.selectCustomMergeWorkflow({ strategy: "pr-only" }); + const policy = await resolveMergePolicy(fx.store, { id: fx.taskId, column: "in-review" }); + expect(policy.source).toBe("workflow"); + expect(policy.pullRequestOnly).toBe(true); + } finally { + await fx.cleanup(); + } + }); + + it("flag ON but default workflow (no merge config): resolves entirely from settings", async () => { + const fx = await makeStoreFixture(); + try { + // No custom workflow selected → default workflow → merge trait has no + // config → settings read-through (verbatim back-compat). The flag is + // already ON from the fixture; set the project-level strategy. + await fx.store.updateSettings(settingsWith({ directMergeCommitStrategy: "auto" })); + const policy = await resolveMergePolicy(fx.store, { id: fx.taskId, column: "in-review" }); + expect(policy.commitStrategy).toBe("auto"); + // Default workflow's merge column has no config, so source is settings. + expect(policy.source).toBe("settings"); + } finally { + await fx.cleanup(); + } + }); +}); + +// ── 2. fileScope modes (off / warn / strict / custom) ──────────────────────── +// +// These use a REAL git repo with a staged out-of-scope file so the merger's +// real `git diff --cached --name-only` returns it. The store is a lightweight +// inline fake (NOT the merger-test-helpers mock, which would shadow git). The +// resolved fileScope mode is driven by the fake's settings + workflow stubs. + +interface ScopeRepo { + rootDir: string; + /** Stage a file at the given repo-relative path (creates it). */ + stage: (relPath: string) => Promise; + cleanup: () => Promise; +} + +async function makeScopeRepo(): Promise { + const rootDir = await mkdtemp(join(tmpdir(), "fusion-scope-")); + await initRepo(rootDir); + const run = (cmd: string) => execSync(cmd, { cwd: rootDir, stdio: "pipe" }); + return { + rootDir, + async stage(relPath) { + const abs = join(rootDir, relPath); + await mkdir(join(abs, ".."), { recursive: true }); + await writeFile(abs, "// staged\n", "utf-8"); + run(`git add -- "${relPath}"`); + }, + cleanup: async () => { + await rm(rootDir, { recursive: true, force: true }); + }, + }; +} + +/** Inline fake store for the file-scope assertions: just the methods the + * resolver + enforcement read. `workflow` drives the resolved fileScope mode; + * omitting it (with a flag-off settings) yields the legacy `warn` mode. */ +function fakeScopeStore(opts: { + declaredScope: string[]; + settings: Settings; + scopeOverride?: boolean; + workflow?: { id: string; mergeConfig: Record }; +}) { + const task: Task = { + id: "FN-4073", + title: "scope task", + description: "x", + column: "in-review", + dependencies: [], + steps: [], + currentStep: 0, + log: [], + scopeOverride: opts.scopeOverride, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as Task; + const parseFileScopeFromPrompt = vi.fn().mockResolvedValue(opts.declaredScope); + return { + task, + parseFileScopeFromPrompt, + store: { + getTask: vi.fn().mockResolvedValue(task), + getSettings: vi.fn().mockResolvedValue(opts.settings), + appendAgentLog: vi.fn().mockResolvedValue(undefined), + parseFileScopeFromPrompt, + getTaskWorkflowSelection: vi.fn().mockReturnValue( + opts.workflow ? { workflowId: opts.workflow.id, stepIds: [] } : undefined, + ), + getWorkflowDefinition: vi.fn().mockResolvedValue( + opts.workflow + ? { id: opts.workflow.id, ir: customMergeWorkflowIr(opts.workflow.mergeConfig) } + : undefined, + ), + } as never, + }; +} + +describe("fileScope modes — enforceSquashFileScopeInvariant", () => { + let repo: ScopeRepo; + beforeEach(async () => { + vi.clearAllMocks(); + repo = await makeScopeRepo(); + }); + afterEach(async () => { + await repo.cleanup(); + }); + + const declared = ["packages/engine/src/merger.ts"]; + + it("'warn' (legacy/flag-OFF default): logs + proceeds, audit carries the file list", async () => { + const { store } = fakeScopeStore({ declaredScope: declared, settings: settingsWith({}) }); + await repo.stage("packages/core/src/store.ts"); // out of scope + const auditor = { git: vi.fn().mockResolvedValue(undefined) }; + + await expect( + enforceSquashFileScopeInvariant({ + store, + taskId: "FN-4073", + rootDir: repo.rootDir, + task: await (store as never as { getTask: (id: string) => Promise }).getTask("FN-4073"), + resetLabel: "file-scope invariant violation", + auditor: auditor as never, + }), + ).resolves.toBeUndefined(); + + expect(auditor.git).toHaveBeenCalledTimes(1); + const call = auditor.git.mock.calls[0][0]; + expect(call.type).toBe("merge:file-scope-violation"); + expect(call.metadata.warningOnly).toBe(true); + expect(call.metadata.stagedFiles).toEqual(["packages/core/src/store.ts"]); + expect(call.metadata.declaredScope).toEqual(declared); + }); + + it("'strict': re-throws FileScopeViolationError and audits with warningOnly=false", async () => { + const { store } = fakeScopeStore({ + declaredScope: declared, + settings: settingsWith({ experimentalFeatures: { workflowColumns: true } }), + workflow: { id: "wf-strict", mergeConfig: { fileScope: "strict" } }, + }); + await repo.stage("packages/core/src/store.ts"); + const auditor = { git: vi.fn().mockResolvedValue(undefined) }; + + await expect( + enforceSquashFileScopeInvariant({ + store, + taskId: "FN-4073", + rootDir: repo.rootDir, + task: await (store as never as { getTask: (id: string) => Promise }).getTask("FN-4073"), + resetLabel: "file-scope invariant violation", + auditor: auditor as never, + }), + ).rejects.toBeInstanceOf(FileScopeViolationError); + + const call = auditor.git.mock.calls[0][0]; + expect(call.metadata.mode).toBe("strict"); + expect(call.metadata.warningOnly).toBe(false); + }); + + it("'off': skips the throw and emits one scope-enforcement-disabled audit", async () => { + const { store } = fakeScopeStore({ + declaredScope: declared, + settings: settingsWith({ experimentalFeatures: { workflowColumns: true } }), + scopeOverride: true, + workflow: { id: "wf-off", mergeConfig: { fileScope: "off" } }, + }); + await repo.stage("packages/core/src/store.ts"); + const auditor = { git: vi.fn().mockResolvedValue(undefined) }; + + await expect( + enforceSquashFileScopeInvariant({ + store, + taskId: "FN-4073", + rootDir: repo.rootDir, + task: await (store as never as { getTask: (id: string) => Promise }).getTask("FN-4073"), + resetLabel: "file-scope invariant violation", + auditor: auditor as never, + }), + ).resolves.toBeUndefined(); + + expect(auditor.git).toHaveBeenCalledTimes(1); + const call = auditor.git.mock.calls[0][0]; + expect(call.type).toBe("merge:file-scope-enforcement-disabled"); + expect(call.metadata.disabledByWorkflowConfig).toBe(true); + // per-task scopeOverride is a documented no-op in this mode + expect(call.metadata.scopeOverrideIsNoOp).toBe(true); + }); + + it("'custom': evaluates supplied rules in place of the prompt's File Scope", async () => { + // Prompt scope would be `declared` (no overlap), but custom rules DO overlap + // the staged file → no violation. + const { store, parseFileScopeFromPrompt } = fakeScopeStore({ + declaredScope: declared, + settings: settingsWith({ experimentalFeatures: { workflowColumns: true } }), + workflow: { id: "wf-custom", mergeConfig: { fileScope: "custom", rules: ["packages/core/src/**"] } }, + }); + await repo.stage("packages/core/src/store.ts"); // overlaps custom rules + const auditor = { git: vi.fn().mockResolvedValue(undefined) }; + + await expect( + enforceSquashFileScopeInvariant({ + store, + taskId: "FN-4073", + rootDir: repo.rootDir, + task: await (store as never as { getTask: (id: string) => Promise }).getTask("FN-4073"), + resetLabel: "file-scope invariant violation", + auditor: auditor as never, + }), + ).resolves.toBeUndefined(); + + expect(auditor.git).not.toHaveBeenCalled(); + // The prompt's File Scope is bypassed when custom rules are present. + expect(parseFileScopeFromPrompt).not.toHaveBeenCalled(); + }); + + it("'custom' with violating rules: rules replace prompt scope and a violation is detected", async () => { + const { store } = fakeScopeStore({ + declaredScope: declared, + settings: settingsWith({ experimentalFeatures: { workflowColumns: true } }), + workflow: { id: "wf-custom", mergeConfig: { fileScope: "custom", rules: ["docs/**"] } }, + }); + await repo.stage("packages/core/src/store.ts"); // does NOT overlap docs/** + const auditor = { git: vi.fn().mockResolvedValue(undefined) }; + + await expect( + enforceSquashFileScopeInvariant({ + store, + taskId: "FN-4073", + rootDir: repo.rootDir, + task: await (store as never as { getTask: (id: string) => Promise }).getTask("FN-4073"), + resetLabel: "file-scope invariant violation", + auditor: auditor as never, + }), + ).resolves.toBeUndefined(); + + expect(auditor.git).toHaveBeenCalledTimes(1); + expect(auditor.git.mock.calls[0][0].metadata.declaredScope).toEqual(["docs/**"]); + }); +}); + +describe("assertSquashOverlapsFileScope — custom rules + scopeOverride interaction", () => { + let repo: ScopeRepo; + beforeEach(async () => { + vi.clearAllMocks(); + repo = await makeScopeRepo(); + }); + afterEach(async () => { + await repo.cleanup(); + }); + + it("custom rules override the per-task scopeOverride (rules take precedence)", async () => { + const { store } = fakeScopeStore({ + declaredScope: ["packages/engine/**"], + settings: settingsWith({}), + scopeOverride: true, + }); + await repo.stage("packages/core/src/store.ts"); // does not overlap custom rules + await expect( + assertSquashOverlapsFileScope({ + store, + taskId: "FN-4073", + rootDir: repo.rootDir, + task: await (store as never as { getTask: (id: string) => Promise }).getTask("FN-4073"), + customScopeRules: ["packages/engine/**"], + }), + ).rejects.toBeInstanceOf(FileScopeViolationError); + }); + + it("without custom rules, scopeOverride bypasses the check (legacy behavior intact)", async () => { + const { store } = fakeScopeStore({ + declaredScope: ["packages/engine/**"], + settings: settingsWith({}), + scopeOverride: true, + }); + await repo.stage("packages/core/src/store.ts"); + await expect( + assertSquashOverlapsFileScope({ + store, + taskId: "FN-4073", + rootDir: repo.rootDir, + task: await (store as never as { getTask: (id: string) => Promise }).getTask("FN-4073"), + }), + ).resolves.toBeUndefined(); + }); +}); + +// ── 3. lost-work guard trio: config CANNOT reach them ──────────────────────── + +describe("lost-work guard trio is non-configurable (KTD-6 regression)", () => { + it("the merge trait config schema exposes NO field that names a lost-work guard", () => { + const def = getTraitRegistry().getTrait("merge"); + expect(def).toBeDefined(); + const keys = (def?.configSchema?.fields ?? []).map((f) => f.key); + // Only policy knobs — nothing that could disable sibling-branch rejection, + // line-anchored attribution, or no-op-finalize modifiedFiles preservation. + expect(keys.sort()).toEqual(["conflictStrategy", "fileScope", "rules", "squash", "strategy"]); + for (const forbidden of [ + "allowSiblingMergeTarget", + "siblingBranch", + "attribution", + "clearModifiedFiles", + "noOpFinalize", + "lostWork", + ]) { + expect(keys).not.toContain(forbidden); + } + }); + + it("resolved policy never carries a lost-work toggle, regardless of fileScope/strategy", async () => { + const fx = await makeStoreFixture(); + try { + for (const cfg of [ + { fileScope: "off", strategy: "always-squash" }, + { fileScope: "warn", strategy: "auto" }, + { fileScope: "custom", rules: ["**/*"], strategy: "pr-only" }, + ] as const) { + await fx.selectCustomMergeWorkflow(cfg); + const policy = await resolveMergePolicy(fx.store, { id: fx.taskId, column: "in-review" }); + // The resolved policy object's keys are a closed set — no guard knob. + expect(Object.keys(policy).sort()).toEqual( + ["commitStrategy", "fileScope", "fileScopeRules", "pullRequestOnly", "source"].sort(), + ); + } + } finally { + await fx.cleanup(); + } + }); +}); + +// ── 4. merge trait hooks: enqueue (onEnter) drives queue, never inline ─────── + +describe("merge trait hooks — enqueue-only, queue-driven", () => { + beforeEach(() => { + __resetMergeTraitRegistrationForTests(); + registerMergeTraitHooks(); + }); + + it("registers real onEnter/onExit impls in the registry (not degraded no-ops)", () => { + const onEnter = getTraitRegistry().resolveTraitHook("merge", "onEnter"); + const onExit = getTraitRegistry().resolveTraitHook("merge", "onExit"); + expect(onEnter.impl).toBeDefined(); + expect(onEnter.warning).toBeUndefined(); // a real impl is registered + expect(onExit.impl).toBeDefined(); + expect(onExit.warning).toBeUndefined(); + }); + + it("onEnter enqueues onto the persisted merge queue and never awaits a merge", async () => { + const fx = await makeStoreFixture(); + try { + const onEnter = getTraitRegistry().resolveTraitHook("merge", "onEnter").impl as ( + s: TaskStore, + t: { id: string; priority?: string }, + ) => Promise; + const task = await fx.store.getTask(fx.taskId); + await onEnter(fx.store, { id: task.id, priority: task.priority }); + // Exactly one queue entry; the merge itself is NOT performed by the hook. + expect(fx.peekQueue(fx.taskId)).toBeTruthy(); + const after = await fx.store.getTask(fx.taskId); + expect(after.column).toBe("in-review"); // hook did not move the card + } finally { + await fx.cleanup(); + } + }); + + it("onEnter is idempotent: re-running (crash-replay) holds exactly one entry", async () => { + const fx = await makeStoreFixture(); + try { + const onEnter = getTraitRegistry().resolveTraitHook("merge", "onEnter").impl as ( + s: TaskStore, + t: { id: string; priority?: string }, + ) => Promise; + const task = await fx.store.getTask(fx.taskId); + await onEnter(fx.store, { id: task.id, priority: task.priority }); + await onEnter(fx.store, { id: task.id, priority: task.priority }); + expect(fx.queueCount()).toBe(1); + } finally { + await fx.cleanup(); + } + }); +}); + +// ── 5. queued merge survives restart (resumes from SQLite) ─────────────────── + +describe("queued merge survives restart (SQLite-authoritative)", () => { + it("a queued entry persists across a fresh TaskStore over the same DB file", async () => { + const rootDir = await mkdtemp(join(tmpdir(), "fusion-merge-trait-")); + try { + await initRepo(rootDir); + // First store: create task in-review and enqueue. + const store1 = new TaskStore(rootDir, undefined, {}); + await store1.init(); + await store1.updateSettings(settingsWith({ mergeStrategy: "direct" })); + const created = await store1.createTask({ + title: "resume", + description: "x", + column: "in-review", + branch: "fusion/fn-resume", + baseBranch: "main", + steps: [], + } as never); + const resumeId = created.id; + store1.enqueueMergeQueue(resumeId, {}); + expect(store1.peekMergeQueue().some((e) => e.taskId === resumeId)).toBe(true); + store1.close(); + + // Second store over the same on-disk DB: the queued entry is still there. + const store2 = new TaskStore(rootDir, undefined, {}); + await store2.init(); + expect(store2.peekMergeQueue().some((e) => e.taskId === resumeId)).toBe(true); + store2.close(); + } finally { + await rm(rootDir, { recursive: true, force: true }); + } + }); +}); + +// ── shared in-memory store fixture ─────────────────────────────────────────── + +interface StoreFixture { + store: TaskStore; + taskId: string; + selectCustomMergeWorkflow: (mergeConfig: Record) => Promise; + peekQueue: (taskId: string) => unknown; + queueCount: () => number; + cleanup: () => Promise; +} + +async function makeStoreFixture(): Promise { + const rootDir = await mkdtemp(join(tmpdir(), "fusion-merge-trait-")); + await initRepo(rootDir); + const store = new TaskStore(rootDir, undefined, { inMemoryDb: true }); + await store.init(); + await store.updateSettings(settingsWith({ mergeStrategy: "direct" })); + // `experimentalFeatures` is a GLOBAL setting (mirrors the characterization + // suite), so it must be set via updateGlobalSettings to flip the flag. + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } } as never); + const created = await store.createTask({ + title: "merge-trait fixture", + description: "merge-trait fixture", + column: "in-review", + branch: "fusion/fn-mt", + baseBranch: "main", + steps: [], + } as never); + const taskId = created.id; + + return { + store, + taskId, + async selectCustomMergeWorkflow(mergeConfig) { + const def = await store.createWorkflowDefinition({ + name: `wf-${Math.random().toString(36).slice(2)}`, + ir: customMergeWorkflowIr(mergeConfig), + } as never); + await store.selectTaskWorkflow(taskId, def.id); + }, + peekQueue(id) { + return store.peekMergeQueue().find((e) => e.taskId === id); + }, + queueCount() { + return store.peekMergeQueue().length; + }, + cleanup: async () => { + store.close(); + await rm(rootDir, { recursive: true, force: true }); + }, + }; +} diff --git a/packages/engine/src/__tests__/plugin-traits.test.ts b/packages/engine/src/__tests__/plugin-traits.test.ts new file mode 100644 index 0000000000..c7362f0295 --- /dev/null +++ b/packages/engine/src/__tests__/plugin-traits.test.ts @@ -0,0 +1,648 @@ +// @vitest-environment node +// +// PLUGIN-CONTRIBUTED TRAITS SUITE (U8, R6/R15/R22, KTD-2/KTD-7). +// +// Asserts against REAL engine wiring per the branch-group dead-wiring lesson: +// - real TaskStore (in-memory sqlite) with the workflowColumns flag ON, +// - real core TraitRegistry (fresh per test) + built-ins, +// - real PluginLoader/PluginStore loading a JSON plugin module that declares +// `traits`, +// - real plugin-trait adapter (registration / gate eval / degrade / dependents). +// +// No engine methods are mocked. The only injected fake is the custom-node +// RUNNER (the prompt-session/script machinery), which is the documented seam the +// executor wires — we substitute a deterministic verdict producer so the test +// stays fast and offline. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execSync } from "node:child_process"; + +import { + TaskStore, + PluginStore, + PluginLoader, + getTraitRegistry, + __resetTraitRegistryForTests, + registerBuiltinTraits, + registerDefaultWorkflowHooks, + __resetDefaultWorkflowHooksForTests, + validatePluginTraitContribution, + type WorkflowIr, + type PluginTraitContribution, +} from "@fusion/core"; +import { + registerPluginTraits, + degradePluginTraits, + findLivePluginTraitDependents, + evaluatePluginGate, + pluginTraitRegistryId, + PluginTraitHasDependentsError, +} from "../plugin-trait-adapter.js"; +import type { WorkflowCustomNodeRunner } from "../workflow-node-handlers.js"; +import type { WorkflowNodeResult } from "../workflow-graph-executor.js"; + +function git(cwd: string, args: string): void { + execSync(`git ${args}`, { cwd, stdio: "ignore" }); +} + +/** Fresh registry with built-ins + default-workflow hooks re-wired (so the + * default-workflow move-effect hooks aren't degraded to no-ops mid-suite). */ +function freshRegistry(): void { + __resetTraitRegistryForTests(); + __resetDefaultWorkflowHooksForTests(); + registerBuiltinTraits(); + registerDefaultWorkflowHooks(); +} + +/** Raw column placement (bypasses adjacency validation for setup). */ +function setColumn(store: TaskStore, taskId: string, column: string): void { + const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db; + db.prepare('UPDATE tasks SET "column" = ?, "columnMovedAt" = ? WHERE id = ?').run( + column, + new Date().toISOString(), + taskId, + ); +} + +function setSelection(store: TaskStore, taskId: string, workflowId: string): void { + const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db; + db.prepare( + `INSERT INTO task_workflow_selection (taskId, workflowId, stepIds, updatedAt) + VALUES (?, ?, '[]', ?) + ON CONFLICT(taskId) DO UPDATE SET workflowId = excluded.workflowId, updatedAt = excluded.updatedAt`, + ).run(taskId, workflowId, new Date().toISOString()); +} + +function readTransitionPending(store: TaskStore, taskId: string): string | null { + const db = (store as unknown as { db: { prepare: (s: string) => { get: (...a: unknown[]) => unknown } } }).db; + const row = db.prepare("SELECT transitionPending FROM tasks WHERE id = ?").get(taskId) as + | { transitionPending: string | null } + | undefined; + return row?.transitionPending ?? null; +} + +/** + * A custom v2 workflow with three ordered columns. `gate-col` carries the given + * plugin trait id; order-derived adjacency lets a card move + * `intake-col → gate-col`. + */ +function customWorkflowIr(pluginTraitId: string, opts?: { traitConfig?: Record }): WorkflowIr { + return { + version: "v2", + name: "Custom", + columns: [ + { id: "intake-col", name: "Intake", traits: [{ trait: "intake" }] }, + { + id: "gate-col", + name: "Gate", + traits: [{ trait: pluginTraitId, config: opts?.traitConfig }], + }, + { id: "done-col", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "intake-col" }, + { id: "end", kind: "end", column: "done-col" }, + ], + edges: [{ from: "start", to: "end" }], + } as WorkflowIr; +} + +const PASS_RUNNER: WorkflowCustomNodeRunner = async (): Promise => ({ + outcome: "success", + value: "passed", +}); +const FAIL_RUNNER: WorkflowCustomNodeRunner = async (): Promise => ({ + outcome: "failure", + value: "blocked", +}); + +describe("U8 plugin trait contribution validation (R22, schemaVersion)", () => { + it("rejects a malformed trait manifest (missing schemaVersion / name)", () => { + const errors = validatePluginTraitContribution({ traitId: "x" }); + expect(errors.some((e) => e.includes("schemaVersion is required"))).toBe(true); + expect(errors.some((e) => e.includes("name is required"))).toBe(true); + }); + + it("rejects a sync `guard` hook key (built-in-only, R22)", () => { + const errors = validatePluginTraitContribution({ + traitId: "g", + name: "G", + schemaVersion: 1, + hooks: { guard: true }, + }); + expect(errors.some((e) => e.includes("hooks.guard"))).toBe(true); + }); + + it("rejects a restricted flag (complete / archived, R22)", () => { + const completeErr = validatePluginTraitContribution({ + traitId: "c", + name: "C", + schemaVersion: 1, + flags: { complete: true }, + }); + expect(completeErr.some((e) => e.includes("flags.complete"))).toBe(true); + + const archivedErr = validatePluginTraitContribution({ + traitId: "a", + name: "A", + schemaVersion: 1, + flags: { archived: true }, + }); + expect(archivedErr.some((e) => e.includes("flags.archived"))).toBe(true); + }); + + it("rejects a wrong schemaVersion (versioned extension contract)", () => { + const errors = validatePluginTraitContribution({ traitId: "v", name: "V", schemaVersion: 2 as unknown as 1 }); + expect(errors.some((e) => e.includes("schemaVersion must be 1"))).toBe(true); + }); + + it("accepts a valid async-only gate contribution", () => { + const errors = validatePluginTraitContribution({ + traitId: "approval", + name: "Approval gate", + schemaVersion: 1, + flags: { gate: true }, + hooks: { gate: { mode: "prompt", prompt: "Approve?", gateMode: "blocking" } }, + }); + expect(errors).toEqual([]); + }); +}); + +describe("U8 registry resolution (valid trait resolves like a built-in)", () => { + beforeEach(() => { + freshRegistry(); + }); + afterEach(() => { + __resetTraitRegistryForTests(); + }); + + it("registers a plugin trait under a plugin-namespaced id and resolves through the same lookup", () => { + const registry = getTraitRegistry(); + const contribution: PluginTraitContribution = { + traitId: "approval", + name: "Approval gate", + schemaVersion: 1, + flags: { gate: true }, + hooks: { gate: { mode: "prompt", prompt: "Approve?", gateMode: "blocking" } }, + }; + const ids = registerPluginTraits({ registry, pluginId: "gate-plugin", contributions: [contribution], runCustomNode: PASS_RUNNER }); + const id = pluginTraitRegistryId("gate-plugin", "approval"); + expect(ids).toEqual([id]); + + // Same lookup path as a built-in. + const def = registry.getTrait(id); + expect(def?.flags.gate).toBe(true); + expect(def?.builtin).toBeFalsy(); + // Built-in still resolvable through the same registry. + expect(registry.getTrait("complete")?.flags.complete).toBe(true); + + // The gate hook impl is registered (not a missing-impl degrade). + const resolved = registry.resolveTraitHook(id, "gate"); + expect(resolved.impl).toBeTypeOf("function"); + expect(resolved.warning).toBeUndefined(); + }); + + it("registry rejects a restricted-flag plugin trait as a backstop (R22)", () => { + const registry = getTraitRegistry(); + // The adapter builds a non-builtin definition; the registry enforces R22. + const bad: PluginTraitContribution = { + traitId: "sneaky", + name: "Sneaky", + schemaVersion: 1, + // @ts-expect-error — restricted flag deliberately set to prove the backstop. + flags: { complete: true }, + }; + expect(() => + registerPluginTraits({ registry, pluginId: "p", contributions: [bad], runCustomNode: PASS_RUNNER }), + ).toThrow(/restricted flag/i); + }); +}); + +describe("U8 gate evaluation (blocking fails closed; advisory allows)", () => { + it("blocking gate: a failure verdict does not allow", async () => { + const result = await evaluatePluginGate({ + traitRegistryId: "plugin:gate-plugin:approval", + descriptor: { mode: "prompt", prompt: "Approve?", gateMode: "blocking" }, + task: { id: "T1" } as never, + runCustomNode: FAIL_RUNNER, + }); + expect(result.outcome).toBe("failure"); + }); + + it("blocking gate: a pass verdict allows", async () => { + const result = await evaluatePluginGate({ + traitRegistryId: "plugin:gate-plugin:approval", + descriptor: { mode: "prompt", prompt: "Approve?", gateMode: "blocking" }, + task: { id: "T1" } as never, + runCustomNode: PASS_RUNNER, + }); + expect(result.outcome).toBe("success"); + }); + + it("advisory gate: the handler reports the raw verdict (store layer record-and-allows)", async () => { + // evaluatePluginGate returns the raw runner outcome; the advisory + // "record-and-allow" decision is made at the store guard (see the store + // re-check suite below, which proves an advisory column move commits). + const result = await evaluatePluginGate({ + traitRegistryId: "plugin:gate-plugin:approval", + descriptor: { mode: "prompt", prompt: "FYI", gateMode: "advisory" }, + task: { id: "T1" } as never, + runCustomNode: FAIL_RUNNER, + }); + expect(result.outcome).toBe("failure"); + }); +}); + +describe("U8 store gate re-check (pre-evaluated verdict, KTD-2)", () => { + let rootDir = ""; + let store: TaskStore; + const gateTraitId = pluginTraitRegistryId("gate-plugin", "approval"); + + beforeEach(async () => { + freshRegistry(); + const registry = getTraitRegistry(); + registry.register({ + id: gateTraitId, + name: "Approval gate", + flags: { gate: true }, + hooks: { gate: true }, + builtin: false, + }); + // A LIVE gate hook impl (so the store enforces the recorded verdict rather + // than treating the gate as a degraded/passive no-op). + registry.registerTraitHookImpl(gateTraitId, "gate", () => undefined); + + rootDir = mkdtempSync(join(tmpdir(), "u8-plugin-traits-")); + git(rootDir, "init -b main"); + git(rootDir, "config user.name 'Fusion'"); + git(rootDir, "config user.email 'hi@runfusion.ai'"); + writeFileSync(join(rootDir, "README.md"), "root\n"); + git(rootDir, "add README.md"); + git(rootDir, "commit -m init"); + store = new TaskStore(rootDir, undefined, { inMemoryDb: false }); + await store.init(); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + }); + + afterEach(() => { + try { store?.close(); } catch { /* ignore */ } + if (rootDir) rmSync(rootDir, { recursive: true, force: true }); + __resetTraitRegistryForTests(); + vi.clearAllMocks(); + }); + + async function seedCardInGateWorkflow(config?: Record): Promise { + const def = await store.createWorkflowDefinition({ + name: "Gate WF", + ir: customWorkflowIr(gateTraitId, { traitConfig: config }), + }); + const task = await store.createTask({ description: "card" }); + setSelection(store, task.id, def.id); + setColumn(store, task.id, "intake-col"); + return task.id; + } + + it("blocking gate with NO recorded verdict rejects the move (fail closed)", async () => { + const id = await seedCardInGateWorkflow({ gateMode: "blocking" }); + await expect( + store.moveTask(id, "gate-col", { moveSource: "user" }), + ).rejects.toThrow(/has not been evaluated|did not pass/); + expect((await store.getTask(id)).column).toBe("intake-col"); + }); + + it("blocking gate with a recorded ALLOW verdict permits the move", async () => { + const id = await seedCardInGateWorkflow({ gateMode: "blocking" }); + store.recordPluginGateVerdict(id, "gate-col", { + traitId: gateTraitId, + allow: true, + gateMode: "blocking", + }); + const moved = await store.moveTask(id, "gate-col", { moveSource: "user" }); + expect(moved.column).toBe("gate-col"); + }); + + it("blocking gate with a recorded DENY verdict rejects the move (typed rejection)", async () => { + const id = await seedCardInGateWorkflow({ gateMode: "blocking" }); + store.recordPluginGateVerdict(id, "gate-col", { + traitId: gateTraitId, + allow: false, + gateMode: "blocking", + detail: "reviewer rejected", + }); + await expect( + store.moveTask(id, "gate-col", { moveSource: "user" }), + ).rejects.toThrow(/reviewer rejected/); + expect((await store.getTask(id)).column).toBe("intake-col"); + }); + + it("advisory gate allows the move even without a verdict (record-and-allow)", async () => { + const id = await seedCardInGateWorkflow({ gateMode: "advisory" }); + const moved = await store.moveTask(id, "gate-col", { moveSource: "user" }); + expect(moved.column).toBe("gate-col"); + }); + + it("engine-sourced move bypasses the plugin gate (KTD-9)", async () => { + const id = await seedCardInGateWorkflow({ gateMode: "blocking" }); + // No verdict recorded; an engine move bypasses guards entirely. + const moved = await store.moveTask(id, "gate-col", { moveSource: "engine" }); + expect(moved.column).toBe("gate-col"); + }); +}); + +describe("U8 onEnter hook degradation (card stays, marker cleared, no wedge)", () => { + let rootDir = ""; + let store: TaskStore; + const traitId = pluginTraitRegistryId("notify-plugin", "boom"); + + beforeEach(async () => { + freshRegistry(); + // A plugin trait with an onEnter hook whose impl THROWS. + const registry = getTraitRegistry(); + registry.register({ + id: traitId, + name: "Boom", + flags: { notify: true }, + hooks: { onEnter: true }, + builtin: false, + }); + registry.registerTraitHookImpl(traitId, "onEnter", () => { + throw new Error("plugin onEnter blew up"); + }); + + rootDir = mkdtempSync(join(tmpdir(), "u8-onenter-")); + git(rootDir, "init -b main"); + git(rootDir, "config user.name 'Fusion'"); + git(rootDir, "config user.email 'hi@runfusion.ai'"); + writeFileSync(join(rootDir, "README.md"), "root\n"); + git(rootDir, "add README.md"); + git(rootDir, "commit -m init"); + store = new TaskStore(rootDir, undefined, { inMemoryDb: false }); + await store.init(); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + }); + + afterEach(() => { + try { store?.close(); } catch { /* ignore */ } + if (rootDir) rmSync(rootDir, { recursive: true, force: true }); + __resetTraitRegistryForTests(); + }); + + it("a throwing plugin onEnter does NOT strand the card or wedge the lock", async () => { + // gate-col carries the throwing onEnter trait; move there, then verify a + // subsequent move still succeeds (the lock was not wedged) and the + // transitionPending marker did not stick. + const def = await store.createWorkflowDefinition({ + name: "Boom WF", + ir: customWorkflowIr(traitId), + }); + const task = await store.createTask({ description: "card" }); + setSelection(store, task.id, def.id); + setColumn(store, task.id, "intake-col"); + + // Degraded-not-stranded (KTD-2/R15): the move commits the column change in + // its transaction; plugin post-commit hooks are isolated from the move's + // success path (a throwing onEnter cannot fail the move, strand the card, or + // wedge the lock). The card lands in gate-col regardless of the plugin hook. + const moved = await store.moveTask(task.id, "gate-col", { moveSource: "user" }); + expect(moved.column).toBe("gate-col"); + + // The marker was cleared post-commit — not left dangling. + expect(readTransitionPending(store, task.id)).toBeNull(); + + // The lock is not wedged: a follow-up move proceeds. + const back = await store.moveTask(task.id, "intake-col", { moveSource: "user" }); + expect(back.column).toBe("intake-col"); + }); +}); + +describe("Residual C: plugin onEnter/onExit are INVOKED on the post-commit path", () => { + let rootDir = ""; + let store: TaskStore; + const enterTrait = pluginTraitRegistryId("notify-plugin", "enter"); + const exitTrait = pluginTraitRegistryId("notify-plugin", "exit"); + let enterCalls = 0; + let exitCalls = 0; + + beforeEach(async () => { + freshRegistry(); + enterCalls = 0; + exitCalls = 0; + const registry = getTraitRegistry(); + registry.register({ id: enterTrait, name: "Enter", flags: { notify: true }, hooks: { onEnter: true }, builtin: false }); + registry.register({ id: exitTrait, name: "Exit", flags: { notify: true }, hooks: { onExit: true }, builtin: false }); + registry.registerTraitHookImpl(enterTrait, "onEnter", () => { enterCalls += 1; }); + registry.registerTraitHookImpl(exitTrait, "onExit", () => { exitCalls += 1; }); + + rootDir = mkdtempSync(join(tmpdir(), "u8-cohooks-")); + git(rootDir, "init -b main"); + git(rootDir, "config user.name 'Fusion'"); + git(rootDir, "config user.email 'hi@runfusion.ai'"); + writeFileSync(join(rootDir, "README.md"), "root\n"); + git(rootDir, "add README.md"); + git(rootDir, "commit -m init"); + store = new TaskStore(rootDir, undefined, { inMemoryDb: false }); + await store.init(); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + }); + + afterEach(() => { + try { store?.close(); } catch { /* ignore */ } + if (rootDir) rmSync(rootDir, { recursive: true, force: true }); + __resetTraitRegistryForTests(); + }); + + it("onEnter fires for the to-column's plugin trait; onExit fires for the from-column's", async () => { + // Workflow: intake-col(exit trait) → gate-col(enter trait) → done-col. + const ir = { + version: "v2", + name: "CoHooks", + columns: [ + { id: "intake-col", name: "Intake", traits: [{ trait: "intake" }, { trait: exitTrait }] }, + { id: "gate-col", name: "Gate", traits: [{ trait: enterTrait }] }, + { id: "done-col", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "intake-col" }, + { id: "end", kind: "end", column: "done-col" }, + ], + edges: [{ from: "start", to: "end" }], + } as WorkflowIr; + const def = await store.createWorkflowDefinition({ name: "CoHooks", ir }); + const task = await store.createTask({ description: "card" }); + setSelection(store, task.id, def.id); + setColumn(store, task.id, "intake-col"); + + const moved = await store.moveTask(task.id, "gate-col", { moveSource: "user" }); + expect(moved.column).toBe("gate-col"); + expect(enterCalls).toBe(1); // gate-col onEnter + expect(exitCalls).toBe(1); // intake-col onExit + // Marker cleared (no strand). + expect(readTransitionPending(store, task.id)).toBeNull(); + }); + + it("engine-sourced (bypassGuards) moves skip plugin hooks (KTD-9)", async () => { + const ir = { + version: "v2", + name: "CoHooks2", + columns: [ + { id: "intake-col", name: "Intake", traits: [{ trait: "intake" }] }, + { id: "gate-col", name: "Gate", traits: [{ trait: enterTrait }] }, + { id: "done-col", name: "Done", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "intake-col" }, + { id: "end", kind: "end", column: "done-col" }, + ], + edges: [{ from: "start", to: "end" }], + } as WorkflowIr; + const def = await store.createWorkflowDefinition({ name: "CoHooks2", ir }); + const task = await store.createTask({ description: "card" }); + setSelection(store, task.id, def.id); + setColumn(store, task.id, "intake-col"); + + await store.moveTask(task.id, "gate-col", { moveSource: "engine", bypassGuards: true }); + expect(enterCalls).toBe(0); // engine move bypasses trait effects + }); +}); + +describe("U8 plugin loader aggregation + disable/force-disable (KTD-7)", () => { + let rootDir = ""; + let pluginStore: PluginStore; + let loader: PluginLoader; + let taskRoot = ""; + let store: TaskStore; + + const traitContribution: PluginTraitContribution = { + traitId: "approval", + name: "Approval gate", + schemaVersion: 1, + flags: { gate: true }, + hooks: { gate: { mode: "prompt", prompt: "Approve?", gateMode: "blocking" } }, + }; + const traitRegistryId = pluginTraitRegistryId("gate-plugin", "approval"); + + beforeEach(async () => { + freshRegistry(); + + rootDir = mkdtempSync(join(tmpdir(), "u8-loader-")); + pluginStore = new PluginStore(rootDir, { inMemoryDb: true, centralGlobalDir: rootDir }); + loader = new PluginLoader({ pluginStore, taskStore: { logActivity: vi.fn() } as never }); + await pluginStore.init(); + + taskRoot = mkdtempSync(join(tmpdir(), "u8-loader-tasks-")); + git(taskRoot, "init -b main"); + git(taskRoot, "config user.name 'Fusion'"); + git(taskRoot, "config user.email 'hi@runfusion.ai'"); + writeFileSync(join(taskRoot, "README.md"), "root\n"); + git(taskRoot, "add README.md"); + git(taskRoot, "commit -m init"); + store = new TaskStore(taskRoot, undefined, { inMemoryDb: false }); + await store.init(); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + }); + + afterEach(async () => { + try { store?.close(); } catch { /* ignore */ } + if (taskRoot) rmSync(taskRoot, { recursive: true, force: true }); + const { rm } = await import("node:fs/promises"); + await rm(rootDir, { recursive: true, force: true }); + __resetTraitRegistryForTests(); + }); + + async function loadGatePlugin(): Promise { + const pluginDir = join(rootDir, "plugins"); + await mkdir(pluginDir, { recursive: true }); + const plugin = { + manifest: { id: "gate-plugin", name: "Gate Plugin", version: "1.0.0" }, + state: "installed", + hooks: {}, + traits: [traitContribution], + }; + const path = join(pluginDir, "gate-plugin.mjs"); + await writeFile(path, `const plugin = ${JSON.stringify(plugin, null, 2)}; export default plugin;`); + await pluginStore.registerPlugin({ manifest: plugin.manifest, path }); + await loader.loadAllPlugins(); + } + + it("loader aggregates plugin trait contributions with ownership", async () => { + await loadGatePlugin(); + const traits = loader.getPluginTraits(); + expect(traits).toHaveLength(1); + expect(traits[0].pluginId).toBe("gate-plugin"); + expect(traits[0].trait.traitId).toBe("approval"); + }); + + it("disable with cards in a plugin-trait column is BLOCKED with a typed dependents error", async () => { + await loadGatePlugin(); + const registry = getTraitRegistry(); + registerPluginTraits({ + registry, + pluginId: "gate-plugin", + contributions: loader.getPluginTraits().map((t) => t.trait), + runCustomNode: PASS_RUNNER, + }); + + // Seed a live card in a column using the plugin trait. + const def = await store.createWorkflowDefinition({ name: "Gate WF", ir: customWorkflowIr(traitRegistryId) }); + const task = await store.createTask({ description: "card" }); + setSelection(store, task.id, def.id); + setColumn(store, task.id, "gate-col"); + + const resolveIr = (taskId: string): WorkflowIr | undefined => + store.getTaskWorkflowSelection(taskId)?.workflowId === def.id ? def.ir : undefined; + + const dependents = await findLivePluginTraitDependents({ + store, + resolveTaskWorkflowIr: resolveIr, + pluginTraitIds: [traitRegistryId], + }); + expect(dependents).toHaveLength(1); + expect(dependents[0].taskId).toBe(task.id); + expect(dependents[0].column).toBe("gate-col"); + + // The typed error is the disable block (mirrors the built-in-workflow block). + const err = new PluginTraitHasDependentsError("gate-plugin", dependents); + expect(err.dependents).toHaveLength(1); + expect(err.message).toContain("gate-plugin"); + }); + + it("force-disable degrades the column to passive: hooks become no-ops, cards still movable", async () => { + await loadGatePlugin(); + const registry = getTraitRegistry(); + registerPluginTraits({ + registry, + pluginId: "gate-plugin", + contributions: loader.getPluginTraits().map((t) => t.trait), + runCustomNode: FAIL_RUNNER, // would block if still live + }); + + const def = await store.createWorkflowDefinition({ name: "Gate WF", ir: customWorkflowIr(traitRegistryId, { traitConfig: { gateMode: "blocking" } }) }); + const task = await store.createTask({ description: "card" }); + setSelection(store, task.id, def.id); + setColumn(store, task.id, "intake-col"); + + // Before degrade: the gate hook impl is registered (not a missing-impl no-op). + expect(registry.resolveTraitHook(traitRegistryId, "gate").warning).toBeUndefined(); + + // Force-disable: degrade the trait's hooks to no-ops. + const degraded = degradePluginTraits(registry, [traitRegistryId]); + expect(degraded).toContain(traitRegistryId); + + // The trait definition still resolves (column not bricked) but the hook is + // now the degraded no-op + audit warning path. + expect(registry.getTrait(traitRegistryId)).toBeDefined(); + const resolved = registry.resolveTraitHook(traitRegistryId, "gate"); + expect(resolved.warning?.kind).toBe("missing-hook-impl"); + + // Card is still movable into the degraded column with NO recorded verdict: + // the store guard sees the degraded (warning) gate and treats it as passive + // (KTD-7 — cards remain movable). A live (non-degraded) blocking gate would + // have rejected this move for lack of a verdict. + const moved = await store.moveTask(task.id, "gate-col", { moveSource: "user" }); + expect(moved.column).toBe("gate-col"); + }); +}); diff --git a/packages/engine/src/__tests__/project-runtime.test.ts b/packages/engine/src/__tests__/project-runtime.test.ts index b0d06cbeda..ac766aecdb 100644 --- a/packages/engine/src/__tests__/project-runtime.test.ts +++ b/packages/engine/src/__tests__/project-runtime.test.ts @@ -10,6 +10,12 @@ vi.mock("@earendil-works/pi-ai", () => ({ Array: (schema: unknown, opts?: unknown) => ({ type: "array", items: schema, ...((opts as object) ?? {}) }), Union: (schemas: unknown[], opts?: unknown) => ({ anyOf: schemas, ...((opts as object) ?? {}) }), Literal: (value: unknown) => ({ const: value }), + Unknown: (opts?: unknown) => ({ ...((opts as object) ?? {}) }), + Record: (_key: unknown, value: unknown, opts?: unknown) => ({ + type: "object", + additionalProperties: value, + ...((opts as object) ?? {}), + }), }, })); diff --git a/packages/engine/src/__tests__/reliability-interactions/executor-no-task-done-vs-worktree-reclaim.test.ts b/packages/engine/src/__tests__/reliability-interactions/executor-no-task-done-vs-worktree-reclaim.test.ts index 20fdc28c62..4010c44eb6 100644 --- a/packages/engine/src/__tests__/reliability-interactions/executor-no-task-done-vs-worktree-reclaim.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/executor-no-task-done-vs-worktree-reclaim.test.ts @@ -101,7 +101,7 @@ describe("reliability interactions: executor no-fn_task_done vs worktree reclaim branch: null, worktreeSessionRetryCount: 1, })); - expect(store.moveTask).toHaveBeenCalledWith("FN-4601", "todo", { preserveProgress: true }); + expect(store.moveTask).toHaveBeenCalledWith("FN-4601", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true }); // FN-4806: session-start missing-worktree is engine self-heal, must not burn retry budget // and must not mark the task failed. expect(store.updateTask).not.toHaveBeenCalledWith( diff --git a/packages/engine/src/__tests__/reliability-interactions/in-progress-limbo-recovery.test.ts b/packages/engine/src/__tests__/reliability-interactions/in-progress-limbo-recovery.test.ts index 664950f3a5..de592e2dd2 100644 --- a/packages/engine/src/__tests__/reliability-interactions/in-progress-limbo-recovery.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/in-progress-limbo-recovery.test.ts @@ -82,7 +82,7 @@ describe("FN-5219 reliability interactions: in-progress limbo recovery", () => { expect(first).toBe(1); expect(second).toBe(0); - expect(mockStore.moveTask).toHaveBeenCalledWith("FN-5149", "todo", { preserveProgress: true }); + expect(mockStore.moveTask).toHaveBeenCalledWith("FN-5149", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true }); expect(mockStore.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ mutationType: "task:auto-recover-in-progress-limbo", target: "FN-5149", diff --git a/packages/engine/src/__tests__/reliability-interactions/worktree-incomplete-session-start.test.ts b/packages/engine/src/__tests__/reliability-interactions/worktree-incomplete-session-start.test.ts index cd7d2bb0e3..7a2ec1993a 100644 --- a/packages/engine/src/__tests__/reliability-interactions/worktree-incomplete-session-start.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/worktree-incomplete-session-start.test.ts @@ -87,8 +87,7 @@ describe("reliability interactions: FN-4917 worktree incomplete session-start", }), })); - expect(store.moveTask.mock.calls).toContainEqual(["FN-4917-T", "todo"]); - expect(store.moveTask.mock.calls.some((call: any[]) => call.length > 2)).toBe(false); + expect(store.moveTask.mock.calls).toContainEqual(["FN-4917-T", "todo", { moveSource: "engine", recoveryRehome: true }]); for (const call of store.logEntry.mock.calls) { const leaked = call.some((arg: unknown) => typeof arg === "string" && /Refusing to start coding agent/.test(arg)); expect(leaked).toBe(false); @@ -113,7 +112,7 @@ describe("reliability interactions: FN-4917 worktree incomplete session-start", await runRecovery(store, task, "Refusing to start coding agent in incomplete worktree: /tmp/wt", events); - expect(store.moveTask).toHaveBeenCalledWith("FN-4917-T", "todo", { preserveProgress: true }); + expect(store.moveTask).toHaveBeenCalledWith("FN-4917-T", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true }); expect(store.moveTask.mock.calls).not.toContainEqual(["FN-4917-T", "todo"]); for (const call of store.logEntry.mock.calls) { const leaked = call.some((arg: unknown) => typeof arg === "string" && /Refusing to start coding agent/.test(arg)); diff --git a/packages/engine/src/__tests__/restart.integration.test.ts b/packages/engine/src/__tests__/restart.integration.test.ts index bba14e30eb..9df703eab2 100644 --- a/packages/engine/src/__tests__/restart.integration.test.ts +++ b/packages/engine/src/__tests__/restart.integration.test.ts @@ -235,6 +235,12 @@ vi.mock("@earendil-works/pi-ai", () => ({ Array: (schema: unknown, opts?: unknown) => ({ type: "array", items: schema, ...((opts as object) ?? {}) }), Union: (schemas: unknown[], opts?: unknown) => ({ anyOf: schemas, ...((opts as object) ?? {}) }), Literal: (value: unknown) => ({ const: value }), + Unknown: (opts?: unknown) => ({ ...((opts as object) ?? {}) }), + Record: (_key: unknown, value: unknown, opts?: unknown) => ({ + type: "object", + additionalProperties: value, + ...((opts as object) ?? {}), + }), }, })); vi.mock("@earendil-works/pi-coding-agent", () => { @@ -256,7 +262,7 @@ import { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees } from "../wo import { createFnAgent } from "../pi.js"; import { execSync } from "node:child_process"; import { existsSync, readdirSync } from "node:fs"; -import type { Task, TaskDetail, TaskStep, Column, Settings, StepStatus } from "@fusion/core"; +import type { Task, TaskDetail, TaskStep, Column, ColumnId, Settings, StepStatus } from "@fusion/core"; const mockedCreateFnAgent = vi.mocked(createFnAgent); const mockedExecSync = vi.mocked(execSync); @@ -320,7 +326,7 @@ function createMockStore(overrides: Record = {}) { return store; } -function makeTask(id: string, column: Column, overrides: Partial = {}): Task { +function makeTask(id: string, column: ColumnId, overrides: Partial = {}): Task { return { id, title: `Task ${id}`, @@ -336,7 +342,7 @@ function makeTask(id: string, column: Column, overrides: Partial = {}): Ta }; } -function makeTaskDetail(id: string, column: Column, overrides: Partial = {}): TaskDetail { +function makeTaskDetail(id: string, column: ColumnId, overrides: Partial = {}): TaskDetail { return { ...makeTask(id, column, overrides), prompt: overrides.prompt ?? "# test\n## Steps\n### Step 0: Preflight\n- [ ] check\n## Review Level: 0", diff --git a/packages/engine/src/__tests__/scheduler.test.ts b/packages/engine/src/__tests__/scheduler.test.ts index 8889197fa4..7a431a3286 100644 --- a/packages/engine/src/__tests__/scheduler.test.ts +++ b/packages/engine/src/__tests__/scheduler.test.ts @@ -92,6 +92,12 @@ function createMockStore(overrides: Partial = {}): TaskStore { recordRunAuditEvent: vi.fn().mockResolvedValue(undefined), getRootDir: vi.fn().mockReturnValue("/test/project"), getTasksDir: vi.fn().mockReturnValue("/test/project/.fusion/tasks"), + // U6: the hold/release sweep consults workflow selection + completion markers + // when the workflowColumns flag is ON; default mocks keep flag-OFF behavior + // (sweep early-returns before touching these). + getTaskWorkflowSelection: vi.fn().mockReturnValue(undefined), + getWorkflowDefinition: vi.fn().mockResolvedValue(undefined), + getCompletionHandoffAcceptedMarker: vi.fn().mockReturnValue(null), on: vi.fn(), off: vi.fn(), ...overrides, @@ -530,6 +536,50 @@ describe("Scheduler", () => { }); }); + describe("U6 hold/release sweep integration (flag-gated)", () => { + function setupTodoStore(workflowColumns: boolean) { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); + const todo = createMockTask({ id: "FN-1", column: "todo", dependencies: [] }); + const store = createMockStore({ + listTasks: vi.fn().mockResolvedValue([todo]), + getTask: vi.fn().mockResolvedValue(todo), + getSettings: vi.fn().mockResolvedValue({ + maxConcurrent: 2, + maxWorktrees: 4, + experimentalFeatures: { workflowColumns }, + }), + }); + const scheduler = new Scheduler(store); + (scheduler as unknown as { running: boolean }).running = true; + return { store, scheduler }; + } + + it("flag-ON default-workflow pickup matches flag-OFF: same todo→in-progress dispatch", async () => { + // Flag-OFF baseline: the legacy pull-from-todo loop dispatches the card. + const off = setupTodoStore(false); + await off.scheduler.schedule(); + const offMoves = vi.mocked(off.store.moveTask).mock.calls.map((c) => [c[0], c[1]]); + expect(offMoves).toContainEqual(["FN-1", "in-progress"]); + + // Flag-ON: the sweep runs first (default-workflow todo is a capacity hold), + // then the legacy loop; the net dispatch is the SAME todo→in-progress move. + const on = setupTodoStore(true); + await on.scheduler.schedule(); + const onMoves = vi.mocked(on.store.moveTask).mock.calls.map((c) => [c[0], c[1]]); + expect(onMoves).toContainEqual(["FN-1", "in-progress"]); + }); + + it("flag-OFF: the sweep never issues a scheduler-sourced move (legacy path byte-identical)", async () => { + const off = setupTodoStore(false); + await off.scheduler.schedule(); + const schedulerSourcedMoves = vi + .mocked(off.store.moveTask) + .mock.calls.filter((c) => (c[2] as { moveSource?: string } | undefined)?.moveSource === "scheduler"); + expect(schedulerSourcedMoves.length).toBe(0); + }); + }); + describe("backlog pressure reporter integration", () => { it("invokes reporter from schedule when enabled", async () => { vi.useFakeTimers(); diff --git a/packages/engine/src/__tests__/self-healing-custom-workflow-recovery.test.ts b/packages/engine/src/__tests__/self-healing-custom-workflow-recovery.test.ts new file mode 100644 index 0000000000..8fc16d54e0 --- /dev/null +++ b/packages/engine/src/__tests__/self-healing-custom-workflow-recovery.test.ts @@ -0,0 +1,124 @@ +// @vitest-environment node +// +// #1411: self-healing recovery/backward moves on CUSTOM workflows must pass +// `recoveryRehome: true` (not rely on `bypassGuards`, which skips trait guards +// but NOT order-derived column-graph adjacency). A custom workflow whose +// order-derived adjacency lacks the custom-column → todo edge would otherwise +// reject the recovery move and strand the card. +// +// This exercises a REAL TaskStore (flag-ON) so the in-lock adjacency check +// (resolveAllowedColumns) actually runs: +// - a backward recovery move WITHOUT recoveryRehome (engine source + +// bypassGuards) is rejected by adjacency, proving bypassGuards alone is +// insufficient (the bug), +// - the SAME move WITH recoveryRehome: true succeeds (the fix self-healing +// now applies at its moveTask call sites). + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execSync } from "node:child_process"; +import { TaskStore, type WorkflowIr } from "@fusion/core"; + +function git(cwd: string, args: string): void { + execSync(`git ${args}`, { cwd, stdio: "ignore" }); +} + +function setColumn(store: TaskStore, taskId: string, column: string): void { + const db = (store as unknown as { db: { prepare: (s: string) => { run: (...a: unknown[]) => unknown } } }).db; + db.prepare('UPDATE tasks SET "column" = ?, "columnMovedAt" = ? WHERE id = ?').run( + column, + new Date().toISOString(), + taskId, + ); +} + +/** + * A custom workflow whose linear order is intake → build → done. Its + * order-derived adjacency has NO edge build → todo (todo is not even a column), + * so a recovery move build → todo is only reachable via recoveryRehome. + */ +function customIr(): WorkflowIr { + return { + version: "v2", + name: "linear-custom", + columns: [ + { id: "intake", name: "intake", traits: [{ trait: "intake" }] }, + { id: "build", name: "build", traits: [] }, + { id: "done", name: "done", traits: [{ trait: "complete" }] }, + ], + nodes: [ + { id: "start", kind: "start", column: "intake" }, + { id: "work", kind: "prompt", column: "build", config: { prompt: "do" } }, + { id: "end", kind: "end", column: "done" }, + ], + edges: [ + { from: "start", to: "work", condition: "success" }, + { from: "work", to: "end", condition: "success" }, + ], + } as WorkflowIr; +} + +describe("#1411 self-healing recovery move on custom workflows", () => { + let rootDir = ""; + let store: TaskStore; + + beforeEach(async () => { + rootDir = mkdtempSync(join(tmpdir(), "fn-1411-")); + git(rootDir, "init -b main"); + git(rootDir, "config user.name 'Fusion'"); + git(rootDir, "config user.email 'hi@runfusion.ai'"); + writeFileSync(join(rootDir, "README.md"), "root\n"); + git(rootDir, "add README.md"); + git(rootDir, "commit -m init"); + store = new TaskStore(rootDir, undefined, { inMemoryDb: false }); + await store.init(); + await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } }); + }); + + afterEach(() => { + try { store?.close(); } catch { /* ignore */ } + if (rootDir) rmSync(rootDir, { recursive: true, force: true }); + vi.clearAllMocks(); + }); + + async function seedCardInBuild(): Promise { + const wf = await store.createWorkflowDefinition({ name: "linear-custom", ir: customIr() }); + const task = await store.createTask({ description: "stuck-in-build" }); + await store.selectTaskWorkflowAndReconcile(task.id, wf.id); + setColumn(store, task.id, "build"); + expect((await store.getTask(task.id)).column).toBe("build"); + return task.id; + } + + it("bypassGuards alone is rejected by order-derived adjacency (build → todo)", async () => { + const id = await seedCardInBuild(); + let caught: unknown; + try { + // Mirrors a self-healing backward move BEFORE the fix: engine source + + // bypassGuards, but no recoveryRehome. Adjacency (build → todo) has no edge. + await store.moveTask(id, "todo", { moveSource: "engine", bypassGuards: true, preserveProgress: true }); + } catch (e) { + caught = e; + } + expect(caught).toBeInstanceOf(Error); + expect((await store.getTask(id)).column).toBe("build"); + }); + + it("recoveryRehome: true lets the recovery move reach todo (the fix)", async () => { + const id = await seedCardInBuild(); + await store.moveTask(id, "todo", { + moveSource: "engine", + recoveryRehome: true, + preserveProgress: true, + }); + expect((await store.getTask(id)).column).toBe("todo"); + }); + + it("recoveryRehome: true also reaches a terminal recovery target (archived)", async () => { + const id = await seedCardInBuild(); + await store.moveTask(id, "archived", { moveSource: "engine", recoveryRehome: true }); + expect((await store.getTask(id)).column).toBe("archived"); + }); +}); diff --git a/packages/engine/src/__tests__/self-healing-in-progress-limbo.test.ts b/packages/engine/src/__tests__/self-healing-in-progress-limbo.test.ts index fb0d501f20..5ef1222233 100644 --- a/packages/engine/src/__tests__/self-healing-in-progress-limbo.test.ts +++ b/packages/engine/src/__tests__/self-healing-in-progress-limbo.test.ts @@ -118,7 +118,7 @@ describe("recoverInProgressLimbo", () => { taskDoneRetryCount: null, sessionFile: null, })); - expect(store.moveTask).toHaveBeenCalledWith("FN-5149", "todo", { preserveProgress: true }); + expect(store.moveTask).toHaveBeenCalledWith("FN-5149", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true }); expect(store.recordRunAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ domain: "database", mutationType: "task:auto-recover-in-progress-limbo", diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index 6121e773f9..12813ab527 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -458,6 +458,8 @@ describe("SelfHealingManager", () => { expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true, preserveStatus: true, + moveSource: "engine", + recoveryRehome: true, }); expect(store.updateTask).toHaveBeenLastCalledWith("FN-001", expect.objectContaining({ stuckKillCount: 7, @@ -494,6 +496,8 @@ describe("SelfHealingManager", () => { expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true, preserveStatus: true, + moveSource: "engine", + recoveryRehome: true, }); expect(store.updateTask).not.toHaveBeenCalledWith("FN-001", expect.objectContaining({ paused: false, @@ -530,6 +534,8 @@ describe("SelfHealingManager", () => { expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo", { preserveProgress: true, preserveStatus: true, + moveSource: "engine", + recoveryRehome: true, }); expect(store.logEntry).toHaveBeenCalledWith( "FN-001", @@ -1364,7 +1370,7 @@ describe("SelfHealingManager", () => { "FN-1473", expect.stringContaining("no-progress no-task_done failure"), ); - expect(store.moveTask).toHaveBeenCalledWith("FN-1473", "todo"); + expect(store.moveTask).toHaveBeenCalledWith("FN-1473", "todo", { moveSource: "engine", recoveryRehome: true }); managerWithRecovery.stop(); }); @@ -2233,7 +2239,7 @@ describe("SelfHealingManager", () => { "FN-3900", expect.stringContaining("session-start unusable-worktree assertion"), ); - expect(store.moveTask).toHaveBeenCalledWith("FN-3900", "todo", { preserveProgress: true }); + expect(store.moveTask).toHaveBeenCalledWith("FN-3900", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true }); managerWithRecovery.stop(); }); @@ -2277,7 +2283,7 @@ describe("SelfHealingManager", () => { "FN-4559", expect.stringContaining("session-start unusable-worktree assertion"), ); - expect(store.moveTask).toHaveBeenCalledWith("FN-4559", "todo", { preserveProgress: true }); + expect(store.moveTask).toHaveBeenCalledWith("FN-4559", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true }); managerWithRecovery.stop(); }); @@ -2309,7 +2315,7 @@ describe("SelfHealingManager", () => { "FN-4560", expect.stringContaining("session-start unusable-worktree assertion"), ); - expect(store.moveTask).toHaveBeenCalledWith("FN-4560", "todo", { preserveProgress: true }); + expect(store.moveTask).toHaveBeenCalledWith("FN-4560", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true }); managerWithRecovery.stop(); }); @@ -2349,7 +2355,7 @@ describe("SelfHealingManager", () => { "FN-4651", expect.stringContaining("Auto-recovered (no-progress): session-start refused unusable worktree"), ); - expect(store.moveTask).toHaveBeenCalledWith("FN-4651", "todo"); + expect(store.moveTask).toHaveBeenCalledWith("FN-4651", "todo", { moveSource: "engine", recoveryRehome: true }); managerWithRecovery.stop(); }); @@ -2574,7 +2580,7 @@ describe("SelfHealingManager", () => { "FN-2164", expect.stringContaining("Auto-retry 1/3"), ); - expect(store.moveTask).toHaveBeenCalledWith("FN-2164", "todo", { preserveProgress: true }); + expect(store.moveTask).toHaveBeenCalledWith("FN-2164", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true }); managerWithRecovery.stop(); }); @@ -3912,7 +3918,7 @@ describe("SelfHealingManager", () => { "FN-1572", expect.stringContaining("in-review task still had incomplete steps"), ); - expect(store.moveTask).toHaveBeenCalledWith("FN-1572", "todo", { preserveProgress: true }); + expect(store.moveTask).toHaveBeenCalledWith("FN-1572", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true }); managerWithRecovery.stop(); }); @@ -3971,7 +3977,7 @@ describe("SelfHealingManager", () => { const result = await managerWithRecovery.recoverStaleIncompleteReviewTasks(); expect(result).toBe(1); - expect(store.moveTask).toHaveBeenCalledWith("FN-407-test-1", "todo", { preserveProgress: true }); + expect(store.moveTask).toHaveBeenCalledWith("FN-407-test-1", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true }); managerWithRecovery.stop(); }); @@ -3999,7 +4005,7 @@ describe("SelfHealingManager", () => { const result = await managerWithRecovery.recoverStaleIncompleteReviewTasks(); expect(result).toBe(1); - expect(store.moveTask).toHaveBeenCalledWith("FN-407-test-2", "todo", { preserveProgress: true }); + expect(store.moveTask).toHaveBeenCalledWith("FN-407-test-2", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true }); managerWithRecovery.stop(); }); @@ -5808,7 +5814,7 @@ describe("SelfHealingManager", () => { expect(result).toBe(1); expect(store.updateTask).not.toHaveBeenCalled(); - expect(store.moveTask).toHaveBeenCalledWith("FN-9003", "todo", { preserveProgress: true }); + expect(store.moveTask).toHaveBeenCalledWith("FN-9003", "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true }); managerWithRecovery.stop(); }); diff --git a/packages/engine/src/__tests__/workflow-graph-fanout.test.ts b/packages/engine/src/__tests__/workflow-graph-fanout.test.ts new file mode 100644 index 0000000000..7cc468b16d --- /dev/null +++ b/packages/engine/src/__tests__/workflow-graph-fanout.test.ts @@ -0,0 +1,360 @@ +import { describe, expect, it, vi } from "vitest"; +import type { TaskDetail, WorkflowIr, WorkflowIrNode } from "@fusion/core"; + +import { WorkflowGraphExecutor, type WorkflowNodeHandler } from "../workflow-graph-executor.js"; +import type { + WorkflowBranchPersistence, + WorkflowBranchProgress, + WorkflowBranchRunState, +} from "../workflow-graph-branches.js"; + +const task = { id: "FN-FANOUT" } as TaskDetail; +const settingsOn = () => ({ experimentalFeatures: { workflowGraphExecutor: true } }); + +/** A controllable deferred so branches can complete in any order under test control. */ +function deferred() { + let resolve!: (v: T) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +/** start → split → (branchA → branchB) → join → tail → end */ +function twoBranchIr(joinConfig: Record): WorkflowIr { + return { + version: "v2", + name: "two-branch", + columns: [{ id: "work", name: "Work", traits: [] }], + nodes: [ + { id: "start", kind: "start" }, + { id: "split", kind: "split", column: "work" }, + { id: "branchA", kind: "prompt", column: "work", config: { prompt: "a" } }, + { id: "branchB", kind: "prompt", column: "work", config: { prompt: "b" } }, + { id: "join", kind: "join", column: "work", config: joinConfig }, + { id: "tail", kind: "prompt", column: "work", config: { prompt: "tail" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "split" }, + { from: "split", to: "branchA" }, + { from: "split", to: "branchB" }, + { from: "branchA", to: "join", condition: "success" }, + { from: "branchB", to: "join", condition: "success" }, + { from: "join", to: "tail", condition: "success" }, + { from: "join", to: "end", condition: "failure" }, + { from: "tail", to: "end", condition: "success" }, + ], + }; +} + +describe("WorkflowGraphExecutor fan-out/join (U13)", () => { + it("mode:all — both branches complete in any order, join fires once, advances to tail", async () => { + const a = deferred(); + const b = deferred(); + const tail = vi.fn(async () => ({ outcome: "success" as const })); + const prompt: WorkflowNodeHandler = async (node) => { + if (node.id === "branchA") await a.promise; + if (node.id === "branchB") await b.promise; + if (node.id === "tail") return tail(); + return { outcome: "success" as const }; + }; + const executor = new WorkflowGraphExecutor({ handlers: { prompt } }); + const run = executor.run(task, settingsOn(), twoBranchIr({ mode: "all" })); + + // Complete in reverse order to prove order-independence. + b.resolve(); + await Promise.resolve(); + expect(tail).not.toHaveBeenCalled(); + a.resolve(); + + const result = await run; + expect(result.outcome).toBe("success"); + expect(result.visitedNodeIds).toContain("tail"); + expect(tail).toHaveBeenCalledTimes(1); + }); + + it("mode:any with collect — first completion fires join; slower branch finishes without re-firing", async () => { + const slow = deferred(); + const tail = vi.fn(async () => ({ outcome: "success" as const })); + let slowFinished = false; + const prompt: WorkflowNodeHandler = async (node) => { + if (node.id === "branchB") { + await slow.promise; + slowFinished = true; + } + if (node.id === "tail") return tail(); + return { outcome: "success" as const }; + }; + const executor = new WorkflowGraphExecutor({ handlers: { prompt } }); + const run = executor.run(task, settingsOn(), twoBranchIr({ mode: "any", onBranchFailure: "collect" })); + + // branchA resolves immediately → join fires. tail must run exactly once. + await Promise.resolve(); + slow.resolve(); + const result = await run; + + expect(result.outcome).toBe("success"); + expect(tail).toHaveBeenCalledTimes(1); + expect(slowFinished).toBe(true); + }); + + it("mode:any with fail-fast — slower branch is aborted via signal", async () => { + let aborted = false; + const slow = deferred(); + const prompt: WorkflowNodeHandler = async (node, ctx) => { + if (node.id === "branchB") { + ctx.signal?.addEventListener("abort", () => { + aborted = true; + slow.resolve(); + }); + await slow.promise; + if (ctx.signal?.aborted) return { outcome: "failure" as const, value: "aborted" }; + } + return { outcome: "success" as const }; + }; + const executor = new WorkflowGraphExecutor({ handlers: { prompt } }); + const result = await executor.run(task, settingsOn(), twoBranchIr({ mode: "any", onBranchFailure: "fail-fast" })); + + expect(result.outcome).toBe("success"); + expect(aborted).toBe(true); + }); + + it("quorum(2) of 3 — join fires on the second completion", async () => { + const ir: WorkflowIr = { + version: "v2", + name: "quorum", + columns: [{ id: "w", name: "W", traits: [] }], + nodes: [ + { id: "start", kind: "start" }, + { id: "split", kind: "split" }, + { id: "b1", kind: "prompt", config: {} }, + { id: "b2", kind: "prompt", config: {} }, + { id: "b3", kind: "prompt", config: {} }, + { id: "join", kind: "join", config: { mode: { quorum: 2 }, onBranchFailure: "collect" } }, + { id: "tail", kind: "prompt", config: {} }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "split" }, + { from: "split", to: "b1" }, + { from: "split", to: "b2" }, + { from: "split", to: "b3" }, + { from: "b1", to: "join", condition: "success" }, + { from: "b2", to: "join", condition: "success" }, + { from: "b3", to: "join", condition: "success" }, + { from: "join", to: "tail", condition: "success" }, + { from: "join", to: "end", condition: "failure" }, + { from: "tail", to: "end" }, + ], + }; + const d3 = deferred(); + const tail = vi.fn(async () => ({ outcome: "success" as const })); + const prompt: WorkflowNodeHandler = async (node) => { + if (node.id === "b3") await d3.promise; + if (node.id === "tail") return tail(); + return { outcome: "success" as const }; + }; + const executor = new WorkflowGraphExecutor({ handlers: { prompt } }); + const run = executor.run(task, settingsOn(), ir); + // b1 + b2 resolve immediately → quorum(2) satisfied without b3. + await Promise.resolve(); + d3.resolve(); + const result = await run; + expect(result.outcome).toBe("success"); + expect(tail).toHaveBeenCalledTimes(1); + }); + + it("branch failure fail-fast — siblings aborted, join routes the failure edge", async () => { + let siblingAborted = false; + const slow = deferred(); + const prompt: WorkflowNodeHandler = async (node, ctx) => { + if (node.id === "branchA") return { outcome: "failure" as const, value: "boom" }; + if (node.id === "branchB") { + ctx.signal?.addEventListener("abort", () => { + siblingAborted = true; + slow.resolve(); + }); + await slow.promise; + return { outcome: "success" as const }; + } + return { outcome: "success" as const }; + }; + const executor = new WorkflowGraphExecutor({ handlers: { prompt } }); + const result = await executor.run(task, settingsOn(), twoBranchIr({ mode: "all", onBranchFailure: "fail-fast" })); + + expect(result.outcome).toBe("failure"); + expect(result.visitedNodeIds).not.toContain("tail"); + expect(siblingAborted).toBe(true); + }); + + it("branch failure collect — all branches finish; join evaluates combined outcomes", async () => { + const calls: string[] = []; + const prompt: WorkflowNodeHandler = async (node) => { + calls.push(node.id); + if (node.id === "branchA") return { outcome: "failure" as const, value: "boom" }; + return { outcome: "success" as const }; + }; + const executor = new WorkflowGraphExecutor({ handlers: { prompt } }); + const result = await executor.run(task, settingsOn(), twoBranchIr({ mode: "all", onBranchFailure: "collect" })); + + // mode:all unmet (one failed) → join outcome failure; both branches ran. + expect(result.outcome).toBe("failure"); + expect(calls).toContain("branchA"); + expect(calls).toContain("branchB"); + const branchOutcomes = result.context["node:join:branchOutcomes"] as { outcome: string }[]; + expect(branchOutcomes.some((b) => b.outcome === "failure")).toBe(true); + expect(branchOutcomes.some((b) => b.outcome === "success")).toBe(true); + }); + + it("crash mid-branch resume — completed branches' nodes are NOT re-run", async () => { + const calls: string[] = []; + const store: WorkflowBranchRunState[] = []; + const persistence: WorkflowBranchPersistence = { + saveBranchState: (s) => { + const idx = store.findIndex((e) => e.branchId === s.branchId); + if (idx >= 0) store[idx] = s; + else store.push({ ...s }); + }, + loadBranchStates: () => store.map((s) => ({ ...s })), + }; + + // First run: branchA completes, branchB hangs (simulated crash before join). + const hang = deferred(); + const aPersisted = deferred(); + const persistenceA: WorkflowBranchPersistence = { + saveBranchState: (s) => { + persistence.saveBranchState!(s); + if (s.branchId === "branchA" && s.status === "completed") aPersisted.resolve(); + }, + loadBranchStates: persistence.loadBranchStates, + }; + const prompt1: WorkflowNodeHandler = async (node) => { + calls.push(`run1:${node.id}`); + if (node.id === "branchB") await hang.promise; // never resolves this run + return { outcome: "success" as const }; + }; + const exec1 = new WorkflowGraphExecutor({ handlers: { prompt: prompt1 }, branchPersistence: persistenceA }); + const run1 = exec1.run(task, settingsOn(), twoBranchIr({ mode: "all" })); + await aPersisted.promise; + // Don't await run1 (branchB stuck) — simulate process death by starting fresh. + + expect(store.find((s) => s.branchId === "branchA")?.status).toBe("completed"); + + // Resume: a brand-new executor reconstructed from persisted rows. + const prompt2: WorkflowNodeHandler = async (node) => { + calls.push(`run2:${node.id}`); + return { outcome: "success" as const }; + }; + const exec2 = new WorkflowGraphExecutor({ handlers: { prompt: prompt2 }, branchPersistence: persistence }); + const result = await exec2.run(task, settingsOn(), twoBranchIr({ mode: "all" })); + + expect(result.outcome).toBe("success"); + // branchA already completed → not re-run on resume. + expect(calls).not.toContain("run2:branchA"); + // branchB re-runs (it never completed). + expect(calls).toContain("run2:branchB"); + hang.resolve(); + await run1.catch(() => {}); + }); + + it("card-position invariant — no column move occurs during the parallel window", async () => { + // The executor never touches task.column; assert the handler context exposes + // the split's column to all branch nodes and the task object is untouched. + const columnsSeen = new Set(); + const taskColumnBefore = (task as { column?: string }).column; + const prompt: WorkflowNodeHandler = async (node) => { + if (node.id.startsWith("branch")) columnsSeen.add(node.column); + return { outcome: "success" as const }; + }; + const executor = new WorkflowGraphExecutor({ handlers: { prompt } }); + await executor.run(task, settingsOn(), twoBranchIr({ mode: "all" })); + // Branch nodes live in the split's column; task position never forked. + expect(columnsSeen).toEqual(new Set(["work"])); + expect((task as { column?: string }).column).toBe(taskColumnBefore); + }); + + it("semaphore bound — branches queue, never exceeding the limit (fake semaphore)", async () => { + let active = 0; + let peak = 0; + const limit = 1; + const queue: (() => void)[] = []; + const fakeSemaphore = { + async run(fn: () => Promise): Promise { + if (active >= limit) await new Promise((res) => queue.push(res)); + active += 1; + peak = Math.max(peak, active); + try { + return await fn(); + } finally { + active -= 1; + queue.shift()?.(); + } + }, + }; + const executor = new WorkflowGraphExecutor({ + handlers: { prompt: async () => ({ outcome: "success" as const }) }, + branchSemaphore: fakeSemaphore, + }); + const result = await executor.run(task, settingsOn(), twoBranchIr({ mode: "all" })); + expect(result.outcome).toBe("success"); + expect(peak).toBeLessThanOrEqual(limit); + }); + + it("nested split resolves recursively", async () => { + // start → split(outer) → [ branchX | split(inner) → [i1 | i2] → joinInner ] → joinOuter → end + const ir: WorkflowIr = { + version: "v2", + name: "nested", + columns: [{ id: "w", name: "W", traits: [] }], + nodes: [ + { id: "start", kind: "start" }, + { id: "outer", kind: "split" }, + { id: "branchX", kind: "prompt", config: {} }, + { id: "inner", kind: "split" }, + { id: "i1", kind: "prompt", config: {} }, + { id: "i2", kind: "prompt", config: {} }, + { id: "joinInner", kind: "join", config: { mode: "all" } }, + { id: "joinOuter", kind: "join", config: { mode: "all" } }, + { id: "end", kind: "end" }, + ], + edges: [ + { from: "start", to: "outer" }, + { from: "outer", to: "branchX" }, + { from: "outer", to: "inner" }, + { from: "branchX", to: "joinOuter", condition: "success" }, + { from: "inner", to: "i1" }, + { from: "inner", to: "i2" }, + { from: "i1", to: "joinInner", condition: "success" }, + { from: "i2", to: "joinInner", condition: "success" }, + { from: "joinInner", to: "joinOuter", condition: "success" }, + { from: "joinOuter", to: "end", condition: "success" }, + ], + }; + const calls: string[] = []; + const executor = new WorkflowGraphExecutor({ + handlers: { + prompt: async (node) => { + calls.push(node.id); + return { outcome: "success" as const }; + }, + }, + }); + const result = await executor.run(task, settingsOn(), ir); + expect(result.outcome).toBe("success"); + expect(calls).toEqual(expect.arrayContaining(["branchX", "i1", "i2"])); + }); + + it("reports live per-branch progress for the dashboard", async () => { + const progress: WorkflowBranchProgress[] = []; + const executor = new WorkflowGraphExecutor({ + handlers: { prompt: async () => ({ outcome: "success" as const }) }, + onBranchProgress: (p) => progress.push(p), + }); + await executor.run(task, settingsOn(), twoBranchIr({ mode: "all" })); + expect(progress.some((p) => p.branchId === "branchA" && p.status === "completed")).toBe(true); + expect(progress.some((p) => p.branchId === "branchB" && p.status === "completed")).toBe(true); + }); +}); diff --git a/packages/engine/src/__tests__/workflow-graph-task-runner.test.ts b/packages/engine/src/__tests__/workflow-graph-task-runner.test.ts index 2d2f5ed4d7..9e865bf9d3 100644 --- a/packages/engine/src/__tests__/workflow-graph-task-runner.test.ts +++ b/packages/engine/src/__tests__/workflow-graph-task-runner.test.ts @@ -242,4 +242,66 @@ describe("WorkflowGraphTaskRunner (CU-U2)", () => { const result = await runner.run(task, flagOn); expect(result.disposition).toBe("completed"); }); + + // #1407/#1412: the runner forwards its injected branchPersistence into the + // WorkflowGraphExecutor, which writes per-branch state and prunes stale runs. + // Uses a real in-memory persistence whose method shape matches the store- + // backed adapter the production executor builds (saveBranchState / + // loadBranchStates / clearStaleBranchStates) — no mock of a nonexistent API. + function fanoutIr(): WorkflowIr { + return { + version: "v1", + name: "fanout", + nodes: [ + { id: "start", kind: "start" }, + { id: "split", kind: "split" }, + { id: "a", kind: "prompt", config: { prompt: "a" } }, + { id: "b", kind: "prompt", config: { prompt: "b" } }, + { id: "join", kind: "join", config: { mode: "all" } }, + { id: "zend", kind: "end" }, + ], + edges: [ + { from: "start", to: "split" }, + { from: "split", to: "a" }, + { from: "split", to: "b" }, + { from: "a", to: "join" }, + { from: "b", to: "join" }, + { from: "join", to: "zend", condition: "success" }, + ], + }; + } + + it("forwards branchPersistence to the executor: writes branch state and prunes stale runs", async () => { + const saved: Array<{ branchId: string; currentNodeId: string; status: string }> = []; + const pruneCalls: Array<{ taskId: string; keepRunId: string }> = []; + const persistence = { + saveBranchState: (s: { branchId: string; currentNodeId: string; status: string }) => { + saved.push({ branchId: s.branchId, currentNodeId: s.currentNodeId, status: s.status }); + }, + loadBranchStates: () => [], + clearStaleBranchStates: (taskId: string, keepRunId: string) => { + pruneCalls.push({ taskId, keepRunId }); + }, + }; + + const runner = new WorkflowGraphTaskRunner({ + store: storeWith(definition(fanoutIr())), + seams: recordingSeams([]), + runCustomNode: async () => ({ outcome: "success" }), + branchPersistence: persistence, + }); + + const result = await runner.run(task, flagOn); + expect(result.disposition).toBe("completed"); + + // Both branches persisted, and each reached "completed" at the join. + expect(saved.some((s) => s.branchId === "a")).toBe(true); + expect(saved.some((s) => s.branchId === "b")).toBe(true); + expect(saved.some((s) => s.status === "completed")).toBe(true); + + // Prune ran (on start AND completion) keyed by the runner's runId. + expect(pruneCalls.length).toBeGreaterThanOrEqual(2); + expect(pruneCalls.every((c) => c.taskId === task.id)).toBe(true); + expect(pruneCalls.every((c) => c.keepRunId === `${task.id}:WF-001`)).toBe(true); + }); }); diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 630cd99639..1b05ae665f 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -12,6 +12,8 @@ import { existsSync } from "node:fs"; import { createHash } from "node:crypto"; import { join, relative, resolve } from "node:path"; import type { AgentStore, AgentState, AgentCapability, AgentUpdateInput, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore } from "@fusion/core"; +import { listTraits } from "@fusion/core"; +import { promoteHeldTask } from "./hold-release.js"; import { DASHBOARD_USER_ID, canAgentTakeImplementationTaskForExplicitRouting, dailyMemoryPath, ensureOpenClawMemoryFiles, extractAgentProvisioningRequest, formatRoleMismatchReason, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, reconcileDeterministicDuplicate, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTaskGithubTracking, resolveTitleSummarizerSettingsModel, runDeterministicDuplicateGuard, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh, summarizeTitle } from "@fusion/core"; import { ResearchOrchestrator } from "./research-orchestrator.js"; import { ResearchProviderRegistry } from "./research/provider-registry.js"; @@ -74,6 +76,47 @@ export const workflowSelectParams = Type.Object({ ), }); +export const taskPromoteParams = Type.Object({ + task_id: Type.Optional( + Type.String({ description: "Held task to promote. Defaults to the current task." }), + ), +}); + +export const workflowCreateParams = Type.Object({ + name: Type.String({ description: "Workflow name (required, non-empty)." }), + description: Type.Optional(Type.String({ description: "Optional human-readable description." })), + ir: Type.Unknown({ + description: + "Workflow graph (intermediate representation). Validated server-side; a malformed graph is rejected.", + }), + layout: Type.Optional( + Type.Record(Type.String(), Type.Unknown(), { + description: "Optional node layout map keyed by node id.", + }), + ), +}); + +export const workflowUpdateParams = Type.Object({ + workflow_id: Type.String({ description: "The workflow definition ID to update (built-ins cannot be edited)." }), + name: Type.Optional(Type.String({ description: "New name." })), + description: Type.Optional(Type.String({ description: "New description." })), + ir: Type.Optional(Type.Unknown({ description: "Replacement workflow graph (validated server-side)." })), + layout: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Replacement node layout map." })), + rehome_to: Type.Optional( + Type.String({ + description: + "When an IR update removes a column that still holds cards, supply the column id to re-home those occupants into. " + + "Required to resolve an OccupiedColumns conflict; the target must exist in the new IR.", + }), + ), +}); + +export const workflowDeleteParams = Type.Object({ + workflow_id: Type.String({ description: "The workflow definition ID to delete (built-ins cannot be deleted)." }), +}); + +export const traitListParams = Type.Object({}); + export const reflectOnPerformanceParams = Type.Object({ focus_area: Type.Optional( Type.String({ description: "Optional focus area for reflection (e.g., 'code quality', 'speed', 'testing')" }), @@ -1006,13 +1049,23 @@ export function createWorkflowSelectTool(store: TaskStore, currentTaskId: string execute: async (_id: string, params: Static) => { const taskId = params.task_id?.trim() || currentTaskId; try { - const enabled = await store.selectTaskWorkflow(taskId, params.workflow_id); + const { enabledWorkflowSteps: enabled, reconciliation } = + await store.selectTaskWorkflowAndReconcile(taskId, params.workflow_id); + const stepSummary = `${enabled.length} step${enabled.length === 1 ? "" : "s"} enabled`; + // Surface the reconciliation outcome so the agent observes any re-home: + // a preserved card stays put; an unpreserved card moves fromColumn→toColumn. + const rehomeNote = + reconciliation && !reconciliation.preserved && reconciliation.fromColumn !== reconciliation.toColumn + ? ` Re-homed from '${reconciliation.fromColumn}' to '${reconciliation.toColumn}'.` + : reconciliation + ? ` Card preserved in '${reconciliation.toColumn}'.` + : ""; return { content: [{ type: "text" as const, - text: `Selected workflow ${params.workflow_id} for ${taskId} (${enabled.length} step${enabled.length === 1 ? "" : "s"} enabled).`, + text: `Selected workflow ${params.workflow_id} for ${taskId} (${stepSummary}).${rehomeNote}`, }], - details: { taskId, workflowId: params.workflow_id, enabledWorkflowSteps: enabled }, + details: { taskId, workflowId: params.workflow_id, enabledWorkflowSteps: enabled, reconciliation }, }; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (err: any) { @@ -1026,6 +1079,239 @@ export function createWorkflowSelectTool(store: TaskStore, currentTaskId: string }; } +/** + * Create a `fn_task_promote` tool that manually releases a held task out of its + * hold column — the agent-native equivalent of the dashboard's "promote" action. + * Defaults to the current task. Wraps {@link promoteHeldTask}. + */ +export function createTaskPromoteTool(store: TaskStore, currentTaskId: string): ToolDefinition { + return { + name: "fn_task_promote", + label: "Promote Held Task", + description: + "Manually promote a held task out of its hold column, releasing it regardless of the " + + "hold's release kind (the explicit operator action a 'manual' hold waits for). Defaults " + + "to the current task. Returns the destination column, or a rejection reason when the task " + + "is not held or the destination is full.", + parameters: taskPromoteParams, + execute: async (_id: string, params: Static) => { + const taskId = params.task_id?.trim() || currentTaskId; + try { + const outcome = await promoteHeldTask(store, taskId); + if (outcome.released) { + return { + content: [{ + type: "text" as const, + text: `Promoted ${taskId} to column '${outcome.toColumn}'.`, + }], + details: { taskId, released: true, toColumn: outcome.toColumn }, + }; + } + return { + content: [{ + type: "text" as const, + text: `ERROR: Could not promote ${taskId}: ${outcome.rejection ?? "unknown"}.`, + }], + details: { taskId, released: false, rejection: outcome.rejection }, + isError: true, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + return { + content: [{ type: "text" as const, text: `ERROR: Failed to promote task: ${err?.message ?? err}` }], + details: {}, + isError: true, + }; + } + }, + }; +} + +/** + * Create a `fn_workflow_create` tool — a thin wrapper over the store's workflow + * definition create. The IR is validated server-side; a malformed graph rejects. + */ +export function createWorkflowCreateTool(store: TaskStore): ToolDefinition { + return { + name: "fn_workflow_create", + label: "Create Workflow", + description: + "Create a new custom workflow definition from a name and a workflow graph (IR). " + + "The IR is validated server-side. Returns the new workflow ID.", + parameters: workflowCreateParams, + execute: async (_id: string, params: Static) => { + try { + const created = await store.createWorkflowDefinition({ + name: params.name, + description: params.description, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ir: params.ir as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + layout: params.layout as any, + }); + return { + content: [{ type: "text" as const, text: `Created workflow ${created.id} (${created.name}).` }], + details: { workflowId: created.id, name: created.name }, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + return { + content: [{ type: "text" as const, text: `ERROR: Failed to create workflow: ${err?.message ?? err}` }], + details: {}, + isError: true, + }; + } + }, + }; +} + +/** + * Create a `fn_workflow_update` tool — a thin wrapper over the store's workflow + * definition update. When an IR change removes a still-occupied column, the store + * throws an OccupiedColumnsError; we surface it as a structured response carrying + * the per-column occupant counts so the agent can retry with `rehome_to`. + */ +export function createWorkflowUpdateTool(store: TaskStore): ToolDefinition { + return { + name: "fn_workflow_update", + label: "Update Workflow", + description: + "Update a custom workflow definition (name/description/ir/layout). Built-ins cannot be edited. " + + "If an IR change removes a column that still holds cards, the update is blocked and returns the " + + "occupied columns — retry with rehome_to set to a column id that survives in the new IR.", + parameters: workflowUpdateParams, + execute: async (_id: string, params: Static) => { + try { + const updated = await store.updateWorkflowDefinition(params.workflow_id, { + name: params.name, + description: params.description, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ir: params.ir as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + layout: params.layout as any, + rehomeTo: params.rehome_to, + }); + return { + content: [{ type: "text" as const, text: `Updated workflow ${updated.id} (${updated.name}).` }], + details: { workflowId: updated.id, name: updated.name }, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + // Surface the typed OccupiedColumnsError as a structured, retryable result. + if (err?.name === "OccupiedColumnsError") { + const occupancies = err.occupancies ?? []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const summary = occupancies.map((o: any) => `${o.columnId} (${o.count})`).join(", "); + return { + content: [{ + type: "text" as const, + text: + `ERROR: Update removes occupied column(s): ${summary}. ` + + `Retry with rehome_to set to a surviving column id.`, + }], + details: { occupiedColumns: occupancies, workflowId: err.workflowId, retryWith: "rehome_to" }, + isError: true, + }; + } + return { + content: [{ type: "text" as const, text: `ERROR: Failed to update workflow: ${err?.message ?? err}` }], + details: {}, + isError: true, + }; + } + }, + }; +} + +/** + * Create a `fn_workflow_delete` tool — a thin wrapper over the store's workflow + * definition delete. Surfaces built-in protection and not-found errors as + * structured responses. (The store auto-re-homes occupants to the default + * workflow on delete, so no rehome target is required here.) + */ +export function createWorkflowDeleteTool(store: TaskStore): ToolDefinition { + return { + name: "fn_workflow_delete", + label: "Delete Workflow", + description: + "Delete a custom workflow definition. Built-ins cannot be deleted. Any tasks using it have " + + "their selection cleared and are re-homed to the default workflow's entry column.", + parameters: workflowDeleteParams, + execute: async (_id: string, params: Static) => { + try { + await store.deleteWorkflowDefinition(params.workflow_id); + return { + content: [{ type: "text" as const, text: `Deleted workflow ${params.workflow_id}.` }], + details: { workflowId: params.workflow_id }, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + if (err?.name === "OccupiedColumnsError") { + const occupancies = err.occupancies ?? []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const summary = occupancies.map((o: any) => `${o.columnId} (${o.count})`).join(", "); + return { + content: [{ + type: "text" as const, + text: `ERROR: Delete blocked by occupied column(s): ${summary}.`, + }], + details: { occupiedColumns: occupancies, workflowId: err.workflowId }, + isError: true, + }; + } + return { + content: [{ type: "text" as const, text: `ERROR: Failed to delete workflow: ${err?.message ?? err}` }], + details: {}, + isError: true, + }; + } + }, + }; +} + +/** + * Create a `fn_trait_list` tool that returns the trait catalog from + * {@link listTraits} — the column-behavior building blocks (id, name, flags) + * used when authoring workflow columns. + */ +export function createTraitListTool(): ToolDefinition { + return { + name: "fn_trait_list", + label: "List Traits", + description: + "List the available column traits (the behavior building blocks for workflow columns): " + + "id, name, description, and behavior flags. Use when authoring or updating a workflow IR.", + parameters: traitListParams, + execute: async () => { + try { + const traits = listTraits(); + if (traits.length === 0) { + return { + content: [{ type: "text" as const, text: "No traits are registered." }], + details: { traits: [] }, + }; + } + const lines = traits.map( + (t) => `- ${t.id}: ${t.name}${t.description ? ` — ${t.description}` : ""}`, + ); + return { + content: [{ type: "text" as const, text: `Available traits:\n${lines.join("\n")}` }], + details: { + traits: traits.map((t) => ({ id: t.id, name: t.name, description: t.description, flags: t.flags })), + }, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + return { + content: [{ type: "text" as const, text: `ERROR: Failed to list traits: ${err?.message ?? err}` }], + details: {}, + isError: true, + }; + } + }, + }; +} + export function createMemorySearchTool(rootDir: string, settings?: MemoryToolSettings, options?: MemoryToolOptions): ToolDefinition { return { name: "fn_memory_search", diff --git a/packages/engine/src/concurrency.ts b/packages/engine/src/concurrency.ts index 8d0ba1f1b3..6bdef4a97b 100644 --- a/packages/engine/src/concurrency.ts +++ b/packages/engine/src/concurrency.ts @@ -111,6 +111,22 @@ export class AgentSemaphore { }); } + /** + * Synchronously reserve a slot if one is immediately available, without + * queuing. Returns true (and bumps `activeCount`) when a slot was taken, + * false when the semaphore is full. Used by the U6 hold/release sweep's + * reservation-first ordering (KTD-10): reserve worktree + semaphore BEFORE + * issuing a release move, and {@link release} the reservation if the move + * rejects on capacity. Unlike {@link acquire} it never enqueues a waiter. + */ + tryAcquire(): boolean { + if (this._active < this.limit) { + this._active++; + return true; + } + return false; + } + /** * Release a previously acquired slot and unblock the next waiting caller * (if any). diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index b2d88c30b7..c0013945d0 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -17,6 +17,7 @@ import { type WorkflowRunObservation, } from "@fusion/core"; import { WorkflowGraphTaskRunner, type WorkflowGraphTaskRunResult } from "./workflow-graph-task-runner.js"; +import type { WorkflowBranchPersistence, WorkflowBranchRunState } from "./workflow-graph-branches.js"; import { observeWorkflowParity, WORKFLOW_INTERPRETER_DUAL_OBSERVE_FLAG } from "./workflow-parity-observer.js"; import type { WorkflowLegacySeams } from "./workflow-node-handlers.js"; import type { WorkflowNodeResult } from "./workflow-graph-executor.js"; @@ -145,6 +146,11 @@ import { createTaskLogTool as sharedCreateTaskLogTool, createWorkflowListTool as sharedCreateWorkflowListTool, createWorkflowSelectTool as sharedCreateWorkflowSelectTool, + createTaskPromoteTool as sharedCreateTaskPromoteTool, + createWorkflowCreateTool as sharedCreateWorkflowCreateTool, + createWorkflowUpdateTool as sharedCreateWorkflowUpdateTool, + createWorkflowDeleteTool as sharedCreateWorkflowDeleteTool, + createTraitListTool as sharedCreateTraitListTool, } from "./agent-tools.js"; import { getTaskCompletionBlockerForStore } from "./task-completion.js"; import { createStreamingDeltaNormalizer } from "./streaming-delta.js"; @@ -3254,6 +3260,12 @@ export class TaskExecutor { seams: this.createGraphSeams(settings), runCustomNode: (node, nodeTask) => this.runGraphCustomNode(node, nodeTask, settings), onEvent: (event) => executorLog.log(`[workflow-graph] ${event.type} ${event.taskId}: ${event.detail}`), + // Wire SQLite-backed per-branch persistence in production (#1407): the + // executor writes each branch's currentNodeId/status to + // workflow_run_branches so fan-out crash-resume and the U9 badges have + // real data, and prunes stale runs (#1412). Adapter degrades to no-op + // when the store predates these methods (additive guard). + branchPersistence: this.buildBranchPersistence(), }); let result: WorkflowGraphTaskRunResult; try { @@ -3280,6 +3292,27 @@ export class TaskExecutor { } } + /** + * Build the store-backed WorkflowBranchPersistence wired into production + * fan-out runs (#1407/#1412). Returns undefined when the store predates the + * persistence methods (older embedded DBs) so the runner stays fully + * in-memory — purely additive. Each adapter method is itself guarded so a + * mixed/partial store never throws into the run. + */ + private buildBranchPersistence(): WorkflowBranchPersistence | undefined { + const store = this.store as unknown as { + saveWorkflowRunBranch?: (state: WorkflowBranchRunState) => void; + loadWorkflowRunBranches?: (taskId: string, runId: string) => WorkflowBranchRunState[]; + clearWorkflowRunBranches?: (taskId: string, keepRunId: string) => void; + }; + if (typeof store.saveWorkflowRunBranch !== "function") return undefined; + return { + saveBranchState: (state) => store.saveWorkflowRunBranch?.(state), + loadBranchStates: (taskId, runId) => store.loadWorkflowRunBranches?.(taskId, runId) ?? [], + clearStaleBranchStates: (taskId, keepRunId) => store.clearWorkflowRunBranches?.(taskId, keepRunId), + }; + } + /** * Dual-observe parity (CU-U5): for a workflow-selected task, compare the * selected graph's routing against the legacy authoritative run for the SAME @@ -4790,6 +4823,11 @@ export class TaskExecutor { this.createTaskDocumentReadTool(task.id), this.createWorkflowListTool(), this.createWorkflowSelectTool(task.id), + this.createTaskPromoteTool(task.id), + this.createWorkflowCreateTool(), + this.createWorkflowUpdateTool(), + this.createWorkflowDeleteTool(), + this.createTraitListTool(), ...(isResearchToolSurfaceEnabled(settings) ? createResearchTools({ store: this.store, @@ -6549,6 +6587,26 @@ export class TaskExecutor { return sharedCreateWorkflowSelectTool(this.store, taskId); } + private createTaskPromoteTool(taskId: string): ToolDefinition { + return sharedCreateTaskPromoteTool(this.store, taskId); + } + + private createWorkflowCreateTool(): ToolDefinition { + return sharedCreateWorkflowCreateTool(this.store); + } + + private createWorkflowUpdateTool(): ToolDefinition { + return sharedCreateWorkflowUpdateTool(this.store); + } + + private createWorkflowDeleteTool(): ToolDefinition { + return sharedCreateWorkflowDeleteTool(this.store); + } + + private createTraitListTool(): ToolDefinition { + return sharedCreateTraitListTool(); + } + private createTaskAddDepTool(taskId: string): ToolDefinition { const store = this.store; return { diff --git a/packages/engine/src/hold-release.ts b/packages/engine/src/hold-release.ts new file mode 100644 index 0000000000..a265cb923f --- /dev/null +++ b/packages/engine/src/hold-release.ts @@ -0,0 +1,552 @@ +/** + * Hold/release sweep — the generalized scheduler (U6, KTD-10, R3 behavior half). + * + * Flag-ON, the scheduler's poll becomes a *hold/release sweep*: for each + * workflow in use by live tasks, it finds cards resting at `hold`-trait columns + * and evaluates their release condition: + * + * - `manual` — released ONLY by an explicit {@link promoteHeldTask} + * call (U9's promote endpoint / CLI). The sweep never + * auto-releases a manual hold. + * - `external-event` — released ONLY by {@link releaseHeldTaskByEvent} (a + * webhook/API release, same shape as manual + an event + * tag). + * - `timer` — released when the injected clock passes the hold's + * deadline (`columnMovedAt + durationMs`, or an explicit + * `deadlineAt`). Fake-timer friendly (FN-5048): the clock + * is injected, never `Date.now()` baked in. + * - `capacity` — released when a downstream capacity (`wip`) column has a + * free slot (same counting rules as the in-txn check). + * - `dependency` — released when the card's dependencies are satisfied + * (KTD-5: dependency task's column has the `complete` + * trait flag in ITS resolved workflow; FN-5719 dual-accept + * also honors the legacy completion signal, logging an + * audit-diff when the two disagree). + * + * Eligible cards move via `store.moveTask(..., { moveSource: "scheduler" })`. + * A scheduler move bypasses trait guards (it is substrate-driven) but the in-txn + * capacity check is NOT a guard — it still runs (KTD-10), so two holds racing + * into one slot serialize: exactly one commits, the other rejects with + * `capacity-exhausted` and retries next sweep. + * + * Reservation ordering (KTD-10): for releases into a processing (capacity) + * column, the sweep reserves worktree + semaphore slots BEFORE issuing the move + * and releases the reservation if the move rejects on capacity — a card is never + * moved into a column it cannot actually start in, and a semaphore-exhausted + * interleaving leaves the card held with no commit. + */ + +import { + isWorkflowColumnsEnabled, + resolveColumnCapacity, + resolveColumnFlags, + resolveColumnAdjacency, + DEFAULT_WORKFLOW_POOL_ID, + TransitionRejectionError, + resolveWorkflowIrForTask, + type TaskStore, + type Task, + type WorkflowIr, + type WorkflowIrV2, + type WorkflowIrColumn, +} from "@fusion/core"; +import { schedulerLog } from "./logger.js"; + +/** A reservation handle returned by {@link HoldReleaseDeps.reserveSlot}. The + * sweep calls `release()` if the subsequent move rejects on capacity. */ +export interface SlotReservation { + release(): void; +} + +/** Injected dependencies so the sweep stays unit-testable with fake timers and + * without real worktree/session allocation. */ +export interface HoldReleaseDeps { + /** Monotonic clock (ms). Inject a fake-timer-driven clock in tests; production + * passes `() => Date.now()`. */ + now: () => number; + /** + * Reserve a worktree + semaphore slot for a card about to be released into a + * processing column (KTD-10 reservation-first). Returns `null` when no slot + * could be reserved (e.g. semaphore exhausted) — the sweep then leaves the + * card held without issuing a move. Returns a {@link SlotReservation} whose + * `release()` the sweep calls if the move rejects on capacity. + * + * Optional: when absent, releases into processing columns proceed without a + * reservation (the in-txn capacity check still arbitrates), which is the + * default-workflow legacy parity path where the scheduler dispatch loop owns + * worktree allocation via `allocateWorktree`. + */ + reserveSlot?: (task: Task, targetColumn: string) => SlotReservation | null; + /** Allocate a worktree path for a release into a processing column (passed + * through to `moveTask`'s `allocateWorktree`). */ + allocateWorktree?: (task: Task, reservedNames: Set) => string | null; +} + +/** Outcome of one sweep pass (for tests + observability). */ +export interface HoldReleaseResult { + released: string[]; + /** taskId → reason it stayed held this pass. */ + held: Array<{ taskId: string; reason: string }>; +} + +// ── Workflow IR resolution (read-only) ──────────────────────────────────────── +// The selection → builtin/custom → default rule lives in @fusion/core's +// resolveWorkflowIrForTask (GitHub #1402); the optional per-sweep irCache Map is +// threaded straight through. + +function effectiveWorkflowId(store: TaskStore, taskId: string): string { + try { + return store.getTaskWorkflowSelection(taskId)?.workflowId ?? DEFAULT_WORKFLOW_POOL_ID; + } catch { + return DEFAULT_WORKFLOW_POOL_ID; + } +} + +function findColumn(ir: WorkflowIr, columnId: string): WorkflowIrColumn | undefined { + if (ir.version !== "v2") return undefined; + return (ir as WorkflowIrV2).columns.find((c) => c.id === columnId); +} + +/** The hold trait config on a column, if any. */ +function resolveHoldConfig(column: WorkflowIrColumn): Record | undefined { + const flags = resolveColumnFlags(column); + if (!flags.hold) return undefined; + const ct = column.traits.find((t) => t.trait === "hold"); + return ct?.config ?? {}; +} + +/** True when the card currently rests at a hold column. */ +function isHeldTask(ir: WorkflowIr, task: Task): boolean { + const column = findColumn(ir, task.column); + if (!column) return false; + return resolveColumnFlags(column).hold === true; +} + +/** + * Resolve the release target column for a held card. + * + * For `capacity` holds, the target is the nearest downstream column (by the + * workflow's column adjacency, breadth-first from the hold column) that carries + * a capacity (`wip`) trait — for the default workflow this is `in-progress`. + * For other release kinds the target is the first adjacency neighbor that is not + * the hold column itself (the forward step out of the hold). + */ +function resolveReleaseTarget(ir: WorkflowIr, fromColumn: string, preferCapacity: boolean): string | undefined { + const v2 = ir as WorkflowIrV2; + const orderedIds = Array.isArray(v2.columns) ? v2.columns.map((c) => c.id) : []; + const fromIdx = orderedIds.indexOf(fromColumn); + const adjacency = resolveColumnAdjacency(ir); + const neighbors = adjacency.get(fromColumn) ?? []; + + if (preferCapacity) { + // Walk FORWARD in declared order for the nearest capacity-bearing column; + // the hold releases downstream, never backward. + for (let i = fromIdx + 1; i < orderedIds.length; i++) { + const col = findColumn(ir, orderedIds[i]); + if (col && resolveColumnFlags(col).countsTowardWip && neighbors.includes(orderedIds[i])) { + return orderedIds[i]; + } + } + // No directly-adjacent capacity column: fall back to the nearest forward + // capacity column reachable via adjacency BFS. + const seen = new Set([fromColumn]); + const queue = [...neighbors]; + while (queue.length > 0) { + const candidate = queue.shift()!; + if (seen.has(candidate)) continue; + seen.add(candidate); + const col = findColumn(ir, candidate); + if (col && resolveColumnFlags(col).countsTowardWip) return candidate; + for (const next of adjacency.get(candidate) ?? []) { + if (!seen.has(next)) queue.push(next); + } + } + } + + // Forward neighbor (declared-order next) if it is adjacent; else any neighbor + // that is forward in declared order; else the first neighbor. + const forwardId = fromIdx >= 0 ? orderedIds[fromIdx + 1] : undefined; + if (forwardId && neighbors.includes(forwardId)) return forwardId; + const forwardNeighbor = neighbors.find((n) => orderedIds.indexOf(n) > fromIdx); + if (forwardNeighbor) return forwardNeighbor; + return neighbors.find((n) => n !== fromColumn); +} + +// ── Dependency satisfaction (KTD-5 + FN-5719 dual-accept) ───────────────────── + +/** Legacy completion signal: dependency's column is a terminal/handoff column. */ +function legacyDependencySatisfied(dep: Task): boolean { + return dep.column === "done" || dep.column === "in-review" || dep.column === "archived"; +} + +/** + * KTD-5 dependency satisfaction: the dependency task's current column has the + * `complete` trait flag in ITS resolved workflow. Dual-accept (FN-5719): the + * legacy completion signal (done/in-review/archived column, or an accepted + * completion-handoff marker) is also honored; when the two disagree an + * audit-diff event is logged. + */ +async function dependencySatisfied(store: TaskStore, dep: Task): Promise { + const ir = await resolveWorkflowIrForTask(store, dep.id); + const column = findColumn(ir, dep.column); + const completeFlag = column ? resolveColumnFlags(column).complete === true : false; + + let markerAccepted = false; + try { + markerAccepted = store.getCompletionHandoffAcceptedMarker(dep.id) !== null; + } catch { + markerAccepted = false; + } + const legacy = legacyDependencySatisfied(dep) || markerAccepted; + + if (completeFlag !== legacy) { + try { + void store.recordRunAuditEvent?.({ + taskId: dep.id, + agentId: "scheduler", + runId: `hold-release:${dep.id}`, + domain: "database", + mutationType: "merge:dependency-parity-diff", + target: dep.id, + metadata: { + depId: dep.id, + completeFlagResult: completeFlag, + legacyResult: legacy, + source: "hold-release.dependency", + }, + }); + } catch { + // Audit is best-effort. + } + } + // Dual-accept: satisfied if EITHER signal says so (the dual-accept window + // closes at graduation per U12; until then both are accepted). + return completeFlag || legacy; +} + +async function allDependenciesSatisfied(store: TaskStore, task: Task, allTasks: Task[]): Promise { + for (const depId of task.dependencies ?? []) { + const dep = allTasks.find((t) => t.id === depId); + if (!dep) continue; // missing dep does not block (matches scheduler posture) + if (!(await dependencySatisfied(store, dep))) return false; + } + return true; +} + +// ── Timer release ───────────────────────────────────────────────────────────── + +/** Resolve the timer deadline (ms epoch) for a timer hold, or `undefined` if not + * resolvable. Supports an explicit `deadlineAt` (ISO or ms) or a relative + * `durationMs`/`timerMs` measured from `columnMovedAt`. */ +function resolveTimerDeadline(holdConfig: Record, task: Task): number | undefined { + const deadlineAt = holdConfig.deadlineAt; + if (typeof deadlineAt === "number" && Number.isFinite(deadlineAt)) return deadlineAt; + if (typeof deadlineAt === "string") { + const parsed = Date.parse(deadlineAt); + if (Number.isFinite(parsed)) return parsed; + } + const duration = + (typeof holdConfig.durationMs === "number" ? holdConfig.durationMs : undefined) ?? + (typeof holdConfig.timerMs === "number" ? holdConfig.timerMs : undefined); + if (typeof duration === "number" && Number.isFinite(duration)) { + const base = Date.parse(task.columnMovedAt ?? task.createdAt); + if (Number.isFinite(base)) return base + duration; + } + return undefined; +} + +// ── Capacity availability (same counting rule as the in-txn check) ──────────── + +/** + * Count cards occupying the (workflow, column) capacity slot from a task + * snapshot, mirroring the store's in-txn count: cards in the column now, plus + * (when countPending) cards mid-`transitionPending` targeting it, scoped to the + * SAME effective workflow. This is the sweep's *pre-check* — the authoritative + * arbitration is still the in-txn check, which rejects a losing racer. + */ +function countCapacitySlot( + allTasks: Task[], + // Pre-built taskId → effective workflowId map (one pass per sweep) so this + // counting loop avoids a per-task `effectiveWorkflowId` DB call. + effectiveWorkflowIdByTask: Map, + targetColumn: string, + workflowId: string, + countPending: boolean, +): number { + let count = 0; + for (const t of allTasks) { + if ((effectiveWorkflowIdByTask.get(t.id) ?? DEFAULT_WORKFLOW_POOL_ID) !== workflowId) continue; + if (t.column === targetColumn) { + count += 1; + continue; + } + if (!countPending) continue; + const tp = (t as Task & { transitionPending?: { toColumn?: string } | null }).transitionPending; + if (tp && typeof tp === "object" && tp.toColumn === targetColumn) count += 1; + } + return count; +} + +// ── The sweep ───────────────────────────────────────────────────────────────── + +/** + * Run one hold/release sweep pass. No-op (returns empty) when the workflowColumns + * flag is OFF — flag-OFF scheduler behavior is byte-identical (the legacy + * pull-from-todo loop is untouched). + */ +export async function runHoldReleaseSweep( + store: TaskStore, + deps: HoldReleaseDeps, +): Promise { + const result: HoldReleaseResult = { released: [], held: [] }; + + const settings = await store.getSettings(); + if (!isWorkflowColumnsEnabled(settings)) return result; + + const allTasks = await store.listTasks({ includeArchived: false }); + + // Per-sweep caches. `allTasks` is a snapshot-stable read within a sweep, so we + // resolve each workflow's IR at most once (irCache) and pre-build the + // taskId → effective-workflowId map a single time rather than per-task DB + // calls inside the capacity counting loop. The authoritative in-txn capacity + // check is unaffected — this only trims the sweep pre-check cost. + const irCache = new Map(); + const effectiveWorkflowIdByTask = new Map(); + for (const t of allTasks) { + effectiveWorkflowIdByTask.set(t.id, effectiveWorkflowId(store, t.id)); + } + + for (const task of allTasks) { + // Skip paused / recovery-backoff tasks exactly as the legacy scheduler does. + if (task.paused || task.userPaused) { + continue; + } + if (task.nextRecoveryAt && Date.parse(task.nextRecoveryAt) > deps.now()) { + continue; + } + + const ir = await resolveWorkflowIrForTask(store, task.id, irCache); + if (!isHeldTask(ir, task)) continue; + + const column = findColumn(ir, task.column); + const holdConfig = column ? resolveHoldConfig(column) : undefined; + if (!column || !holdConfig) continue; + const release = typeof holdConfig.release === "string" ? holdConfig.release : "manual"; + + // manual / external-event are NEVER auto-released by the sweep. + if (release === "manual" || release === "external-event") { + result.held.push({ taskId: task.id, reason: `${release}-only` }); + continue; + } + + let shouldRelease = false; + if (release === "timer") { + const deadline = resolveTimerDeadline(holdConfig, task); + shouldRelease = deadline !== undefined && deps.now() >= deadline; + if (!shouldRelease) { + result.held.push({ taskId: task.id, reason: "timer-not-elapsed" }); + continue; + } + } else if (release === "dependency") { + shouldRelease = await allDependenciesSatisfied(store, task, allTasks); + if (!shouldRelease) { + result.held.push({ taskId: task.id, reason: "deps-unsatisfied" }); + continue; + } + } else if (release === "capacity") { + // Capacity holds release into the nearest downstream capacity column when a + // slot is free (pre-check); the in-txn check is the authority. + const target = resolveReleaseTarget(ir, task.column, true); + if (!target) { + result.held.push({ taskId: task.id, reason: "no-downstream-capacity-column" }); + continue; + } + const capacity = resolveColumnCapacity(ir, target, settings); + if (capacity.hasCapacity && Number.isFinite(capacity.limit)) { + const workflowId = effectiveWorkflowIdByTask.get(task.id) ?? DEFAULT_WORKFLOW_POOL_ID; + const occupants = countCapacitySlot(allTasks, effectiveWorkflowIdByTask, target, workflowId, capacity.countPending); + if (occupants >= capacity.limit) { + result.held.push({ taskId: task.id, reason: "downstream-full" }); + continue; + } + } + shouldRelease = true; + } + + if (!shouldRelease) continue; + + const target = resolveReleaseTarget(ir, task.column, release === "capacity"); + if (!target) { + result.held.push({ taskId: task.id, reason: "no-release-target" }); + continue; + } + + const released = await issueRelease(store, deps, task, target, ir); + if (released) { + result.released.push(task.id); + } else { + result.held.push({ taskId: task.id, reason: "move-rejected-or-no-slot" }); + } + } + + return result; +} + +/** + * Issue a single release move (`moveSource: "scheduler"`). For releases into a + * processing (capacity) column the reservation-first ordering (KTD-10) reserves + * worktree + semaphore before the move and releases the reservation if the move + * rejects on capacity. Returns true on a committed move, false otherwise (the + * card stays held). + */ +async function issueRelease( + store: TaskStore, + deps: HoldReleaseDeps, + task: Task, + target: string, + ir: WorkflowIr, +): Promise { + const targetColumn = findColumn(ir, target); + const targetIsProcessing = targetColumn ? resolveColumnFlags(targetColumn).countsTowardWip === true : false; + + let reservation: SlotReservation | null = null; + if (targetIsProcessing && deps.reserveSlot) { + reservation = deps.reserveSlot(task, target); + if (!reservation) { + // Semaphore/worktree exhausted — reservation-first means no move at all. + schedulerLog.log(`Hold release for ${task.id} deferred — no reservable slot for ${target}`); + return false; + } + } + + // A concurrent sweep (or explicit promote) can win the move for this same card + // while we hold a reservation. The store serializes the move under a per-task + // lock and resolves a redundant same-column move to a silent no-op: it returns + // the card already at the target WITHOUT re-allocating a slot or emitting a + // `task:moved`. A snapshot/pre-read can't tell winner from loser (both reads + // race ahead of either commit on the per-task lock). Instead we attribute the + // transition by OBJECT IDENTITY: a real move emits `task:moved` with the very + // Task object it then returns, whereas a no-op returns a freshly-read object + // and emits nothing. So the call whose `moveTask` result IS the emitted task is + // the real mover; any other call that reserved performed a redundant no-op and + // must release the slot it grabbed (FN-1415). + const movedTaskObjects = new Set(); + const onMoved = (data: { task: object; to: string }): void => { + if (data.to === target) movedTaskObjects.add(data.task); + }; + store.on("task:moved", onMoved); + + try { + const result = await store.moveTask(task.id, target, { + moveSource: "scheduler", + allocateWorktree: + targetIsProcessing && deps.allocateWorktree + ? (reservedNames) => deps.allocateWorktree!(task, reservedNames) + : undefined, + }); + if (reservation && !movedTaskObjects.has(result)) { + // Same-column no-op: a racing sweep already moved this card to the target. + reservation.release(); + schedulerLog.log(`Hold release for ${task.id} skipped — already at ${target} (racing sweep won)`); + return false; + } + return true; + } catch (error) { + if (error instanceof TransitionRejectionError && error.rejection.code === "capacity-exhausted") { + // Lost the in-txn race for the slot — release the reservation, stay held. + reservation?.release(); + schedulerLog.log(`Hold release for ${task.id} rejected on capacity for ${target} — staying held`); + return false; + } + // Any other failure: release the reservation and let the card stay held. + reservation?.release(); + schedulerLog.warn( + `Hold release for ${task.id} into ${target} failed: ${error instanceof Error ? error.message : String(error)}`, + ); + return false; + } finally { + store.off("task:moved", onMoved); + } +} + +// ── Explicit (manual / external-event) releases ─────────────────────────────── + +/** + * Manually promote a held card out of its hold column (U9's promote endpoint / + * CLI calls this). Releases regardless of the hold's release kind — a manual + * promote is the explicit operator action the `manual` release kind waits for, + * and it is also accepted for other kinds as an operator override. The move + * still serializes through the in-txn capacity check (KTD-10): a promote into a + * full column rejects with `capacity-exhausted`, surfaced to the caller. + */ +export async function promoteHeldTask( + store: TaskStore, + taskId: string, + deps: Pick = {}, +): Promise<{ released: boolean; toColumn?: string; rejection?: string }> { + const task = await store.getTask(taskId); + if (!task) return { released: false, rejection: "task-not-found" }; + + const ir = await resolveWorkflowIrForTask(store, taskId); + if (!isHeldTask(ir, task)) { + return { released: false, rejection: "not-held" }; + } + const target = resolveReleaseTarget(ir, task.column, true); + if (!target) return { released: false, rejection: "no-release-target" }; + + const released = await issueRelease( + store, + { now: () => Date.now(), reserveSlot: deps.reserveSlot, allocateWorktree: deps.allocateWorktree }, + task, + target, + ir, + ); + return released ? { released: true, toColumn: target } : { released: false, rejection: "capacity-exhausted-or-no-slot" }; +} + +/** + * Release a held card on an external event (webhook/API). Same shape as + * {@link promoteHeldTask} plus an `eventTag` recorded in the audit; only acts on + * `external-event` holds (a no-op otherwise so a stray webhook can't release a + * manual/timer/capacity hold). + */ +export async function releaseHeldTaskByEvent( + store: TaskStore, + taskId: string, + eventTag: string, + deps: Pick = {}, +): Promise<{ released: boolean; toColumn?: string; rejection?: string }> { + const task = await store.getTask(taskId); + if (!task) return { released: false, rejection: "task-not-found" }; + + const ir = await resolveWorkflowIrForTask(store, taskId); + const column = findColumn(ir, task.column); + const holdConfig = column ? resolveHoldConfig(column) : undefined; + if (!column || !holdConfig || holdConfig.release !== "external-event") { + return { released: false, rejection: "not-external-event-hold" }; + } + try { + void store.recordRunAuditEvent?.({ + taskId, + agentId: "scheduler", + runId: `hold-release:event:${taskId}`, + domain: "database", + mutationType: "task:hold-release-event", + target: taskId, + metadata: { eventTag, fromColumn: task.column }, + }); + } catch { + // best-effort + } + const target = resolveReleaseTarget(ir, task.column, true); + if (!target) return { released: false, rejection: "no-release-target" }; + + const released = await issueRelease( + store, + { now: () => Date.now(), reserveSlot: deps.reserveSlot, allocateWorktree: deps.allocateWorktree }, + task, + target, + ir, + ); + return released ? { released: true, toColumn: target } : { released: false, rejection: "capacity-exhausted-or-no-slot" }; +} diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index d864128c60..9eb88b2391 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -25,6 +25,13 @@ export { type WorkflowGraphExecutorDeps, type WorkflowGraphExecutorResult, } from "./workflow-graph-executor.js"; +export { + runSplitJoin, + type WorkflowBranchPersistence, + type WorkflowBranchProgress, + type WorkflowBranchRunState, + type WorkflowBranchSemaphore, +} from "./workflow-graph-branches.js"; export { createDefaultNodeHandlers, createNoopLegacySeams, @@ -63,6 +70,13 @@ export { getConflictedFiles, type AutostashHandle, } from "./merger.js"; +export { + registerMergeTraitHooks, + resolveMergePolicy, + type ResolvedMergePolicy, + type MergeFileScopeMode, + type MergeTraitStrategy, +} from "./merge-trait.js"; export { resolveIntegrationBranch, resolveIntegrationBranchSync, @@ -449,6 +463,17 @@ export { HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext } from ". export { TokenCapDetector, type TokenCapCheckResult } from "./token-cap-detector.js"; export { SelfHealingManager, type SelfHealingOptions, type RebindResult } from "./self-healing.js"; export { PluginRunner, type PluginRunnerOptions } from "./plugin-runner.js"; +export { + registerPluginTraits, + degradePluginTraits, + unregisterPluginTraits, + findLivePluginTraitDependents, + pluginTraitToDefinition, + pluginTraitRegistryId, + evaluatePluginGate, + PluginTraitHasDependentsError, + type PluginTraitDependent, +} from "./plugin-trait-adapter.js"; // Agent runtime abstraction export { type AgentRuntime, type AgentRuntimeOptions, type AgentSessionResult } from "./agent-runtime.js"; export { @@ -517,6 +542,16 @@ export { } from "./remote-access/index.js"; export { RemoteNodeClient } from "./runtimes/remote-node-client.js"; export { RemoteNodeRuntime, type RemoteNodeRuntimeConfig } from "./runtimes/remote-node-runtime.js"; +// Hold/release sweep + manual promote (U6/U9). Exported so the dashboard +// promote endpoint can release a manually-held card via the same authority. +export { + promoteHeldTask, + releaseHeldTaskByEvent, + runHoldReleaseSweep, + type HoldReleaseDeps, + type HoldReleaseResult, + type SlotReservation, +} from "./hold-release.js"; export { StepSessionExecutor } from "./step-session-executor.js"; export type { StepResult, ParallelWave, StepSessionExecutorOptions } from "./step-session-executor.js"; // Multi-project runtime types diff --git a/packages/engine/src/merge-trait.ts b/packages/engine/src/merge-trait.ts new file mode 100644 index 0000000000..a300d64590 --- /dev/null +++ b/packages/engine/src/merge-trait.ts @@ -0,0 +1,261 @@ +/** + * Merge trait behavior (U7, R10) — `@fusion/engine` side. + * + * The merge trait turns merge/PR orchestration, merge strategy, squash posture + * and file-scope enforcement mode into *configuration* over the substrate merge + * capability (KTD-6). This module owns two things: + * + * 1. The merge trait's hook implementations, registered into core's trait + * registry via the `registerTraitHookImpl` DI seam (mirrors + * `setCreateFnAgent`): + * - `onEnter` → enqueue the task onto the *persisted* merge-request + * queue (reuse the store's existing enqueue path). It NEVER awaits a + * merge inline; completion is driven by the merge-queue worker loop + * (`ProjectEngine.pickNextMergeTaskId` → `aiMergeTask` → + * `store.moveTask(id, "done")`) and resolved via the queue, so a + * graph walk / transition never blocks on a merge (the plan-002 + * deadlock hazard). + * - `onExit` → leaving the merge column dequeues a pending request. + * The store already performs this in-lock inside `moveTaskInternal` + * (`dequeueMergeQueueOnColumnExit`, a private method); the hook + * delegates to that existing mechanism rather than reimplementing the + * dequeue (see the onExit impl note). It is registered so the registry + * resolves a real impl (not a degraded no-op + audit warning). + * + * 2. `resolveMergePolicy` — a small read-through resolver consulted by + * `merger.ts` at its existing policy-knob read sites. When the + * `workflowColumns` flag is ON it reads the merge-trait config from the + * task's resolved workflow; otherwise (and when the workflow's merge + * trait carries no config, e.g. the built-in default workflow) it falls + * back to the existing settings knobs (`directMergeCommitStrategy`, + * `mergeStrategy`, scope settings) for back-compat. + * + * The three 2026-05-23 lost-work guards stay in `merger.ts` mechanics and are + * UNREACHABLE from this config (KTD-6 / R10): sibling `fusion/fn-*` merge-target + * rejection, line-anchored commit attribution, and the no-op-finalize + * `modifiedFiles` preservation are not gated by any field this resolver + * exposes. + */ + +import { + isWorkflowColumnsEnabled, + registerTraitHookImpl, + resolveWorkflowIrForTask, + type DirectMergeCommitStrategy, + type Settings, + type Task, + type TaskStore, + type WorkflowIr, + type WorkflowIrColumn, +} from "@fusion/core"; +import { mergerLog } from "./logger.js"; + +// ── Resolved merge policy ──────────────────────────────────────────────────── + +/** File-scope enforcement mode (R10). `custom` evaluates `rules` in place of + * the task's File Scope section. */ +export type MergeFileScopeMode = "strict" | "warn" | "off" | "custom"; + +/** The merge strategy as authored on the trait. Direct-merge commit strategies + * plus `pr-only` (which routes to the pull-request flow without a direct + * merge). Absent on the trait → resolved from settings. */ +export type MergeTraitStrategy = DirectMergeCommitStrategy | "pr-only"; + +/** Fully-resolved merge policy consumed by `merger.ts`. */ +export interface ResolvedMergePolicy { + /** Direct-merge commit strategy. For `pr-only` this is the fallback used if + * a direct merge is ever taken; `pullRequestOnly` is the authoritative + * routing signal. */ + commitStrategy: DirectMergeCommitStrategy; + /** True when the trait authored `strategy: "pr-only"` — the merge is routed + * through the PR flow (enqueue-with-prState marker) without a direct merge. */ + pullRequestOnly: boolean; + /** File-scope enforcement mode. */ + fileScope: MergeFileScopeMode; + /** Custom scope rules (only meaningful when `fileScope === "custom"`). */ + fileScopeRules: string[]; + /** Where the policy came from — `workflow` when read from the task's merge + * trait config (flag ON), `settings` for the legacy/back-compat read-through. */ + source: "workflow" | "settings"; +} + +// ── Workflow IR resolution (read-only, flag-gated) ─────────────────────────── +// The selection → builtin/custom → default rule is shared via @fusion/core's +// resolveWorkflowIrForTask (GitHub #1402); a missing/corrupt definition degrades +// to the default workflow so policy resolution never throws. + +/** Find the column the task currently sits in (by id). */ +function findColumn(ir: WorkflowIr, columnId: string): WorkflowIrColumn | undefined { + if (ir.version !== "v2") return undefined; + return ir.columns.find((c) => c.id === columnId); +} + +/** Extract the merge trait's config from a column, if it carries one. */ +function readMergeTraitConfig(column: WorkflowIrColumn | undefined): Record | undefined { + if (!column) return undefined; + const ct = column.traits.find((t) => t.trait === "merge"); + if (!ct) return undefined; + return ct.config ?? {}; +} + +// ── Policy read-through resolver ───────────────────────────────────────────── + +const VALID_COMMIT_STRATEGIES: ReadonlySet = new Set([ + "auto", + "always-squash", + "always-rebase", +]); +const VALID_FILE_SCOPE_MODES: ReadonlySet = new Set(["strict", "warn", "off", "custom"]); + +/** The settings-only fallback policy (legacy / flag-OFF / no trait config). */ +function settingsPolicy(settings: Pick): ResolvedMergePolicy { + return { + commitStrategy: settings.directMergeCommitStrategy ?? "always-squash", + pullRequestOnly: settings.mergeStrategy === "pull-request", + // Legacy file-scope behavior is a soft warn (see + // `enforceSquashFileScopeInvariant`, which logs + proceeds), so the + // back-compat read-through reports `warn` — the existing call path is + // unchanged when the flag is OFF. + fileScope: "warn", + fileScopeRules: [], + source: "settings", + }; +} + +/** + * Resolve the effective merge policy for a task (R10). Flag ON: read the merge + * trait's config from the task's resolved workflow column; fall back to + * settings for any field the trait leaves unset (the built-in default + * workflow's merge trait carries no config, so it resolves entirely from + * settings — verbatim back-compat). Flag OFF: settings only. + * + * The lost-work guard trio is intentionally NOT represented here: no field this + * resolver returns can disable the sibling-branch rejection, line-anchored + * attribution, or the no-op-finalize `modifiedFiles` guard (KTD-6). + */ +export async function resolveMergePolicy( + store: TaskStore, + task: Pick, + settings?: Pick, +): Promise { + const resolvedSettings = settings ?? (await store.getSettings()); + const fallback = settingsPolicy(resolvedSettings); + + if (!isWorkflowColumnsEnabled(resolvedSettings)) { + return fallback; + } + + let config: Record | undefined; + try { + const ir = await resolveWorkflowIrForTask(store, task.id); + config = readMergeTraitConfig(findColumn(ir, task.column)); + } catch { + config = undefined; + } + // No merge trait, or a merge trait carrying no policy fields (e.g. the + // built-in default workflow's `{ trait: "merge" }` with no config) → resolve + // entirely from settings (verbatim back-compat). + if (!config || (config.strategy === undefined && config.fileScope === undefined)) { + return fallback; + } + + // strategy → commitStrategy + pullRequestOnly + let commitStrategy = fallback.commitStrategy; + let pullRequestOnly = fallback.pullRequestOnly; + const rawStrategy = config.strategy; + if (rawStrategy === "pr-only") { + pullRequestOnly = true; + } else if (typeof rawStrategy === "string" && VALID_COMMIT_STRATEGIES.has(rawStrategy)) { + commitStrategy = rawStrategy as DirectMergeCommitStrategy; + pullRequestOnly = false; + } + + // fileScope → mode + rules + let fileScope = fallback.fileScope; + const rawFileScope = config.fileScope; + if (typeof rawFileScope === "string" && VALID_FILE_SCOPE_MODES.has(rawFileScope)) { + fileScope = rawFileScope as MergeFileScopeMode; + } + const fileScopeRules = Array.isArray(config.rules) + ? (config.rules.filter((r): r is string => typeof r === "string")) + : []; + + return { + commitStrategy, + pullRequestOnly, + fileScope, + fileScopeRules, + source: "workflow", + }; +} + +// ── Merge trait hook implementations (DI into core's trait registry) ───────── + +/** + * onEnter: enqueue the task onto the persisted merge-request queue. NEVER awaits + * a merge (KTD-6) — the merge-queue worker loop drives the actual merge and the + * subsequent move to the `complete`-flagged column. Delegates to the store's + * existing `enqueueMergeQueue` so the queue mechanics (audit, priority, + * idempotent ON CONFLICT insert) are not reimplemented. + * + * Idempotent: `enqueueMergeQueue` is `ON CONFLICT(taskId) DO NOTHING`, so a + * crash-then-rerun (recovery sweep replaying `transitionPending` hooks) holds + * exactly one queue entry. + * + * Invoked by the store's post-commit hook runner with `(store, task)`. + */ +async function mergeOnEnter(store: TaskStore, task: Pick): Promise { + try { + store.enqueueMergeQueue(task.id, { priority: task.priority }); + } catch (err) { + // Enqueue rejects (e.g. task not in the merge column) degrade to a no-op: + // the card is never stranded and the queue is never corrupted. The store + // already audits the rejection. + const message = err instanceof Error ? err.message : String(err); + mergerLog.warn(`merge enqueue skipped for task ${task.id}: ${message}`); + } +} + +/** + * onExit: leaving the merge column dequeues a pending (unleased) request. + * + * NOTE (design / delegation): the store ALREADY performs dequeue-on-column-exit + * in-lock inside `moveTaskInternal` via the private + * `dequeueMergeQueueOnColumnExit`, which runs unconditionally on every move and + * owns the lease-aware semantics (drop an unleased entry; audit a leased one as + * a stale-lease event). The merge trait's onExit therefore *delegates to that + * existing mechanism* — it does not reissue a dequeue (which would be a + * redundant second pass and could not see the lease columns without a store API + * change the prompt forbids). Registering the hook makes the registry resolve a + * real impl (not a degraded no-op + audit warning) and documents that the + * substrate, not the trait, owns the dequeue mechanic (KTD-6: traits configure + * and invoke capabilities; they never reimplement them). + */ +function mergeOnExit(): void { + // Intentional no-op: dequeue is owned by the store's in-lock + // `dequeueMergeQueueOnColumnExit` (see note above). +} + +let registered = false; + +/** + * Register the merge trait's hook implementations into core's shared trait + * registry. Idempotent (guarded), so importing this module (or calling it from + * engine startup) more than once is safe. Mirrors the `setCreateFnAgent` DI + * pattern: core declares the hook descriptors; the engine supplies the impls. + */ +export function registerMergeTraitHooks(): void { + if (registered) return; + registered = true; + registerTraitHookImpl("merge", "onEnter", mergeOnEnter as never); + registerTraitHookImpl("merge", "onExit", mergeOnExit as never); +} + +/** Test-only: re-arm registration so a fresh registry can be exercised. */ +export function __resetMergeTraitRegistrationForTests(): void { + registered = false; +} + +// Register on import (idempotent) so the engine's trait registry resolves real +// merge-hook impls without a separate wiring call. +registerMergeTraitHooks(); diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index 49ab13c6de..a776e7cb86 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -92,6 +92,7 @@ import { normalizeMergeAdvanceAutoSyncMode, isMergeRequestContractShadowEnabled, } from "@fusion/core"; +import { resolveMergePolicy, type MergeFileScopeMode } from "./merge-trait.js"; import { describeModel, promptWithFallback } from "./pi.js"; import { accumulateSessionTokenUsage } from "./session-token-usage.js"; import { createResolvedAgentSession, extractRuntimeHint, resolveMergerSessionModel } from "./agent-session-helpers.js"; @@ -4930,10 +4931,16 @@ export async function assertSquashOverlapsFileScope(params: { taskId: string; rootDir: string; task: Task; + /** U7 (R10): when the merge trait's `fileScope: "custom"` mode is active, + * these glob/path rules replace the task's File Scope section as the + * declared scope. `scopeOverride` is a documented no-op only under + * `fileScope: "off"` (handled by the caller, which skips this assert). */ + customScopeRules?: string[]; }): Promise { - const { store, taskId, rootDir, task } = params; + const { store, taskId, rootDir, task, customScopeRules } = params; + const hasCustomRules = Array.isArray(customScopeRules) && customScopeRules.length > 0; - if (task.scopeOverride === true) { + if (!hasCustomRules && task.scopeOverride === true) { const reasonSuffix = task.scopeOverrideReason?.trim() ? ` — reason: ${task.scopeOverrideReason.trim()}` : ""; @@ -4947,11 +4954,16 @@ export async function assertSquashOverlapsFileScope(params: { return; } - if (typeof (store as Partial).parseFileScopeFromPrompt !== "function") { - return; + let declaredScope: string[]; + if (hasCustomRules) { + // Custom rules replace the parsed File Scope section entirely. + declaredScope = customScopeRules; + } else { + if (typeof (store as Partial).parseFileScopeFromPrompt !== "function") { + return; + } + declaredScope = await store.parseFileScopeFromPrompt(taskId); } - - const declaredScope = await store.parseFileScopeFromPrompt(taskId); if (declaredScope.length === 0) { return; } @@ -4986,12 +4998,70 @@ export async function enforceSquashFileScopeInvariant(params: { resetLabel: string; auditor?: RunAuditor; }): Promise { + // U7 (R10): resolve the file-scope enforcement mode from the merge trait + // (flag ON) or settings (back-compat). The lost-work guard trio is NOT gated + // by this mode — it lives elsewhere in the mechanics and stays enforced for + // every mode (KTD-6). + const policy = await resolveMergePolicy(params.store, params.task); + const mode: MergeFileScopeMode = policy.fileScope; + + if (mode === "off") { + // Skip the violation throw, but emit exactly one per-merge audit event + // recording that scope enforcement was disabled by workflow config. Per-task + // `scopeOverride` is a documented no-op in this mode (the scope check itself + // is disabled, so there is nothing to override). + if (params.auditor) { + try { + await params.auditor.git({ + type: "merge:file-scope-enforcement-disabled", + target: params.taskId, + metadata: { + resetLabel: params.resetLabel, + mode: "off", + disabledByWorkflowConfig: true, + scopeOverrideIsNoOp: params.task.scopeOverride === true, + }, + }); + } catch (auditErr) { + mergerLog.warn(`${params.taskId}: failed to emit run_audit event for file-scope-enforcement-disabled: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`); + } + } + return; + } + + const customScopeRules = mode === "custom" ? policy.fileScopeRules : undefined; + try { - await assertSquashOverlapsFileScope(params); + await assertSquashOverlapsFileScope({ ...params, customScopeRules }); } catch (error: unknown) { if (!(error instanceof FileScopeViolationError)) { throw error; } + // `strict` re-throws the violation (hard guardrail that blocks the merge); + // `warn`/`custom` log + proceed, with the audit carrying the violating file + // list (same payload as the error). + if (mode === "strict") { + if (params.auditor) { + try { + await params.auditor.git({ + type: "merge:file-scope-violation", + target: params.taskId, + metadata: { + resetLabel: params.resetLabel, + mode: "strict", + stagedFiles: error.stagedFiles, + declaredScope: error.declaredScope, + stagedFileCount: error.stagedFiles.length, + declaredScopeCount: error.declaredScope.length, + warningOnly: false, + }, + }); + } catch (auditErr) { + mergerLog.warn(`${params.taskId}: failed to emit run_audit event for FileScopeViolationError (strict): ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`); + } + } + throw error; + } const warningMessage = `${error.message} Warning only — continuing merge.`; await params.store.appendAgentLog( params.taskId, @@ -7534,6 +7604,11 @@ export async function aiMergeTask( const projectRootDir = rootDir; const settings = await store.getSettings(); + // U7 (R10): resolve the merge trait's policy (strategy / fileScope / rules) + // from the task's workflow when the workflowColumns flag is ON, falling back + // to the existing settings knobs otherwise. Read-through only — merge + // mechanics (and the non-configurable lost-work guard trio) are untouched. + const mergePolicy = await resolveMergePolicy(store, task, settings); const resolvedIntegrationBranch = await resolveIntegrationBranch(projectRootDir, settings); const groupRouting = await resolveBranchGroupMergeRouting({ task, @@ -9204,8 +9279,17 @@ export async function aiMergeTask( let selectedPostMergeAuditStrategy: PostMergeAuditStrategy = "squash"; let classifiedBranchCommits: BranchCommitClassification[] = []; - if (settings.mergeStrategy !== "pull-request") { - const configuredRoute = resolveDirectMergeCommitStrategy(settings, task.prompt); + // U7 (R10): `pr-only` authored on the merge trait routes through the PR flow + // exactly like `settings.mergeStrategy === "pull-request"` — no direct-merge + // commit routing runs. + const isPullRequestRoute = settings.mergeStrategy === "pull-request" || mergePolicy.pullRequestOnly; + if (!isPullRequestRoute) { + // When the workflow's merge trait authored a commit strategy, it takes + // precedence over the project/prompt setting (read-through, mechanics + // unchanged); otherwise fall back to the existing resolver. + const configuredRoute = mergePolicy.source === "workflow" + ? { strategy: mergePolicy.commitStrategy, source: "workflow" as const } + : resolveDirectMergeCommitStrategy(settings, task.prompt); if (configuredRoute.strategy === "auto") { try { const classification = await classifyBranchCommitsForDirectMerge( diff --git a/packages/engine/src/plugin-runner.ts b/packages/engine/src/plugin-runner.ts index 829a55ead8..e00648812e 100644 --- a/packages/engine/src/plugin-runner.ts +++ b/packages/engine/src/plugin-runner.ts @@ -21,6 +21,8 @@ import type { PluginContext, PluginSkillContribution, PluginWorkflowStepContribution, + PluginTraitContribution, + WorkflowIr, PluginPromptContribution, PluginPromptContributions, PluginPromptSurface, @@ -32,7 +34,21 @@ import type { import type { ToolDefinition } from "@earendil-works/pi-coding-agent"; import { Type } from "@earendil-works/pi-ai"; import { isAbsolute } from "node:path"; +import { + getTraitRegistry, + resolveWorkflowIrForTask, +} from "@fusion/core"; import { createLogger, executorLog } from "./logger.js"; +import type { WorkflowCustomNodeRunner } from "./workflow-node-handlers.js"; +import { + registerPluginTraits, + degradePluginTraits, + unregisterPluginTraits, + findLivePluginTraitDependents, + pluginTraitRegistryId, + PluginTraitHasDependentsError, + type PluginTraitDependent, +} from "./plugin-trait-adapter.js"; // Type for the task store's event data interface TaskMovedEvent { @@ -106,6 +122,11 @@ interface CachedWorkflowStepTemplates { version: number; } +interface CachedTraits { + traits: Array<{ pluginId: string; trait: PluginTraitContribution }>; + version: number; +} + interface CachedPromptContributions { contributions: Array<{ pluginId: string; @@ -133,6 +154,7 @@ export class PluginRunner { private cachedSkills: CachedSkills | null = null; private cachedWorkflowSteps: CachedWorkflowSteps | null = null; private cachedWorkflowStepTemplates: CachedWorkflowStepTemplates | null = null; + private cachedTraits: CachedTraits | null = null; private cachedPromptContributions: CachedPromptContributions | null = null; private cachedSetupInfo: CachedSetupInfo | null = null; private toolsCacheVersion = 0; @@ -144,7 +166,13 @@ export class PluginRunner { private skillsCacheVersion = 0; private workflowStepsCacheVersion = 0; private workflowStepTemplatesCacheVersion = 0; + private traitsCacheVersion = 0; private promptContributionsCacheVersion = 0; + /** Map of pluginId → the registry trait ids it currently has registered. */ + private registeredPluginTraitIds = new Map(); + /** The custom-node runner used to execute plugin trait hooks (set via + * setTraitHookRunner; mirrors how the executor wires runGraphCustomNode). */ + private traitHookRunner: WorkflowCustomNodeRunner | undefined; private setupCacheVersion = 0; private hookTimeoutMs: number; @@ -221,6 +249,7 @@ export class PluginRunner { this.invalidateSkillsCache(); this.invalidateWorkflowStepsCache(); this.invalidateWorkflowStepTemplatesCache(); + this.invalidateTraitsCache(); this.invalidatePromptContributionsCache(); this.invalidateSetupCache(); } @@ -359,6 +388,163 @@ export class PluginRunner { return this.cachedWorkflowSteps.steps; } + /** + * Get all plugin trait contributions with their plugin ids (U8). Aggregated / + * cached / invalidated exactly like workflow steps. + */ + getPluginTraits(): Array<{ pluginId: string; trait: PluginTraitContribution }> { + if (!this.cachedTraits || this.cachedTraits.version !== this.traitsCacheVersion) { + // Older loaders (and some test fakes) predate the traits API — degrade to + // an empty contribution set rather than crashing the runner. + const getter = this.options.pluginLoader.getPluginTraits; + this.cachedTraits = { + traits: typeof getter === "function" ? getter.call(this.options.pluginLoader) : [], + version: this.traitsCacheVersion, + }; + } + return this.cachedTraits.traits; + } + + /** + * Wire the custom-node runner that executes plugin trait hooks (gate / onEnter + * / onExit / releaseCondition) through the prompt-session/script machinery. + * The executor sets this the way it wires its own runGraphCustomNode. Must be + * set before traits are synced for hooks to actually run (otherwise the + * registry resolves declared hooks to the degraded no-op + audit path). + */ + setTraitHookRunner(runner: WorkflowCustomNodeRunner): void { + this.traitHookRunner = runner; + // Re-sync so already-loaded plugin traits pick up the runner. + this.syncPluginTraits(); + } + + /** + * Register all currently-loaded plugins' trait contributions into the core + * TraitRegistry (plugin-namespaced ids). Re-runs on cache invalidation. Traits + * for plugins no longer present are dropped from the registry (degraded path + * is the force-disable route; a clean unload removes them). + */ + syncPluginTraits(): void { + const registry = getTraitRegistry(); + const runner = this.traitHookRunner; + const current = this.getPluginTraits(); + + // Group contributions by plugin id. + const byPlugin = new Map(); + for (const { pluginId, trait } of current) { + const list = byPlugin.get(pluginId) ?? []; + list.push(trait); + byPlugin.set(pluginId, list); + } + + // Drop traits for plugins no longer present. + for (const [pluginId, ids] of [...this.registeredPluginTraitIds.entries()]) { + if (!byPlugin.has(pluginId)) { + unregisterPluginTraits(registry, ids); + this.registeredPluginTraitIds.delete(pluginId); + } + } + + if (!runner) { + // No runner yet: don't register hooks (they'd degrade to no-ops anyway). + // Definitions still register so the catalog/validation see them. + for (const [pluginId, contributions] of byPlugin) { + const ids = registerPluginTraits({ + registry, + pluginId, + contributions, + runCustomNode: async () => ({ outcome: "success" as const }), + }); + this.registeredPluginTraitIds.set(pluginId, ids); + } + return; + } + + for (const [pluginId, contributions] of byPlugin) { + try { + const ids = registerPluginTraits({ registry, pluginId, contributions, runCustomNode: runner }); + this.registeredPluginTraitIds.set(pluginId, ids); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.log.warn(`Failed to register traits for plugin '${pluginId}': ${msg}`); + } + } + } + + /** + * The live-dependents guard (KTD-7). Returns the tasks currently sitting in a + * column that uses one of the plugin's traits. A non-force disable/unregister + * with a non-empty result must be blocked; the force path degrades instead. + */ + async findPluginTraitDependents(pluginId: string): Promise { + const ids = this.collectPluginTraitRegistryIds(pluginId); + if (ids.length === 0) return []; + return findLivePluginTraitDependents({ + store: this.options.taskStore, + resolveTaskWorkflowIr: (taskId) => this.resolveTaskWorkflowIr(taskId), + pluginTraitIds: ids, + }); + } + + /** + * Disable a plugin's traits. With live dependents and `force !== true`, throws + * `PluginTraitHasDependentsError`. With `force`, degrades the columns to + * passive (hooks become no-ops + audit warning) and emits one audit event; + * cards remain movable. + */ + async disablePluginTraits(pluginId: string, opts?: { force?: boolean }): Promise<{ + degraded: string[]; + dependents: PluginTraitDependent[]; + }> { + const registry = getTraitRegistry(); + const ids = this.collectPluginTraitRegistryIds(pluginId); + const dependents = await this.findPluginTraitDependents(pluginId); + if (dependents.length > 0 && !opts?.force) { + throw new PluginTraitHasDependentsError(pluginId, dependents); + } + const degraded = degradePluginTraits(registry, ids); + if (degraded.length > 0) { + try { + this.options.taskStore.recordRunAuditEvent({ + agentId: "system", + runId: `plugin-trait-degrade-${pluginId}-${Date.now()}`, + domain: "database", + mutationType: "plugin:trait-degraded", + target: pluginId, + metadata: { + pluginId, + degradedTraitIds: degraded, + affectedTasks: dependents.map((d) => d.taskId), + note: "hooks now resolve to no-ops; cards remain movable", + }, + }); + } catch { + // Audit is best-effort; degradation already applied. + } + } + return { degraded, dependents }; + } + + /** Collect the registry trait ids for a plugin (from the registration map, or + * derived from current contributions as a fallback). */ + private collectPluginTraitRegistryIds(pluginId: string): string[] { + const tracked = this.registeredPluginTraitIds.get(pluginId); + if (tracked && tracked.length > 0) return tracked; + return this.getPluginTraits() + .filter((t) => t.pluginId === pluginId) + .map((t) => pluginTraitRegistryId(pluginId, t.trait.traitId)); + } + + /** + * Resolve a task's workflow IR through the shared @fusion/core resolver + * (selection → builtin/custom → default fallback) on the public store surface + * — the adapter never reaches into store internals (GitHub #1402; previously a + * divergent raw-SQL copy via getDatabase()). + */ + private resolveTaskWorkflowIr(taskId: string): Promise { + return resolveWorkflowIrForTask(this.options.taskStore, taskId); + } + getPluginWorkflowStepTemplates(): Array<{ pluginId: string; template: WorkflowStepTemplate }> { if (!this.cachedWorkflowStepTemplates || this.cachedWorkflowStepTemplates.version !== this.workflowStepTemplatesCacheVersion) { this.cachedWorkflowStepTemplates = { @@ -572,6 +758,7 @@ export class PluginRunner { this.invalidateSkillsCache(); this.invalidateWorkflowStepsCache(); this.invalidateWorkflowStepTemplatesCache(); + this.invalidateTraitsCache(); this.invalidatePromptContributionsCache(); this.invalidateSetupCache(); executorLog.log(`Plugin ${pluginId} reloaded`); @@ -593,6 +780,7 @@ export class PluginRunner { this.invalidateSkillsCache(); this.invalidateWorkflowStepsCache(); this.invalidateWorkflowStepTemplatesCache(); + this.invalidateTraitsCache(); this.invalidatePromptContributionsCache(); this.invalidateSetupCache(); @@ -619,6 +807,7 @@ export class PluginRunner { this.invalidateSkillsCache(); this.invalidateWorkflowStepsCache(); this.invalidateWorkflowStepTemplatesCache(); + this.invalidateTraitsCache(); this.invalidatePromptContributionsCache(); this.invalidateSetupCache(); @@ -645,6 +834,7 @@ export class PluginRunner { this.invalidateSkillsCache(); this.invalidateWorkflowStepsCache(); this.invalidateWorkflowStepTemplatesCache(); + this.invalidateTraitsCache(); this.invalidatePromptContributionsCache(); this.invalidateSetupCache(); @@ -670,6 +860,7 @@ export class PluginRunner { this.invalidateSkillsCache(); this.invalidateWorkflowStepsCache(); this.invalidateWorkflowStepTemplatesCache(); + this.invalidateTraitsCache(); this.invalidatePromptContributionsCache(); this.invalidateSetupCache(); } @@ -687,6 +878,7 @@ export class PluginRunner { this.invalidateSkillsCache(); this.invalidateWorkflowStepsCache(); this.invalidateWorkflowStepTemplatesCache(); + this.invalidateTraitsCache(); this.invalidatePromptContributionsCache(); this.invalidateSetupCache(); } @@ -704,6 +896,7 @@ export class PluginRunner { this.invalidateSkillsCache(); this.invalidateWorkflowStepsCache(); this.invalidateWorkflowStepTemplatesCache(); + this.invalidateTraitsCache(); this.invalidatePromptContributionsCache(); this.invalidateSetupCache(); } @@ -721,6 +914,7 @@ export class PluginRunner { this.invalidateSkillsCache(); this.invalidateWorkflowStepsCache(); this.invalidateWorkflowStepTemplatesCache(); + this.invalidateTraitsCache(); this.invalidatePromptContributionsCache(); this.invalidateSetupCache(); } @@ -738,6 +932,7 @@ export class PluginRunner { this.invalidateSkillsCache(); this.invalidateWorkflowStepsCache(); this.invalidateWorkflowStepTemplatesCache(); + this.invalidateTraitsCache(); this.invalidatePromptContributionsCache(); this.invalidateSetupCache(); } @@ -970,6 +1165,14 @@ export class PluginRunner { this.log.log(`Workflow step templates cache invalidated (version: ${this.workflowStepTemplatesCacheVersion})`); } + private invalidateTraitsCache(): void { + this.traitsCacheVersion++; + this.log.log(`Plugin traits cache invalidated (version: ${this.traitsCacheVersion})`); + // Re-register/deregister plugin traits in the core registry to match the + // newly-loaded/unloaded set (mirrors the workflow-step contribution flow). + this.syncPluginTraits(); + } + private invalidatePromptContributionsCache(): void { this.promptContributionsCacheVersion++; this.log.log(`Prompt contributions cache invalidated (version: ${this.promptContributionsCacheVersion})`); diff --git a/packages/engine/src/plugin-trait-adapter.ts b/packages/engine/src/plugin-trait-adapter.ts new file mode 100644 index 0000000000..42c9dcb57c --- /dev/null +++ b/packages/engine/src/plugin-trait-adapter.ts @@ -0,0 +1,276 @@ +/** + * Plugin trait adapter (U8, R6/R15/R22, KTD-7). + * + * Bridges plugin-contributed traits (`PluginTraitContribution`) into core's + * `TraitRegistry` and routes their executable hooks through the SAME + * prompt-session / script / verdict machinery contributed workflow STEPS use. + * + * Design (mirrors the workflow-step contribution pattern): + * - Plugin trait ids are namespaced `plugin::` so they can + * never collide with built-ins or be overridden (TraitRegistry rejects + * builtin-namespace overrides + restricted flags already). + * - Hooks are async-only (gate/onEnter/onExit/releaseCondition). A sync + * `guard` key is rejected at contribution validation (core), so it never + * reaches the registry. + * - Executable hooks do NOT run raw in-process code. The adapter builds a + * synthetic `WorkflowIrNode` from the hook descriptor (mode + prompt / + * scriptName + gateMode) and delegates to the injected + * `WorkflowCustomNodeRunner` — the exact path contributed workflow steps + * execute through. Gates additionally reuse `createGateHandler` semantics + * (blocking fails closed; advisory records-and-allows). + * - Gates are evaluated PRE-MOVE, outside the task lock (KTD-2): the verdict + * is recorded into the store via `recordPluginGateVerdict`; the store's + * in-lock guard re-checks it cheaply. No plugin code runs in-lock. + * + * Disable/uninstall protection (KTD-7): + * - `findLivePluginTraitDependents` resolves every live task's workflow + + * current column and reports tasks sitting in a column that uses one of the + * plugin's traits. A non-force disable with dependents is blocked. + * - `degradePluginTraits` (force path) deregisters the hook impls so the + * registry resolves them to the no-op + audit-warning path — columns become + * passive, cards stay movable, one audit event is emitted. + */ + +import type { + PluginTraitContribution, + PluginTraitHookDescriptor, + TaskStore, + TaskDetail, + TraitDefinition, + TraitHookKind, + WorkflowIr, + WorkflowIrNode, +} from "@fusion/core"; +import { TraitRegistry, findWorkflowColumn } from "@fusion/core"; + +import { createGateHandler } from "./workflow-node-handlers.js"; +import type { WorkflowCustomNodeRunner } from "./workflow-node-handlers.js"; +import type { WorkflowNodeResult } from "./workflow-graph-executor.js"; + +/** Build the registry-facing id for a plugin trait. */ +export function pluginTraitRegistryId(pluginId: string, traitId: string): string { + return `plugin:${pluginId}:${traitId}`; +} + +/** The async hook points a plugin trait may carry. */ +const PLUGIN_HOOK_KINDS: readonly Exclude[] = [ + "gate", + "onEnter", + "onExit", + "releaseCondition", +]; + +/** + * Convert a `PluginTraitContribution` into a core `TraitDefinition`. The result + * is NOT built-in (`builtin` stays falsy), so the registry enforces R22 + * (restricted flags / sync guard rejected) on registration as a backstop even + * though core's `validatePluginTraitContribution` already rejected them. + */ +export function pluginTraitToDefinition( + pluginId: string, + contribution: PluginTraitContribution, +): TraitDefinition { + const hooks: TraitDefinition["hooks"] = {}; + if (contribution.hooks?.gate) hooks.gate = true; + if (contribution.hooks?.onEnter) hooks.onEnter = true; + if (contribution.hooks?.onExit) hooks.onExit = true; + if (contribution.hooks?.releaseCondition) hooks.releaseCondition = true; + + return { + id: pluginTraitRegistryId(pluginId, contribution.traitId), + name: contribution.name, + description: contribution.description, + flags: { ...(contribution.flags ?? {}) }, + configSchema: contribution.configSchema + ? { fields: contribution.configSchema.fields.map((f) => ({ ...f })) } + : undefined, + hooks: Object.keys(hooks).length > 0 ? hooks : undefined, + builtin: false, + }; +} + +/** + * Build a synthetic workflow node from a hook descriptor so the hook executes + * through the existing custom-node runner (the contributed-workflow-step path). + */ +function hookDescriptorToNode( + traitRegistryId: string, + hookKind: Exclude, + descriptor: PluginTraitHookDescriptor, +): WorkflowIrNode { + const isGate = hookKind === "gate"; + // The custom-node runner reads `config.gateMode === "gate"` (blocking) vs + // anything else (advisory). Map our blocking/advisory onto that contract. + const gateModeForRunner = descriptor.gateMode === "advisory" ? "advisory" : "gate"; + return { + id: `trait:${traitRegistryId}:${hookKind}`, + kind: isGate ? "gate" : "prompt", + config: { + name: traitRegistryId, + prompt: descriptor.prompt ?? "", + scriptName: descriptor.scriptName, + gateMode: isGate ? gateModeForRunner : undefined, + }, + } as WorkflowIrNode; +} + +/** + * Evaluate a plugin gate descriptor through the gate handler + custom-node + * runner (the same machinery contributed steps use). Returns the node result; + * blocking gates fail closed (a failure outcome → not allowed), advisory gates + * always pass at the handler level (the verdict is still recorded). + */ +export async function evaluatePluginGate(params: { + traitRegistryId: string; + descriptor: PluginTraitHookDescriptor; + task: TaskDetail; + context?: Record; + runCustomNode: WorkflowCustomNodeRunner; +}): Promise { + const { traitRegistryId, descriptor, task, context, runCustomNode } = params; + const node = hookDescriptorToNode(traitRegistryId, "gate", descriptor); + const handler = createGateHandler(runCustomNode); + return handler(node, { task, context: context ?? {}, settings: undefined }); +} + +/** + * Register a plugin's trait contributions into the registry and wire each async + * hook's implementation. Hook impls delegate to the injected custom-node runner + * (gate/onEnter/onExit/releaseCondition). Returns the registry ids registered so + * the caller can later degrade/unregister them. + * + * Idempotent per id: a trait already present (same plugin reload) is skipped for + * the definition but its hook impls are refreshed. + */ +export function registerPluginTraits(params: { + registry: TraitRegistry; + pluginId: string; + contributions: PluginTraitContribution[]; + /** Resolves the custom-node runner for a given task (the executor's). */ + runCustomNode: WorkflowCustomNodeRunner; +}): string[] { + const { registry, pluginId, contributions, runCustomNode } = params; + const registered: string[] = []; + + for (const contribution of contributions) { + const def = pluginTraitToDefinition(pluginId, contribution); + if (!registry.has(def.id)) { + // Registration enforces R22 as a backstop (restricted flag / guard hook). + registry.register(def); + } + registered.push(def.id); + + for (const hookKind of PLUGIN_HOOK_KINDS) { + const descriptor = contribution.hooks?.[hookKind]; + if (!descriptor) continue; + registry.registerTraitHookImpl(def.id, hookKind, ((...args: unknown[]) => { + const ctx = args[0] as + | { task?: TaskDetail; context?: Record } + | undefined; + const task = ctx?.task; + if (!task) return undefined; + const node = hookDescriptorToNode(def.id, hookKind, descriptor); + return runCustomNode(node, task, ctx?.context ?? {}); + }) as (...args: unknown[]) => unknown); + } + } + + return registered; +} + +/** A live task sitting in a column that uses one of a plugin's traits. */ +export interface PluginTraitDependent { + taskId: string; + column: string; + /** The registry ids of the plugin's traits used by that column. */ + traitIds: string[]; +} + +/** Typed error for a blocked disable/unregister with live dependents (KTD-7). */ +export class PluginTraitHasDependentsError extends Error { + readonly pluginId: string; + readonly dependents: PluginTraitDependent[]; + constructor(pluginId: string, dependents: PluginTraitDependent[]) { + super( + `Cannot disable plugin '${pluginId}': ${dependents.length} task(s) are in columns using its traits ` + + `(${dependents.map((d) => `${d.taskId}@${d.column}`).join(", ")}). ` + + `Force-disable to degrade those columns to passive.`, + ); + this.name = "PluginTraitHasDependentsError"; + this.pluginId = pluginId; + this.dependents = dependents; + } +} + +/** + * Resolve every live (non-archived) task's workflow + current column and report + * those sitting in a column that uses one of the given plugin trait registry + * ids. Pure read-side: resolves the workflow IR through the injected resolver + * (so we don't reach into the store's private methods). + */ +export async function findLivePluginTraitDependents(params: { + store: Pick; + /** Resolve the (already-parsed) workflow IR for a task id. May resolve + * asynchronously (the shared @fusion/core resolver awaits the definition). */ + resolveTaskWorkflowIr: (taskId: string) => WorkflowIr | undefined | Promise; + /** The registry ids of the plugin's traits to check for. */ + pluginTraitIds: string[]; +}): Promise { + const { store, resolveTaskWorkflowIr, pluginTraitIds } = params; + const traitSet = new Set(pluginTraitIds); + if (traitSet.size === 0) return []; + + const dependents: PluginTraitDependent[] = []; + const tasks = await store.listTasks({ slim: true, includeArchived: false }); + for (const task of tasks) { + const ir = await resolveTaskWorkflowIr(task.id); + if (!ir) continue; + const column = findWorkflowColumn(ir, task.column); + if (!column) continue; + const used = column.traits + .map((ct) => ct.trait) + .filter((id) => traitSet.has(id)); + if (used.length > 0) { + dependents.push({ taskId: task.id, column: task.column, traitIds: used }); + } + } + return dependents; +} + +/** + * Degrade a plugin's traits to passive (force-disable path, KTD-7). Deregisters + * the hook impls so the registry resolves them to the no-op + audit-warning + * path; the trait definitions stay registered so columns referencing them keep + * resolving (cards remain movable). Returns the list of degraded registry ids. + */ +export function degradePluginTraits( + registry: TraitRegistry, + pluginTraitIds: string[], +): string[] { + const degraded: string[] = []; + for (const id of pluginTraitIds) { + const def = registry.getTrait(id); + if (!def) continue; + let any = false; + for (const hookKind of PLUGIN_HOOK_KINDS) { + if (registry.deregisterTraitHookImpl(id, hookKind)) any = true; + } + if (any || def.hooks) degraded.push(id); + } + return degraded; +} + +/** + * Fully unregister a plugin's traits from the registry (no live dependents). + * Removes the definitions and any hook impls. Returns removed registry ids. + */ +export function unregisterPluginTraits( + registry: TraitRegistry, + pluginTraitIds: string[], +): string[] { + const removed: string[] = []; + for (const id of pluginTraitIds) { + if (registry.unregisterTrait(id)) removed.push(id); + } + return removed; +} diff --git a/packages/engine/src/run-audit.ts b/packages/engine/src/run-audit.ts index da0a5af3cd..0b560535c2 100644 --- a/packages/engine/src/run-audit.ts +++ b/packages/engine/src/run-audit.ts @@ -152,6 +152,7 @@ export type GitMutationType = | "merge:start" | "merge:resolve" | "merge:file-scope-violation" + | "merge:file-scope-enforcement-disabled" | "merge:auto-prerebase:applied" | "merge:auto-prerebase:skipped" | "merge:auto-prerebase:failed" diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index c0c4b3b93c..35afa2e55a 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -32,6 +32,8 @@ import type { AutoClaimSnapshotManager } from "./auto-claim-snapshot.js"; import { StaleTaskReporter } from "./stale-task-reporter.js"; import { BacklogPressureReporter } from "./backlog-pressure-reporter.js"; import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js"; +import { isWorkflowColumnsEnabled, DEFAULT_WORKFLOW_POOL_ID } from "@fusion/core"; +import { runHoldReleaseSweep, type SlotReservation } from "./hold-release.js"; /** * Check whether two sets of file scope paths overlap. @@ -278,6 +280,20 @@ interface ConcurrencyGateSnapshot { slack: number; } +/** + * U6 (KTD-10): a per-(workflow, column) capacity gate, the generalization of the + * three legacy gates to workflow-defined WIP columns. Additive — the three-gate + * report shape (maxConcurrent/maxWorktrees/semaphore) is preserved verbatim; this + * is an optional extra field populated only when the workflowColumns flag is ON. + */ +interface PerColumnCapacityGate { + workflowId: string; + columnId: string; + used: number; + limit: number; + slack: number; +} + interface ConcurrencyGateDiagnostic { available: number; bindingGates: ConcurrencyGateName[]; @@ -289,6 +305,9 @@ interface ConcurrencyGateDiagnostic { maxWorktrees: string[]; semaphore?: string[]; }; + /** U6: additive per-column capacity gates (flag-ON only; omitted otherwise so + * the legacy three-gate report shape is byte-identical when the flag is OFF). */ + perColumnGates?: PerColumnCapacityGate[]; } function computeConcurrencyGateDiagnostic(params: { @@ -299,6 +318,9 @@ function computeConcurrencyGateDiagnostic(params: { semaphore?: AgentSemaphore; inProgressTaskIds: string[]; available: number; + /** U6: additive per-column capacity gates (flag-ON only). Omitted → the legacy + * three-gate report is byte-identical. */ + perColumnGates?: PerColumnCapacityGate[]; }): ConcurrencyGateDiagnostic { const maxConcurrentGate: ConcurrencyGateSnapshot = { used: params.agentSlots, @@ -334,6 +356,8 @@ function computeConcurrencyGateDiagnostic(params: { maxWorktrees: [...params.inProgressTaskIds], semaphore: semaphoreGate ? [...params.inProgressTaskIds] : undefined, }, + // U6: additive only — present when flag-ON, omitted otherwise. + ...(params.perColumnGates ? { perColumnGates: params.perColumnGates } : {}), }; } @@ -938,6 +962,8 @@ export class Scheduler { semaphore: diagnostic.semaphoreGate, holders: diagnostic.holders, available: diagnostic.available, + // U6: additive per-column capacity gates (present only flag-ON). + ...(diagnostic.perColumnGates ? { perColumnGates: diagnostic.perColumnGates } : {}), }, }); } catch (error) { @@ -1171,6 +1197,18 @@ export class Scheduler { } this.wasEnginePaused = false; + // ── U6: hold/release sweep (flag-ON only) ────────────────────────────── + // Flag OFF: this is skipped entirely — the legacy pull-from-todo loop + // below is byte-identical. Flag ON: the sweep evaluates hold-column + // release conditions (manual/timer/capacity/dependency/external-event) and + // releases eligible cards via moveSource:"scheduler", serializing through + // the in-txn capacity check. For the DEFAULT workflow the legacy loop below + // still drives todo→in-progress pickup (parity); the sweep adds custom- + // workflow hold handling and the generalized capacity-release path. + if (isWorkflowColumnsEnabled(settings)) { + await this.runHoldReleaseSweepPass(); + } + // Count only in-progress tasks toward the worktree limit. // In-review tasks with worktrees are idle (waiting to merge) and // should not block new tasks from starting. @@ -1207,6 +1245,19 @@ export class Scheduler { semaphoreAvailable, ); const inProgressTaskIds = inProgress.map((task) => task.id); + // U6 (KTD-10): when the workflowColumns flag is ON, report the default + // workflow's in-progress capacity as a per-column gate — the generalization + // of the legacy maxConcurrent gate (which reads through to the same value). + // Additive: omitted flag-OFF so the three-gate report shape is unchanged. + const perColumnGates = isWorkflowColumnsEnabled(settings) + ? [{ + workflowId: DEFAULT_WORKFLOW_POOL_ID, + columnId: "in-progress", + used: agentSlots, + limit: maxConcurrent, + slack: maxConcurrent - agentSlots, + }] + : undefined; const concurrencyGateDiagnostic = computeConcurrencyGateDiagnostic({ agentSlots, maxConcurrent, @@ -1215,6 +1266,7 @@ export class Scheduler { semaphore: this.options.semaphore, inProgressTaskIds, available, + perColumnGates, }); if (available <= 0) return; @@ -1892,13 +1944,44 @@ export class Scheduler { } } + /** + * U6: run one hold/release sweep pass, wiring the scheduler's semaphore + + * worktree allocation into the reservation-first ordering (KTD-10). Failures + * are isolated so a sweep error never breaks the scheduling pass. + */ + private async runHoldReleaseSweepPass(): Promise { + try { + await runHoldReleaseSweep(this.store, { + now: () => Date.now(), + reserveSlot: this.options.semaphore + ? (): SlotReservation | null => { + const sem = this.options.semaphore!; + if (!sem.tryAcquire()) return null; + let released = false; + return { + release: () => { + if (released) return; + released = true; + sem.release(); + }, + }; + } + : undefined, + allocateWorktree: (task, reservedNames) => + planTaskWorktreePath(task, this.store.getRootDir(), undefined, reservedNames, {}), + }); + } catch (error) { + schedulerLog.error("Hold/release sweep failed:", error); + } + } + /** * Handle a mission-linked task column move. * Keeps feature state synchronized with task columns across the full task * lifecycle, including review/merge transitions and older tasks whose task * row has mission/slice metadata but whose feature row lacks taskId. */ - private async handleMissionTaskMove(taskId: string, toColumn: import("@fusion/core").Column): Promise { + private async handleMissionTaskMove(taskId: string, toColumn: import("@fusion/core").ColumnId): Promise { if (!this.options.missionStore) return; const missionStore = this.options.missionStore; diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 500edfe0fc..55c603a4d9 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -28,7 +28,7 @@ import { promisify } from "node:util"; import { setImmediate as setImmediateCb } from "node:timers"; import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs"; import { isAbsolute, join, relative, resolve } from "node:path"; -import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, allowsAutoMergeProcessing, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core"; +import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, allowsAutoMergeProcessing, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkflowColumnsEnabled, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core"; import type { MeshLeaseManager } from "./mesh-lease-manager.js"; import { createLogger, schedulerLog } from "./logger.js"; import { RemovalReason, classifyTaskWorktree, getRegisteredWorktreeBranchMap, getRegisteredWorktreePaths, isUsableTaskWorktree, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js"; @@ -110,7 +110,9 @@ export async function archiveAsGhostBug( findings: decision.findings.slice(0, 10), }, }); - await store.moveTask(taskId, "archived"); + // #1411: recovery/terminal move — recoveryRehome skips order-derived adjacency + // so a custom-workflow card can always reach the terminal column. + await store.moveTask(taskId, "archived", { moveSource: "engine", recoveryRehome: true }); } async function classifyOwnedLandedEvidenceForSelfHealing(rootDir: string, task: Task, mergeTargetBranch: string): Promise { @@ -437,9 +439,10 @@ export async function autoRecoverWorktreeSessionStartFailure( : `Auto-recovered: retry/verification session targeted unusable worktree${staleWorktree ? ` (${staleWorktree})` : ""} — cleared stale session metadata and requeued to todo (attempt ${nextCount}/${MAX_WORKTREE_SESSION_RETRIES}, failure: ${failureExcerpt})`, ); if (noProgress) { - await store.moveTask(task.id, "todo"); + // #1411: backward recovery move — recoveryRehome skips order-derived adjacency. + await store.moveTask(task.id, "todo", { moveSource: "engine", recoveryRehome: true }); } else { - await store.moveTask(task.id, "todo", { preserveProgress: true }); + await store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true }); } return { outcome: "requeue-todo", retries: nextCount, classification }; } @@ -1112,6 +1115,9 @@ export class SelfHealingManager { await this.store.moveTask(taskId, "todo", { preserveProgress: true, preserveStatus: true, + // #1411: backward recovery — skip order-derived adjacency. + moveSource: "engine", + recoveryRehome: true, }); } catch (moveErr: unknown) { const moveErrMessage = moveErr instanceof Error ? moveErr.message : String(moveErr); @@ -1768,6 +1774,10 @@ export class SelfHealingManager { { name: "auto-archive-meta-resolved", fn: () => this.autoArchiveResolvedMetaTasks() }, { name: "auto-archive-meta-stalled", fn: () => this.autoArchiveStalledMetaTasks() }, { name: "board-stall-auto-recovery", fn: () => this.runBoardStallAutoRecoverySweep() }, + // #1401: periodically recover transitionPending markers stranded by a + // crash between the in-txn write and the post-commit clear (flag-ON + // only; a no-op when there are no markers). + { name: "recover-stale-transition-pending", fn: () => this.runStaleTransitionPendingSweep() }, { name: "reconcile-self-defeating-deps", fn: () => this.reconcileSelfDefeatingDependencies() }, { name: "reconcile-dependency-cycles", fn: () => this.reconcileDependencyCycles().then(() => undefined) }, { name: "reclaim-pr-conflicts", fn: () => this.reclaimPrConflicts() }, @@ -2200,6 +2210,8 @@ export class SelfHealingManager { }); await this.store.moveTask(task.id, "todo", { moveSource: "engine", + // #1411: backward recovery — skip order-derived adjacency. + recoveryRehome: true, preserveWorktree: true, preserveProgress: true, preserveResumeState: true, @@ -2467,6 +2479,8 @@ export class SelfHealingManager { } else { await this.store.moveTask(task.id, "todo", { moveSource: "engine", + // #1411: backward recovery — skip order-derived adjacency. + recoveryRehome: true, preserveProgress: true, preserveResumeState: true, }); @@ -2570,6 +2584,8 @@ export class SelfHealingManager { } else { await this.store.moveTask(task.id, "todo", { moveSource: "engine", + // #1411: backward recovery — skip order-derived adjacency. + recoveryRehome: true, preserveProgress: true, preserveResumeState: true, }); @@ -2637,6 +2653,8 @@ export class SelfHealingManager { const idleMs = Number.isFinite(idleAnchorMs) ? Math.max(0, Date.now() - idleAnchorMs) : null; await this.store.moveTask(task.id, "todo", { moveSource: "engine", + // #1411: backward recovery — skip order-derived adjacency. + recoveryRehome: true, preserveWorktree: true, preserveProgress: true, preserveResumeState: true, @@ -2703,6 +2721,8 @@ export class SelfHealingManager { } else { await this.store.moveTask(task.id, "todo", { moveSource: "engine", + // #1411: backward recovery — skip order-derived adjacency. + recoveryRehome: true, preserveWorktree: true, preserveProgress: true, preserveResumeState: true, @@ -3621,6 +3641,8 @@ export class SelfHealingManager { preserveWorktree: true, preserveResumeState: true, moveSource: "engine", + // #1411: backward recovery — skip order-derived adjacency. + recoveryRehome: true, }); await this.store.logEntry( task.id, @@ -3886,6 +3908,19 @@ export class SelfHealingManager { return archived; } + /** + * #1401: periodic transitionPending recovery sweep. Flag-ON only — when + * `workflowColumns` is OFF the legacy path never writes markers, so there is + * nothing to recover. Delegates to the store's idempotent recovery method + * (a no-op when no stale markers exist), keeping capacity counts honest after + * a crash between the in-txn marker write and the post-commit clear. + */ + async runStaleTransitionPendingSweep(): Promise { + const settings = await this.store.getSettings(); + if (!isWorkflowColumnsEnabled(settings)) return; + await this.store.recoverStaleTransitionPending(); + } + async runBoardStallAutoRecoverySweep(): Promise<{ holders: string[]; recovered: number; unrecovered: boolean }> { const settings = await this.store.getSettings(); const windowMs = Number(settings.boardStallSweepWindowMs ?? 2 * 60 * 60_000); @@ -4616,7 +4651,8 @@ export class SelfHealingManager { await this.emitBackwardMoveNoAction(task, "finalize-no-op-review", "task:finalize-no-op-review-no-action", proof); continue; } - await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine" }); + // #1411: backward recovery — skip order-derived adjacency. + await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true }); continue; } @@ -4666,7 +4702,8 @@ export class SelfHealingManager { classification: "proven-no-op", baseRef: classification.baseRef, }); - await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine" }); + // #1411: backward recovery — skip order-derived adjacency. + await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true }); recovered++; continue; } @@ -5102,7 +5139,8 @@ export class SelfHealingManager { task.id, "Auto-recovered: in-review task still had incomplete steps — moved back to todo for retry", ); - await this.store.moveTask(task.id, "todo", { preserveProgress: true }); + // #1411: backward recovery — skip order-derived adjacency. + await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true }); log.log(`Recovered stale incomplete review task ${task.id}: moved back to todo`); recovered++; } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); @@ -5506,7 +5544,8 @@ export class SelfHealingManager { task.id, "Auto-recovered: in-review task idle past stuck-task timeout — kicked back to todo", ); - await this.store.moveTask(task.id, "todo", { preserveProgress: true }); + // #1411: backward recovery — skip order-derived adjacency. + await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true }); log.log(`Kicked ghost review task ${task.id} back to todo`); recovered++; } catch (err: unknown) { @@ -7248,7 +7287,8 @@ export class SelfHealingManager { stepStatuses, }, }); - await this.store.moveTask(task.id, "todo", { preserveProgress: true }); + // #1411: backward recovery — skip order-derived adjacency. + await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true }); recovered++; } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); @@ -7785,7 +7825,8 @@ export class SelfHealingManager { task.id, "Auto-recovered no-progress no-task_done failure — clean worktree, moved back to todo", ); - await this.store.moveTask(task.id, "todo"); + // #1411: backward recovery — skip order-derived adjacency. + await this.store.moveTask(task.id, "todo", { moveSource: "engine", recoveryRehome: true }); recovered++; } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); log.error(`Failed to recover no-progress no-task_done failure ${task.id}: ${errorMessage}`); @@ -7952,7 +7993,8 @@ export class SelfHealingManager { task.id, `Auto-retry ${nextCount}/${MAX_TASK_DONE_RETRIES}: agent finished without fn_task_done — requeuing to todo to resume partial work`, ); - await this.store.moveTask(task.id, "todo", { preserveProgress: true }); + // #1411: backward recovery — skip order-derived adjacency. + await this.store.moveTask(task.id, "todo", { preserveProgress: true, moveSource: "engine", recoveryRehome: true }); recovered++; } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err); diff --git a/packages/engine/src/workflow-graph-branches.ts b/packages/engine/src/workflow-graph-branches.ts new file mode 100644 index 0000000000..7c6194852e --- /dev/null +++ b/packages/engine/src/workflow-graph-branches.ts @@ -0,0 +1,364 @@ +import type { Settings, TaskDetail, WorkflowIrEdge, WorkflowIrNode } from "@fusion/core"; +import { WorkflowIrError } from "@fusion/core"; + +import type { WorkflowNodeOutcome, WorkflowNodeResult } from "./workflow-graph-executor.js"; +import { schedulerLog } from "./logger.js"; + +/** + * Concurrent fan-out/join branch execution (U13, KTD-11, R21). + * + * When the sequential walker reaches a `split` node, every outgoing edge becomes + * a branch that walks concurrently up to the matching `join`. The join then + * synchronizes per its config (`all | any | quorum(n)`) and either fails fast + * (cancelling siblings via an AbortSignal) or collects all branch outcomes. + * + * This module owns ONLY the parallel window: it is handed a `runBranchNode` + * callback that reuses the executor's per-node retry logic, and a small set of + * graph lookups. The card's board position never forks — that invariant is + * upheld by the executor (no handler-driven column moves happen here). + */ + +/** Per-branch persisted run state (ADR-0001 reconstructible). */ +export interface WorkflowBranchRunState { + taskId: string; + runId: string; + branchId: string; + /** Node the branch is currently at / last completed. */ + currentNodeId: string; + status: "running" | "completed" | "failed" | "aborted"; +} + +/** + * Persistence callback surface. Kept as an injected interface so the executor + * stays DI-pure and fake-friendly; the SQLite-backed implementation is wired + * separately (see workflow_run_branches table). All methods are optional so a + * fully in-memory run (tests, flag-off) needs no persistence at all. + */ +export interface WorkflowBranchPersistence { + /** Idempotent upsert of a branch's progress, keyed by (taskId, runId, branchId). */ + saveBranchState?(state: WorkflowBranchRunState): void | Promise; + /** Load any persisted branch states for a run (used on resume). */ + loadBranchStates?(taskId: string, runId: string): WorkflowBranchRunState[] | Promise; + /** + * Prune stale branch rows for a task, keeping only `keepRunId` (#1412). + * Called on run start and run completion to bound unbounded growth across a + * long-lived task's repeated runs. + */ + clearStaleBranchStates?(taskId: string, keepRunId: string): void | Promise; +} + +/** + * Await a `saveBranchState` call inside a guard so a Promise-returning impl + * cannot escape as an unhandled rejection, and so a persistence failure never + * kills branch execution (log-and-continue). For a synchronous impl this + * preserves the prior behavior (the write completes before the caller proceeds). + */ +async function persistBranchState( + persistence: WorkflowBranchPersistence | undefined, + state: WorkflowBranchRunState, +): Promise { + try { + await persistence?.saveBranchState?.(state); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + schedulerLog.warn( + `saveBranchState failed for task ${state.taskId} run ${state.runId} branch ${state.branchId}: ${message}`, + ); + } +} + +/** Minimal semaphore shape — structurally compatible with AgentSemaphore. */ +export interface WorkflowBranchSemaphore { + run(fn: () => Promise): Promise; +} + +/** Snapshot of a single branch's progress, surfaced for dashboard badges (U9). */ +export interface WorkflowBranchProgress { + branchId: string; + nodeId: string; + status: WorkflowBranchRunState["status"]; +} + +export interface BranchEnvironment { + task: TaskDetail; + settings: Pick | undefined; + runId: string; + nodeMap: Map; + outgoingMap: Map; + /** Reuses the executor's executeNodeWithRetries (+ context bookkeeping). */ + runBranchNode: ( + node: WorkflowIrNode, + signal: AbortSignal, + ) => Promise; + shouldTraverseEdge: (edge: WorkflowIrEdge, source: WorkflowNodeResult) => boolean; + persistence?: WorkflowBranchPersistence; + semaphore?: WorkflowBranchSemaphore; + /** Reports live per-branch progress for the card (no column move). */ + onBranchProgress?: (progress: WorkflowBranchProgress) => void; + /** Node IDs already completed in a prior (crashed) run — skipped on resume. */ + completedNodeIds?: Set; +} + +export interface SplitJoinResult { + /** The join node where branches converged. */ + joinNodeId: string; + /** Join outcome: success if the mode was satisfied, else failure. */ + outcome: WorkflowNodeOutcome; + /** Per-branch outcomes, exposed so the join's outgoing edge conditions can read them. */ + branchOutcomes: { branchId: string; outcome: WorkflowNodeOutcome; nodeId: string }[]; + /** Node IDs visited across all branches (for the executor's visited list). */ + visitedNodeIds: string[]; +} + +interface ResolvedJoinConfig { + mode: "all" | "any" | { quorum: number }; + onBranchFailure: "fail-fast" | "collect"; +} + +function resolveJoinConfig(join: WorkflowIrNode): ResolvedJoinConfig { + const rawMode = join.config?.mode; + let mode: ResolvedJoinConfig["mode"] = "all"; + if (rawMode === "all" || rawMode === "any") mode = rawMode; + else if (rawMode && typeof rawMode === "object" && "quorum" in rawMode) { + const n = (rawMode as { quorum: unknown }).quorum; + if (typeof n === "number" && Number.isInteger(n) && n > 0) mode = { quorum: n }; + else throw new WorkflowIrError(`join '${join.id}' quorum must be a positive integer`); + } + const rawFail = join.config?.onBranchFailure; + const onBranchFailure: ResolvedJoinConfig["onBranchFailure"] = + rawFail === "collect" ? "collect" : "fail-fast"; + return { mode, onBranchFailure }; +} + +/** How many successful completions satisfy this join mode for `branchCount` branches. */ +function requiredCompletions(mode: ResolvedJoinConfig["mode"], branchCount: number): number { + if (mode === "all") return branchCount; + if (mode === "any") return 1; + return Math.min(mode.quorum, branchCount); +} + +/** + * Execute a split's branches concurrently and synchronize at the join. + * + * Returns once the join is satisfied (or definitively cannot be). Sibling + * branches are aborted on fail-fast via the shared AbortSignal; on collect they + * are awaited. Nested splits recurse: a branch walk that itself hits a `split` + * calls back into this function for its inner window. + */ +export async function runSplitJoin( + split: WorkflowIrNode, + env: BranchEnvironment, +): Promise { + const branchEdges = (env.outgoingMap.get(split.id) ?? []).filter( + (e) => env.shouldTraverseEdge(e, { outcome: "success" }), + ); + if (branchEdges.length === 0) { + throw new WorkflowIrError(`split '${split.id}' has no traversable branches`); + } + + const join = findMatchingJoin(branchEdges[0].to, env); + if (!join) throw new WorkflowIrError(`split '${split.id}' has no reachable matching join`); + const joinConfig = resolveJoinConfig(env.nodeMap.get(join)!); + + const controller = new AbortController(); + const branchCount = branchEdges.length; + const required = requiredCompletions(joinConfig.mode, branchCount); + + const visitedNodeIds: string[] = []; + const branchOutcomes: SplitJoinResult["branchOutcomes"] = []; + let succeeded = 0; + let failed = 0; + let settled = false; + let resolveJoin!: (outcome: WorkflowNodeOutcome) => void; + const joinReached = new Promise((res) => { + resolveJoin = res; + }); + + const settle = (outcome: WorkflowNodeOutcome): void => { + if (settled) return; + settled = true; + resolveJoin(outcome); + }; + + // Re-evaluate the join after each branch settles. `lastWasFailure` only + // affects fail-fast (one failure cancels siblings immediately). + const evaluateJoin = (lastWasFailure: boolean): void => { + if (settled) return; + if (joinConfig.onBranchFailure === "fail-fast" && lastWasFailure) { + controller.abort(); + settle("failure"); + return; + } + if (succeeded >= required) { + // Mode satisfied. Fail-fast cancels any laggards; collect lets them finish. + if (joinConfig.onBranchFailure === "fail-fast") controller.abort(); + settle("success"); + return; + } + if (succeeded + failed >= branchCount) { + // All branches settled but the mode is unmet. + settle("failure"); + } + }; + + const branchPromises = branchEdges.map((edge) => { + const branchId = edge.to; + return walkBranch(branchId, join, env, controller.signal, visitedNodeIds) + .then((result) => { + branchOutcomes.push({ branchId, outcome: result.outcome, nodeId: result.lastNodeId }); + if (result.outcome === "success") succeeded += 1; + else failed += 1; + evaluateJoin(result.outcome === "failure"); + }) + .catch((err) => { + // An aborted branch settles silently; any other throw fails the join. + if (controller.signal.aborted) { + branchOutcomes.push({ branchId, outcome: "failure", nodeId: branchId }); + return; + } + failed += 1; + branchOutcomes.push({ branchId, outcome: "failure", nodeId: branchId }); + evaluateJoin(true); + void err; + }); + }); + + // Wait for the join to resolve, then let in-flight branches settle so collect + // semantics (and persistence writes) complete before we return. + const outcome = await joinReached; + await Promise.allSettled(branchPromises); + + return { joinNodeId: join, outcome, branchOutcomes, visitedNodeIds }; +} + +interface BranchWalkResult { + outcome: WorkflowNodeOutcome; + lastNodeId: string; +} + +/** + * Walk a single branch from `startNodeId` up to (but not including) the join. + * Reuses the injected per-node runner; supports nested splits by recursing into + * runSplitJoin. Honors the AbortSignal (fail-fast cancellation) and skips nodes + * already completed in a prior run (crash resume idempotency). + */ +async function walkBranch( + startNodeId: string, + joinId: string, + env: BranchEnvironment, + signal: AbortSignal, + visitedNodeIds: string[], +): Promise { + let currentId = startNodeId; + let lastResult: WorkflowNodeResult = { outcome: "success" }; + + for (;;) { + if (signal.aborted) return { outcome: "failure", lastNodeId: currentId }; + if (currentId === joinId) return { outcome: lastResult.outcome, lastNodeId: currentId }; + + const node = env.nodeMap.get(currentId); + if (!node) throw new WorkflowIrError(`Unknown workflow node: ${currentId}`); + + if (node.kind === "split") { + // Nested split: resolve its inner window, then continue from the inner join. + const inner = await runSplitJoin(node, env); + visitedNodeIds.push(...inner.visitedNodeIds); + lastResult = { outcome: inner.outcome }; + const next = nextEdge(inner.joinNodeId, env, lastResult); + if (!next) return { outcome: inner.outcome, lastNodeId: inner.joinNodeId }; + currentId = next; + continue; + } + + visitedNodeIds.push(currentId); + + const alreadyDone = env.completedNodeIds?.has(currentId) ?? false; + if (alreadyDone) { + lastResult = { outcome: "success" }; + } else { + const exec = async (): Promise => env.runBranchNode(node, signal); + lastResult = env.semaphore ? await env.semaphore.run(exec) : await exec(); + await persistBranchState(env.persistence, { + taskId: env.task.id, + runId: env.runId, + branchId: startNodeId, + currentNodeId: currentId, + status: lastResult.outcome === "success" ? "running" : "failed", + }); + env.onBranchProgress?.({ + branchId: startNodeId, + nodeId: currentId, + status: lastResult.outcome === "success" ? "running" : "failed", + }); + } + + if (lastResult.outcome === "failure") { + await persistBranchState(env.persistence, { + taskId: env.task.id, + runId: env.runId, + branchId: startNodeId, + currentNodeId: currentId, + status: "failed", + }); + return { outcome: "failure", lastNodeId: currentId }; + } + + const next = nextEdge(currentId, env, lastResult); + if (!next) { + // Dead-end before the join — treat as branch completion. + return { outcome: lastResult.outcome, lastNodeId: currentId }; + } + if (next === joinId) { + await persistBranchState(env.persistence, { + taskId: env.task.id, + runId: env.runId, + branchId: startNodeId, + currentNodeId: currentId, + status: "completed", + }); + env.onBranchProgress?.({ branchId: startNodeId, nodeId: currentId, status: "completed" }); + return { outcome: lastResult.outcome, lastNodeId: currentId }; + } + currentId = next; + } +} + +/** The next node along a matching outgoing edge, or undefined if none matches. */ +function nextEdge( + nodeId: string, + env: BranchEnvironment, + source: WorkflowNodeResult, +): string | undefined { + const edges = (env.outgoingMap.get(nodeId) ?? []) + .filter((e) => env.shouldTraverseEdge(e, source)) + .sort((a, b) => a.to.localeCompare(b.to)); + return edges[0]?.to; +} + +/** + * Find the join node a branch starting at `startNodeId` converges on. Walks + * forward through the (non-failure) edges; recurses one level for nested splits + * so balanced nesting resolves to the correct outer join. + */ +function findMatchingJoin(startNodeId: string, env: BranchEnvironment): string | undefined { + const seen = new Set(); + let currentId: string | undefined = startNodeId; + while (currentId && !seen.has(currentId)) { + seen.add(currentId); + const node = env.nodeMap.get(currentId); + if (!node) return undefined; + if (node.kind === "join") return currentId; + if (node.kind === "split") { + const innerJoin = findMatchingJoin( + (env.outgoingMap.get(currentId) ?? [])[0]?.to ?? "", + env, + ); + if (!innerJoin) return undefined; + currentId = (env.outgoingMap.get(innerJoin) ?? []).find((e) => e.condition !== "failure")?.to; + continue; + } + const out = env.outgoingMap.get(currentId) ?? []; + currentId = out.find((e) => e.condition !== "failure")?.to ?? out[0]?.to; + } + return undefined; +} diff --git a/packages/engine/src/workflow-graph-executor.ts b/packages/engine/src/workflow-graph-executor.ts index 16e2c6da35..fe64000cbc 100644 --- a/packages/engine/src/workflow-graph-executor.ts +++ b/packages/engine/src/workflow-graph-executor.ts @@ -7,6 +7,14 @@ import { type WorkflowCustomNodeRunner, type WorkflowLegacySeams, } from "./workflow-node-handlers.js"; +import { + runSplitJoin, + type BranchEnvironment, + type WorkflowBranchPersistence, + type WorkflowBranchProgress, + type WorkflowBranchRunState, + type WorkflowBranchSemaphore, +} from "./workflow-graph-branches.js"; export type WorkflowNodeOutcome = "success" | "failure"; @@ -20,6 +28,9 @@ export interface WorkflowNodeExecutionContext { task: TaskDetail; settings: Pick | undefined; context: Record; + /** Set during concurrent branch execution; fail-fast aborts via this signal. + * Undefined on the sequential path (zero behavior change for linear graphs). */ + signal?: AbortSignal; } export type WorkflowNodeHandler = (node: WorkflowIrNode, context: WorkflowNodeExecutionContext) => Promise; @@ -30,6 +41,15 @@ export interface WorkflowGraphExecutorDeps { /** Executes custom (non-seam) prompt/script/gate nodes. */ runCustomNode?: WorkflowCustomNodeRunner; maxRetriesPerNode?: number; + /** Per-branch run-state persistence (U13). Optional — fully in-memory without it. */ + branchPersistence?: WorkflowBranchPersistence; + /** Bounds concurrent branch-node execution. Omit when the semaphore is + * enforced beneath runCustomNode (the session layer) to avoid double-acquire. */ + branchSemaphore?: WorkflowBranchSemaphore; + /** Live per-branch progress (dashboard badges). */ + onBranchProgress?: (progress: WorkflowBranchProgress) => void; + /** Stable identifier for this run, used to key persisted branch state. */ + runId?: string; } export interface WorkflowGraphExecutorResult { @@ -85,6 +105,39 @@ export class WorkflowGraphExecutor { const context: Record = {}; const visitedNodeIds: string[] = []; const inStack = new Set(); + const runId = this.deps.runId ?? `${task.id}:run`; + + // On resume, completed branch nodes (from a prior crashed run) are skipped + // so their handlers do not re-fire (idempotency). + let completedNodeIds: Set | undefined; + const persisted = await this.deps.branchPersistence?.loadBranchStates?.(task.id, runId); + if (persisted && persisted.length > 0) { + completedNodeIds = new Set( + persisted + .filter((s: WorkflowBranchRunState) => s.status === "completed") + .map((s) => s.currentNodeId), + ); + } + + // Prune prior-run branch rows on run start (#1412). Done after the resume + // load so this run's own (taskId, runId) rows survive while every stale run + // is removed. Never throws into the run. + await this.pruneStaleBranches(task.id, runId); + + // Shared branch environment: built lazily so the sequential path pays nothing. + const branchEnv = (): BranchEnvironment => ({ + task, + settings, + runId, + nodeMap, + outgoingMap, + runBranchNode: (node, signal) => this.executeNodeWithRetries(node, task, settings, context, signal), + shouldTraverseEdge: (edge, source) => this.shouldTraverseEdge(edge, source), + persistence: this.deps.branchPersistence, + semaphore: this.deps.branchSemaphore, + onBranchProgress: this.deps.onBranchProgress, + completedNodeIds, + }); const walk = async (nodeId: string): Promise => { const node = nodeMap.get(nodeId); @@ -101,6 +154,23 @@ export class WorkflowGraphExecutor { return { outcome: "success" }; } + if (node.kind === "split") { + // Concurrent fan-out: branches run in parallel up to their join, which + // synchronizes per its config. The card stays in the split's column for + // the whole window (no handler-driven move happens in here). Execution + // then continues sequentially from the join node. + const splitResult = await runSplitJoin(node, branchEnv()); + visitedNodeIds.push(...splitResult.visitedNodeIds); + context[`node:${node.id}:outcome`] = splitResult.outcome; + context[`node:${splitResult.joinNodeId}:outcome`] = splitResult.outcome; + context[`node:${splitResult.joinNodeId}:branchOutcomes`] = splitResult.branchOutcomes; + if (!inStack.has(splitResult.joinNodeId)) visitedNodeIds.push(splitResult.joinNodeId); + return await traverseChildren( + nodeMap.get(splitResult.joinNodeId)!, + { outcome: splitResult.outcome }, + ); + } + const result = await this.executeNodeWithRetries(node, task, settings, context); if (result.contextPatch) Object.assign(context, result.contextPatch); context[`node:${node.id}:outcome`] = result.outcome; @@ -141,6 +211,9 @@ export class WorkflowGraphExecutor { }; const terminal = await walk(startNode.id); + // Prune again on run completion (#1412): keeps only this run's rows so the + // table does not accumulate historical runs for a long-lived task. + await this.pruneStaleBranches(task.id, runId); return { executed: true, outcome: terminal.outcome, @@ -149,6 +222,15 @@ export class WorkflowGraphExecutor { }; } + /** Best-effort prune of stale-run branch rows; never throws into the run. */ + private async pruneStaleBranches(taskId: string, keepRunId: string): Promise { + try { + await this.deps.branchPersistence?.clearStaleBranchStates?.(taskId, keepRunId); + } catch { + // Pruning is additive bookkeeping — a failure must not affect the run. + } + } + private shouldTraverseEdge(edge: WorkflowIrEdge, sourceResult: WorkflowNodeResult): boolean { if (!edge.condition) return sourceResult.outcome === "success"; if (edge.condition === "success") return sourceResult.outcome === "success"; @@ -164,6 +246,7 @@ export class WorkflowGraphExecutor { task: TaskDetail, settings: Pick | undefined, context: Record, + signal?: AbortSignal, ): Promise { const handler = this.handlers[node.kind]; if (!handler) { @@ -178,8 +261,10 @@ export class WorkflowGraphExecutor { let lastError: unknown; for (let attempt = 0; attempt < maxAttempts; attempt++) { + // Fail-fast cancellation: a branch aborted mid-retry stops re-trying. + if (signal?.aborted) return { outcome: "failure", value: "aborted" }; try { - return await handler(node, { task, settings, context }); + return await handler(node, { task, settings, context, signal }); } catch (error) { lastError = error; } diff --git a/packages/engine/src/workflow-graph-task-runner.ts b/packages/engine/src/workflow-graph-task-runner.ts index ecfb88fac4..f1b892cdbc 100644 --- a/packages/engine/src/workflow-graph-task-runner.ts +++ b/packages/engine/src/workflow-graph-task-runner.ts @@ -3,6 +3,11 @@ import { isExperimentalFeatureEnabled } from "@fusion/core"; import { WorkflowGraphExecutor, type WorkflowNodeOutcome } from "./workflow-graph-executor.js"; import type { WorkflowCustomNodeRunner, WorkflowLegacySeams } from "./workflow-node-handlers.js"; +import type { + WorkflowBranchPersistence, + WorkflowBranchProgress, + WorkflowBranchSemaphore, +} from "./workflow-graph-branches.js"; // (Both types are also used as values in the side-effect tracking wrappers below.) /** @@ -37,6 +42,13 @@ export interface WorkflowGraphTaskRunnerDeps { maxRetriesPerNode?: number; /** Optional diagnostics hook (audit/log emission). Never throws into the run. */ onEvent?: (event: { type: "start" | "terminal" | "fallback"; taskId: string; detail: string }) => void; + /** Per-branch run-state persistence + resume (U13). Additive; in-memory without it. */ + branchPersistence?: WorkflowBranchPersistence; + /** Bounds concurrent branch-node execution (U13); omit when the semaphore is + * enforced beneath runCustomNode at the session layer. */ + branchSemaphore?: WorkflowBranchSemaphore; + /** Live per-branch progress for dashboard badges (U9/U13). */ + onBranchProgress?: (progress: WorkflowBranchProgress) => void; } /** @@ -47,8 +59,18 @@ export interface WorkflowGraphTaskRunnerDeps { * run the legacy pipeline; a task is never stranded by interpreter bugs. */ export class WorkflowGraphTaskRunner { + /** Latest per-branch progress, keyed by branchId. Store/dashboard-readable + * (U9 badges). Reset at the start of each run; the card never moves during a + * parallel window (KTD-11) so this is purely presentational state. */ + private readonly branchProgress = new Map(); + public constructor(private readonly deps: WorkflowGraphTaskRunnerDeps) {} + /** Snapshot of current per-branch progress (branchId, nodeId, status). */ + public getBranchProgress(): WorkflowBranchProgress[] { + return [...this.branchProgress.values()]; + } + private emit(type: "start" | "terminal" | "fallback", taskId: string, detail: string): void { try { this.deps.onEvent?.({ type, taskId, detail }); @@ -91,6 +113,7 @@ export class WorkflowGraphTaskRunner { } this.emit("start", task.id, definition.id); + this.branchProgress.clear(); // Track whether any node side effects ran. A pre-run interpreter error // (bad IR structure, wiring) can safely fall back to the legacy pipeline; @@ -117,6 +140,17 @@ export class WorkflowGraphTaskRunner { seams: wrappedSeams, runCustomNode: wrappedRunCustomNode, maxRetriesPerNode: this.deps.maxRetriesPerNode, + branchPersistence: this.deps.branchPersistence, + branchSemaphore: this.deps.branchSemaphore, + runId: `${task.id}:${definition.id}`, + onBranchProgress: (progress) => { + this.branchProgress.set(progress.branchId, progress); + try { + this.deps.onBranchProgress?.(progress); + } catch { + // Progress reporting must never affect the run. + } + }, }); const result = await executor.run(task, settings, definition.ir); if (!result.executed) { diff --git a/packages/engine/src/worktree-pool.ts b/packages/engine/src/worktree-pool.ts index 5f39f7c3f6..939b5f77d7 100644 --- a/packages/engine/src/worktree-pool.ts +++ b/packages/engine/src/worktree-pool.ts @@ -2,7 +2,7 @@ import { exec } from "node:child_process"; import { promisify } from "node:util"; import { existsSync, lstatSync, readdirSync, rmSync, realpathSync } from "node:fs"; import { basename, join, relative, resolve, isAbsolute } from "node:path"; -import type { Column, SecretsStore, Settings, TaskStore, WorktrunkSettings } from "@fusion/core"; +import type { ColumnId, SecretsStore, Settings, TaskStore, WorktrunkSettings } from "@fusion/core"; import { assertCleanBranchAtBase, inspectBranchConflict } from "./branch-conflicts.js"; import { worktreePoolLog } from "./logger.js"; import { isInsideConfiguredWorktreesDir, resolveWorktreesDir } from "./worktree-paths.js"; @@ -943,7 +943,7 @@ export async function reapOrphanWorktrees( } /** Columns where merger/finalization owns branch lifecycle. */ -const MERGER_MANAGED_COLUMNS: ReadonlySet = new Set(["in-review", "done"]); +const MERGER_MANAGED_COLUMNS: ReadonlySet = new Set(["in-review", "done"]); /** * Return local `fusion/*` branches not associated with any active task. diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 3db443ee93..fd8f4aac03 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -16,7 +16,6 @@ "dismissOAuth": "Dismiss OAuth re-login banner", "done": "Done", "edit": "Edit", - "generateInsights": "Generate new insights", "no": "No", "openSettings": "Open Settings", "pull": "Pull", @@ -66,10 +65,13 @@ "notMerged": "Not merged", "refresh": "Refresh", "time": { - "daysAgo": "{{count}}d ago", - "hoursAgo": "{{count}}h ago", + "daysAgo_one": "{{count}}d ago", + "daysAgo_other": "{{count}}d ago", + "hoursAgo_one": "{{count}}h ago", + "hoursAgo_other": "{{count}}h ago", "justNow": "Just now", - "minutesAgo": "{{count}}m ago" + "minutesAgo_one": "{{count}}m ago", + "minutesAgo_other": "{{count}}m ago" }, "title": "Activity Log" }, @@ -95,26 +97,30 @@ "hideToolCallsResults": "Hide tool calls and results", "hideToolOutput": "Hide tool output", "live": "Live", - "loadMore": "Load More", "loading": "Loading agent logs…", "loadingMore": "Loading…", + "loadMore": "Load More", "markdown": "Markdown", "plain": "Plain", "planning": "Planning", "reviewer": "Reviewer", "showFormattedMarkdown": "Show formatted markdown", + "showing": "Showing {{visible}} of {{total}} entries", "showOutput": "Show output", "showRawText": "Show raw text", "showToolCallsResults": "Show tool calls and results", "showToolOutput": "Show tool output", - "showing": "Showing {{visible}} of {{total}} entries", "switchMarkdown": "Switch to markdown mode", "switchPlainText": "Switch to plain text mode", - "timeDaysAgo": "{{count}}d ago", - "timeHoursAgo": "{{count}}h ago", + "timeDaysAgo_one": "{{count}}d ago", + "timeDaysAgo_other": "{{count}}d ago", + "timeHoursAgo_one": "{{count}}h ago", + "timeHoursAgo_other": "{{count}}h ago", "timeJustNow": "just now", - "timeMinutesAgo": "{{count}}m ago", - "toolEntriesHidden": "{{count}} tool entries hidden", + "timeMinutesAgo_one": "{{count}}m ago", + "timeMinutesAgo_other": "{{count}}m ago", + "toolEntriesHidden_one": "{{count}} tool entries hidden", + "toolEntriesHidden_other": "{{count}} tool entries hidden", "toolsOff": "Tools: Off", "toolsOn": "Tools: On", "usingDefault": "Using default" @@ -239,15 +245,6 @@ "promptDefault": "Default: {{preview}}", "templateName": "e.g. My Custom Executor" }, - "roles": { - "custom": "Custom Agent", - "engineer": "Engineer Agent", - "executor": "Executor Agent", - "merger": "Merger Agent", - "reviewer": "Reviewer Agent", - "scheduler": "Scheduler Agent", - "triage": "Triage Agent" - }, "sections": { "builtinTemplates": "Built-in Templates", "customTemplates": "Custom Templates" @@ -281,7 +278,8 @@ }, "agents": { "activate": "Activate", - "activeAgents": "Active Agents ({{count}})", + "activeAgents_one": "Active Agents ({{count}})", + "activeAgents_other": "Active Agents ({{count}})", "activePrefix": "Active: ", "advancedSettingsDesc": "Low-level configuration options for this agent.", "advancedSettingsTitle": "Advanced Settings", @@ -291,15 +289,16 @@ "agentMail": "Agent Mail", "agentModelLabel": "Agent Model", "agentPlural": "agents", + "agentsFound_one": "{{count}} agent{{plural}} found", + "agentsFound_other": "{{count}} agent{{plural}} found", "agentSingular": "agent", - "agentSoulLabel": "Agent Soul", - "agentsFound": "{{count}} agent{{plural}} found", "agentsLabel": "Agents", + "agentSoulLabel": "Agent Soul", "aiInterview": "AI Interview", "allChangesSaved": "All changes saved", - "allTime": "All time", "allowParallelExecution": "Allow Parallel Execution", "allowParallelExecutionHint": "Allow this agent to run multiple heartbeats concurrently.", + "allTime": "All time", "alreadyOnDefault": "Already on default", "applyPreset": "Apply preset", "assignedSkills": "Assigned Skills", @@ -330,12 +329,12 @@ "bulkActions": "Bulk Actions", "bulkActionsLoadFailed": "Failed to load bulk agent actions: {{error}}", "bulkAgentActions": "Bulk agent actions", - "bulkConfirmMessage": "{{action}} {{count}} agent(s)?", + "bulkConfirmMessage_one": "{{action}} {{count}} agent(s) in this project?", + "bulkConfirmMessage_other": "{{action}} {{count}} agent(s) in this project?", "bulkNoEligible": "No eligible agents", - "bulkResult": "{{action}} {{count}} agent(s)", - "bulkResultWithFailures": "{{action}} {{count}} agent(s), {{failed}} failed", "bulkResult_one": "{{action}} {{successCount}} {{agentWord}}; skipped {{skippedCount}}", "bulkResult_other": "{{action}} {{successCount}} {{agentWord}}; skipped {{skippedCount}}", + "bulkResultWithFailures": "{{action}} {{count}} agent(s), {{failed}} failed", "bundleDescription": "Configure how this agent's code bundle is managed.", "bundleEntryFileHint": "The entry file for the managed bundle.", "bundleEntryFileLabel": "Entry File", @@ -381,9 +380,9 @@ "copyId": "Copy ID", "create": "Create", "createAgent": "Create agent", + "created": "Agent \"{{name}}\" created", "createError": "Failed to create agent: {{error}}", "createSuccess": "Agent \"{{name}}\" created", - "created": "Agent \"{{name}}\" created", "creating": "Creating...", "creatingAgent": "Creating agent...", "currentAgent": "Current agent", @@ -400,12 +399,12 @@ "delete": "Delete", "deleteAgent": "Delete Agent", "deleteConfirm": "Are you sure you want to delete this agent? This cannot be undone.", + "deleted": "Agent deleted", "deleteError": "Failed to delete agent: {{error}}", "deleteFailed": "Failed to delete agent", "deleteMessage": "Delete agent \"{{name}}\"? This cannot be undone.", "deleteSuccess": "Agent \"{{name}}\" deleted", "deleteTitle": "Delete Agent", - "deleted": "Agent deleted", "deletionNotAvailable": "Deletion is not available while the agent is running.", "deletionPermanent": "This will permanently delete the agent and all associated data.", "details": "Details", @@ -473,6 +472,8 @@ "healthError": "Error", "heartbeat": "Heartbeat:", "heartbeatAndHealth": "Heartbeat & Health", + "heartbeatClampedToMin_one": "Heartbeat interval set to 5 minutes (minimum). {{count}} minute was below the 5-minute minimum.", + "heartbeatClampedToMin_other": "Heartbeat interval set to 5 minutes (minimum). {{count}} minutes was below the 5-minute minimum.", "heartbeatCustom": "Custom heartbeat run", "heartbeatEnabled": "Heartbeat Enabled", "heartbeatEnabledHint": "Allow this agent to run on a scheduled heartbeat.", @@ -483,12 +484,12 @@ "heartbeatFileLoadFailed": "Failed to load heartbeat file", "heartbeatFilePlaceholder": "Heartbeat procedure content...", "heartbeatFilePreviewMode": "Preview mode", - "heartbeatFileSaveFailed": "Failed to save heartbeat file", "heartbeatFileSaved": "Heartbeat file saved", + "heartbeatFileSaveFailed": "Failed to save heartbeat file", "heartbeatIntervalHint": "How frequently the heartbeat runs, in seconds.", "heartbeatIntervalLabel": "Heartbeat Interval (s)", - "heartbeatIntervalUpdateFailed": "Failed to update heartbeat interval: {{error}}", "heartbeatIntervalUpdated": "Heartbeat interval updated to {{interval}} for {{name}}", + "heartbeatIntervalUpdateFailed": "Failed to update heartbeat interval: {{error}}", "heartbeatMustBeNumber": "Heartbeat interval must be a valid number", "heartbeatMustBePositive": "Heartbeat interval must be greater than 0", "heartbeatOverdue": "Heartbeat overdue {{elapsed}}", @@ -512,8 +513,8 @@ "heartbeatSpeedPreset": "Heartbeat speed preset", "heartbeatSpeedSaveFailed": "Failed to save heartbeat multiplier: {{error}}", "heartbeatSpeedSet": "Heartbeat speed set to ×{{value}}", - "heartbeatStartFailed": "Failed to start heartbeat", "heartbeatStarted": "Heartbeat started", + "heartbeatStartFailed": "Failed to start heartbeat", "heartbeatTimeoutHint": "Maximum time in seconds a heartbeat run may take before being killed.", "heartbeatTimeoutLabel": "Heartbeat Timeout (s)", "heartbeatUpgradeFailed": "Failed to upgrade heartbeat procedure", @@ -527,16 +528,18 @@ "importButton": "Import {{label}}", "importComplete": "Import Complete", "importDescription": "Import agents from an Agent Companies package. Browse the companies.sh catalog to discover published agents, upload an AGENTS.md file, select a directory, or paste manifest content.", - "importingAgents": "Importing {{count}} agent{{plural}}...", + "importingAgents_one": "Importing {{count}} agent{{plural}}...", + "importingAgents_other": "Importing {{count}} agent{{plural}}...", "importingAgentsAndSkills": "Importing {{agentCount}} agent{{agentPlural}} and {{skillCount}} skill{{skillPlural}}...", - "importingSkills": "Importing {{count}} skill{{plural}}...", - "inProgress": "In progress", + "importingSkills_one": "Importing {{count}} skill{{plural}}...", + "importingSkills_other": "Importing {{count}} skill{{plural}}...", "inbox": "Inbox", - "inheritProjectDefault": "Inherit project default", "inheritingProjectDefault": "Inheriting project default", + "inheritProjectDefault": "Inherit project default", "inlineMemoryFieldHint": "This memory is embedded directly in the agent's context.", "inlineMemoryHint": "Short-form memory injected on every heartbeat.", "inlineMemoryLabel": "Inline Memory", + "inProgress": "In progress", "input": "Input", "inputTokens": "Input", "installs": "installs", @@ -544,15 +547,15 @@ "instructionsEmptyPreview": "No instructions yet — switch to edit mode to add some.", "instructionsFileEditorDesc": "Edit the linked instructions file directly.", "instructionsFileEditorTitle": "Instructions File", - "instructionsFileSaveFailed": "Failed to save instructions file", "instructionsFileSaved": "Instructions file saved", + "instructionsFileSaveFailed": "Failed to save instructions file", "instructionsHint": "These instructions are prepended to every prompt this agent receives.", "instructionsPathHint": "Path to a markdown file containing this agent's instructions.", "instructionsPathLabel": "Instructions File Path", "instructionsPathPlaceholder": "/path/to/instructions.md", "instructionsPlaceholder": "Enter instructions for this agent...", - "instructionsSaveFailed": "Failed to save instructions", "instructionsSaved": "Instructions saved", + "instructionsSaveFailed": "Failed to save instructions", "instructionsTextPlaceholder": "Add custom behavior instructions...", "instructionsTitle": "Instructions", "intentPrompt": "What do you want this agent to do?", @@ -574,7 +577,6 @@ "liveLogs": "Live logs", "liveRun": "Live run", "loadError": "Failed to load agents: {{error}}", - "loadTasksFailed": "Failed to load tasks", "loading": "Loading agent...", "loadingAgents": "Loading agents...", "loadingCompanies": "Loading companies…", @@ -594,15 +596,17 @@ "loadingRuntimes": "Loading runtimes...", "loadingSkillContent": "Loading skill content...", "loadingTasks": "Loading tasks...", - "logEntries": "log entries", + "loadTasksFailed": "Failed to load tasks", + "logEntries_one": "{{count}} entries", + "logEntries_other": "{{count}} entries", "logsWillAppear": "Logs will appear here once the agent starts running.", "logsWillAppearActive": "Logs will appear here.", + "mailboxLoadFailed": "Failed to load mailbox", "mailFrom": "From", "mailSent": "Sent", "mailTo": "To", "mailToLabel": "To", "mailType": "Type", - "mailboxLoadFailed": "Failed to load mailbox", "manifestContent": "Manifest content", "manifestPlaceholder": "---\nname: CEO\ntitle: Chief Executive Officer\nreportsTo: null\nskills:\n - review\n---\nAgent instructions go here...", "maxConcurrentRunsHint": "Maximum number of heartbeats that can run simultaneously.", @@ -616,8 +620,8 @@ "memoryFileMeta": "{{size}} bytes · updated {{date}}", "memoryFilePlaceholder": "Memory file content...", "memoryFilePreviewMode": "Preview mode", - "memoryFileSaveFailed": "Failed to save memory file", "memoryFileSaved": "Memory file saved", + "memoryFileSaveFailed": "Failed to save memory file", "memoryFilesHint": "Files stored in the agent's memory layers.", "memoryFilesHintSuffix": "Select a file to view or edit its contents.", "memoryFilesLabel": "Memory Files", @@ -630,8 +634,8 @@ "memoryLayerLongTermDesc": "Persistent facts and knowledge retained across sessions.", "memoryPlaceholder": "Enter inline memory for this agent...", "memoryReadOnly": "Read-only", - "memorySaveFailed": "Failed to save memory", "memorySaved": "Memory saved", + "memorySaveFailed": "Failed to save memory", "memoryTitle": "Memory", "memoryTooLong": "Memory content is too long", "messageResponseModeHint": "When this agent responds to incoming messages.", @@ -672,6 +676,7 @@ "noLogsForRun": "No logs for this run", "noManager": "No manager", "noMemoryFiles": "No memory files", + "noneUsingBuiltIn": "None (using built-in)", "noOutboxMessages": "No messages in outbox", "noOutputCaptured": "No output captured", "noPausedEligible": "No paused agents eligible to resume", @@ -684,11 +689,9 @@ "noSkillsInPackage": "No skills in package", "noTasksAssigned": "No tasks assigned", "noTokenUsageYet": "No token usage recorded yet. Token totals appear here once agents run.", - "noneUsingBuiltIn": "None (using built-in)", "notScheduled": "Not scheduled", "notSelected": "Not selected", "off": "Off", - "onHeartbeat": "On heartbeat", "onboarding": { "applyDraftAgent": "Apply draft to agent form", "applyDraftSettings": "Apply draft to settings form", @@ -729,9 +732,9 @@ "updatedDraftReady": "Updated draft ready for review", "yes": "yes" }, + "onHeartbeat": "On heartbeat", "openDetails": "Open details for {{name}}", "optional": "(optional)", - "orPasteManifest": "or paste manifest content", "orgChartCanvas": "Org chart canvas", "orgChartCenter": "Center org chart", "orgChartEmployees": "{{name}} employees", @@ -740,6 +743,7 @@ "orgChartView": "Org Chart view", "orgChartZoomIn": "Zoom in org chart", "orgChartZoomOut": "Zoom out org chart", + "orPasteManifest": "or paste manifest content", "outbox": "Outbox", "output": "Output", "outputTokens": "Output", @@ -750,13 +754,21 @@ "pauseAgentsFailed": "Failed to pause agents: {{error}}", "pauseAll": "Pause All", "pauseAllAgents": "Pause All Agents", + "pauseAllConfirm_one": "Pause {{count}} agent in this project?", + "pauseAllConfirm_other": "Pause {{count}} agents in this project?", "pauseAllTitle": "Pause All Agents", - "pauseCountHint": "{{count}} active agent(s) will be paused", "pauseCountHint_one": "Pause {{count}} active/running agent", "pauseCountHint_other": "Pause {{count}} active/running agents", + "pauseCountHint_one_one": "", + "pauseCountHint_one_other": "", + "pauseCountHint_other_one": "", + "pauseCountHint_other_other": "", "pausedPast": "paused", + "pausedSummary_one": "Paused {{count}} agent; skipped {{skipped}}", + "pausedSummary_other": "Paused {{count}} agents; skipped {{skipped}}", "pendingApprovals": "Pending Approvals", - "pendingApprovalsCount": "{{count}} pending", + "pendingApprovalsCount_one": "{{count}} pending approvals", + "pendingApprovalsCount_other": "{{count}} pending approvals", "performance": { "avgDuration": "Avg Duration", "noData": "No performance data yet", @@ -774,6 +786,7 @@ "preview": "Preview", "promptSize": "Prompt size", "promptSizeChart": "Prompt Size Chart", + "provideManifest": "Please provide manifest content", "ratings": { "addError": "Failed to add rating: {{error}}", "addRating": "Add Rating", @@ -786,7 +799,8 @@ "categorySelect": "Select category...", "categorySpeed": "Speed", "commentPlaceholder": "Optional comment...", - "count": "{{count}} ratings", + "count_one": "{{count}} ratings", + "count_other": "{{count}} ratings", "deleteError": "Failed to delete rating: {{error}}", "deleteRating": "Delete rating", "deleteSuccess": "Rating deleted", @@ -794,14 +808,11 @@ "loadError": "Failed to load ratings: {{error}}", "loading": "Loading ratings...", "noRatings": "No ratings yet", - "starCount": "{{count}} star", + "starCount_one": "{{count}} star", + "starCount_other": "{{count}} stars", "submitRating": "Submit Rating", "submitting": "Submitting...", - "title": "User Ratings", - "trendDeclining": "↓ Declining", - "trendImproving": "↑ Improving", - "trendInsufficient": "Insufficient data", - "trendStable": "→ Stable" + "title": "User Ratings" }, "recentRuns": "Recent Runs", "reflections": { @@ -818,18 +829,14 @@ "metricAvgDuration": "Avg Duration:", "metricErrors": "Errors:", "metricFailed": "Failed:", - "metricTasks": "Tasks:", "metrics": "Metrics", + "metricTasks": "Tasks:", "noReflections": "No reflections yet", + "reflecting": "Reflecting...", "reflectNow": "Reflect Now", "reflectNowTitle": "Generate a manual reflection", - "reflecting": "Reflecting...", "sectionTitle": "Performance, Reflections & Ratings", - "suggestedImprovements": "Suggested Improvements", - "triggerManual": "Manual", - "triggerPeriodic": "Periodic", - "triggerPostTask": "Post-Task", - "triggerUserRequested": "User Requested" + "suggestedImprovements": "Suggested Improvements" }, "refresh": "Refresh", "removeAvatar": "Remove avatar", @@ -842,19 +849,29 @@ "resetDayWeekly": "Day of week (0=Sun)", "resetting": "Resetting...", "result": "Result", - "resultCreated": "{{count}} created", - "resultErrors": "{{count}} error{{plural}}", - "resultSkipped": "{{count}} skipped (already exist)", + "resultCreated_one": "{{count}} created", + "resultCreated_other": "{{count}} created", + "resultErrors_one": "{{count}} error{{plural}}", + "resultErrors_other": "{{count}} error{{plural}}", + "resultSkipped_one": "{{count}} skipped (already exist)", + "resultSkipped_other": "{{count}} skipped (already exist)", "resume": "Resume", "resumeAction": "Resume", "resumeAgentsFailed": "Failed to resume agents: {{error}}", "resumeAll": "Resume All", "resumeAllAgents": "Resume All Agents", + "resumeAllConfirm_one": "Resume {{count}} agent in this project?", + "resumeAllConfirm_other": "Resume {{count}} agents in this project?", "resumeAllTitle": "Resume All Agents", - "resumeCountHint": "{{count}} paused agent(s) will be resumed", "resumeCountHint_one": "Resume {{count}} paused agent", "resumeCountHint_other": "Resume {{count}} paused agents", + "resumeCountHint_one_one": "", + "resumeCountHint_one_other": "", + "resumeCountHint_other_one": "", + "resumeCountHint_other_other": "", "resumedPast": "resumed", + "resumedSummary_one": "Resumed {{count}} agent; skipped {{skipped}}", + "resumedSummary_other": "Resumed {{count}} agents; skipped {{skipped}}", "retry": "Retry", "reviewConfiguration": "Review generated configuration", "reviewHint": "Review your agent configuration before creating.", @@ -868,20 +885,18 @@ "roleReviewer": "Reviewer", "roleScheduler": "Scheduler", "roleTriage": "Triage", + "roleUpdated": "Agent role updated to {{role}}", "roleUpdateError": "Failed to update role: {{error}}", "roleUpdateFailed": "Failed to update role: {{error}}", "roleUpdateSuccess": "Agent role updated to {{role}}", - "roleUpdated": "Agent role updated to {{role}}", "runAriaLabel": "Run {{id}}", "runDetailsFailed": "Failed to load run details", "runMissedHeartbeat": "Run missed heartbeat", "runMissedHeartbeatHint": "Trigger a run if the agent misses a scheduled heartbeat.", + "running": "Running", "runNow": "Run Now", "runNowAria": "Run now for {{name}}", "runNowFor": "Run now for {{name}}", - "runStarted": "Run started", - "runStopped": "Run stopped", - "running": "Running", "runs": { "empty": "No runs yet", "loading": "Loading runs…", @@ -890,9 +905,12 @@ "stopMessage": "Stop this run?", "stopTitle": "Stop Run" }, - "runsCount": "{{count}} run(s)", + "runsCount_one": "{{count}} run", + "runsCount_other": "{{count}} runs", "runsSuccessRate": "{{rate}}% success rate", + "runStarted": "Run started", "runsToday": "Runs today", + "runStopped": "Run stopped", "runtime": "Runtime", "runtimeEmpty": "No plugin runtimes available", "runtimeLabel": "Runtime", @@ -925,29 +943,37 @@ "selectAllAgents": "Select all agents", "selectAllSkills": "Select all skills", "selectAnAgent": "Select an agent", + "selectCompany": "Please select a company from the catalog", "selectDirectory": "Select Directory", + "selected": "Selected:", + "selectedAgentLabel_one": "{{count}} Agent{{plural}}", + "selectedAgentLabel_other": "{{count}} Agent{{plural}}", + "selectedSkillLabel_one": "{{count}} Skill{{plural}}", + "selectedSkillLabel_other": "{{count}} Skill{{plural}}", "selectMemoryFile": "Select a memory file", "selectModel": "Model", "selectModelPlaceholder": "Select a model…", "selectRuntime": "Select a runtime", "selectSkill": "Select skill {{name}}", - "selected": "Selected:", - "selectedAgentLabel": "{{count}} Agent{{plural}}", - "selectedSkillLabel": "{{count}} Skill{{plural}}", "setHeartbeatAria": "Set heartbeat interval for {{name}}", - "settingsSaveFailed": "Failed to save settings", "settingsSaved": "Settings saved", + "settingsSaveFailed": "Failed to save settings", "setupModeAriaLabel": "Agent setup mode", "showSystemAgents": "Show system agents", "skills": "Skills", "skillsDescription": "Manage the skills available to this agent.", - "skillsErrors": "{{count}} skill{{plural}} error{{pluralError}}", - "skillsFound": "{{count}} skill{{plural}} found", + "skillsErrors_one": "{{count}} skill{{plural}} error{{pluralError}}", + "skillsErrors_other": "{{count}} skill{{plural}} error{{pluralError}}", + "skillsFound_one": "{{count}} skill{{plural}} found", + "skillsFound_other": "{{count}} skill{{plural}} found", "skillsHint": "Optional skills to assign to this agent", - "skillsImported": "{{count}} skill{{plural}} imported", + "skillsImported_one": "{{count}} skill{{plural}} imported", + "skillsImported_other": "{{count}} skill{{plural}} imported", "skillsNone": "No skills assigned", - "skillsSelected": "{{count}} skill selected", - "skillsSkipped": "{{count}} skill{{plural}} skipped (already exist)", + "skillsSelected_one": "{{count}} skill selected", + "skillsSelected_other": "{{count}} skills selected", + "skillsSkipped_one": "{{count}} skill{{plural}} skipped (already exist)", + "skillsSkipped_other": "{{count}} skill{{plural}} skipped (already exist)", "skillsTitle": "Skills", "skipHeartbeatWhenIdle": "Skip heartbeat when idle", "skipHeartbeatWhenIdleHint": "Avoid running heartbeats when the agent has nothing to do.", @@ -955,23 +981,23 @@ "soulEmptyPreview": "No soul yet — switch to edit mode to add one.", "soulHint": "Describe who this agent is — its character, tone, and values.", "soulPlaceholder": "Describe this agent's soul...", - "soulSaveFailed": "Failed to save soul", "soulSaved": "Soul saved", + "soulSaveFailed": "Failed to save soul", "soulTitle": "Soul", "soulTooLong": "Soul content is too long", "start": "Start", - "startOnboarding": "Start onboarding", "starting": "Starting...", + "startOnboarding": "Start onboarding", "stateActive": "Active", "stateAll": "All States", "stateError": "Error", "stateIdle": "Idle", "statePaused": "Paused", "stateRunning": "Running", + "stateUpdated": "Agent state updated", "stateUpdateError": "Failed to update state: {{error}}", "stateUpdateFailed": "Failed to update agent state", "stateUpdateSuccess": "Agent state updated to {{state}}", - "stateUpdated": "Agent state updated", "status": "Status", "statusCount": "{{activeCount}} active · {{runningCount}} running", "step": "Step {{number}}{{total}}: {{name}}", @@ -1012,16 +1038,6 @@ "thinkingMinimal": "Minimal", "thinkingOff": "Off", "throughput": "Throughput", - "time": { - "daysAgo": "{{count}}d ago", - "hoursAgo": "{{count}}h ago", - "inAMoment": "in a moment", - "inDays": "in {{count}}d", - "inHours": "in {{count}}h", - "inMinutes": "in {{count}}m", - "justNow": "just now", - "minutesAgo": "{{count}}m ago" - }, "title": "Agents", "titleLabel": "Title", "titlePlaceholder": "e.g. Senior Engineer", @@ -1070,11 +1086,12 @@ }, "approval": { "dismissBanner": "Dismiss approval notification banner", - "needAttention": "{{count}} approval {{noun}} need your attention", + "needAttention_one": "{{count}} approval {{noun}} need your attention", + "needAttention_other": "{{count}} approval {{noun}} need your attention", "openMailbox": "Open Mailbox", "requestPlural": "requests", - "requestSingular": "request", - "requests": "Approval requests" + "requests": "Approval requests", + "requestSingular": "request" }, "auth": { "clearAndRetry": "Clear token and retry", @@ -1100,9 +1117,12 @@ "confirmMessage": "This session is active in another tab. Open anyway?", "confirmTitle": "Open Active Session", "dismissButton": "Dismiss", - "pillLabel": "AI {{count}}", - "pillTitle": "{{count}} background AI task", - "pillTitleWithInput": "{{count}} background AI task ({{needsInput}} needs input)", + "pillLabel_one": "AI {{count}}", + "pillLabel_other": "AI {{count}}", + "pillTitle_one": "{{count}} background AI task", + "pillTitle_other": "{{count}} background AI task", + "pillTitleWithInput_one": "{{count}} background AI task ({{needsInput}} needs input)", + "pillTitleWithInput_other": "{{count}} background AI task ({{needsInput}} needs input)", "popoverHeader": "Background Tasks", "status": { "activeElsewhere": "active in another tab", @@ -1123,10 +1143,19 @@ "done": "Done", "inProgress": "In Progress", "inReview": "In Review", + "rejection": { + "capacityExhausted": "That column is at capacity. Try again when a slot frees up.", + "guardRejected": "This move is not allowed by the workflow.", + "mergeBlocked": "This task is blocked from completing until its merge step finishes.", + "promoteRejected": "This card could not be promoted.", + "unknownColumn": "That column doesn't exist in this task's workflow.", + "workflowMismatch": "Drag can't move a card between workflows. Use the workflow switcher instead." + }, "todo": "To Do", "triage": "Triage" }, "branchGroup": { + "abandonGroup": "Abandon group", "autoMergeEnabled": "Auto-merge enabled", "collapseLabel": "Collapse branch group", "completionText": "{{landed}} of {{total}} members finished", @@ -1185,13 +1214,8 @@ "failedToCreateSession": "Failed to create chat session", "failedToDeleteConversation": "Failed to delete conversation", "failedToDeleteRoom": "Failed to delete room", - "failedToGetResponse": "Failed to get response", "failedToSendRoomMessage": "Failed to send room message", "failureDetails": "Failure details", - "failureReferenceId": "ID", - "failureReferenceKind": "Kind", - "failureReferenceLabel": "Reference", - "failureReferenceMetaLabel": "Label", "helpMessageContent": "Available commands:\n- `/new` or `/clear` — Clear conversation and start fresh\n- `/skill:{name}` — Use a specific skill\n- `/help` — Show this help", "jumpToLatest": "Latest", "latest": "Latest", @@ -1222,14 +1246,16 @@ "noRoomsYet": "No rooms yet.", "noSkillsAvailable": "No skills available", "noSkillsFound": "No skills found", - "openMailboxMessage": "Open mailbox message", "openQuickChat": "Open quick chat", "queuedMessage": "Queued: {{preview}}", "quickChatTitle": "Quick Chat", - "relativeTimeDays": "{{count}}d ago", - "relativeTimeHours": "{{count}}h ago", + "relativeTimeDays_one": "{{count}}d ago", + "relativeTimeDays_other": "{{count}}d ago", + "relativeTimeHours_one": "{{count}}h ago", + "relativeTimeHours_other": "{{count}}h ago", "relativeTimeJustNow": "just now", - "relativeTimeMinutes": "{{count}}m ago", + "relativeTimeMinutes_one": "{{count}}m ago", + "relativeTimeMinutes_other": "{{count}}m ago", "removeAttachment": "Remove {{name}}", "resizePanelBottom": "Resize panel from bottom", "resizePanelBottomLeft": "Resize panel from bottom-left corner", @@ -1242,7 +1268,8 @@ "resizeSidebar": "Resize chat sidebar", "responseCopied": "Response copied", "responseFailed": "Response failed", - "roomMemberCount": "{{count}} member", + "roomMemberCount_one": "{{count}} member", + "roomMemberCount_other": "{{count}} members", "roomsGroupLabel": "Rooms", "scopeDirect": "Direct", "scopeRooms": "Rooms", @@ -1269,19 +1296,12 @@ "thinking": "Thinking", "thinkingLabel": "Thinking", "thinkingStatus": "Thinking…", - "toolCallArgsPrefix": "args", - "toolCallResultPrefix": "result", - "toolCallStatusCompleted": "completed", - "toolCallStatusError": "error", - "toolCallStatusErrors": "errors", - "toolCallStatusRunning": "running", "toolCalls": "Tool calls", - "toolCallsCount": "{{count}} tool calls", - "toolCallsHeader": "Tool calls", + "toolCallsCount_one": "{{count}} tool calls", + "toolCallsCount_other": "{{count}} tool calls", "typeMessage": "Type a message...", "unreadMessages": "Unread messages", "untitledSession": "Untitled", - "viewFailureDetails": "View failure details", "you": "You" }, "chatRooms": { @@ -1295,8 +1315,8 @@ "cli": { "installBody": "Get the {{fn}} and {{fusion}} commands on your terminal so you can drive Fusion from anywhere. One click below or copy the command into your shell.", "installButton": "Install with npm", - "installTitle": "Install the Fusion CLI", "installing": "Installing…", + "installTitle": "Install the Fusion CLI", "openSettings": "Open Settings", "updateButton": "Update with npm", "updateTitle": "Update the Fusion CLI", @@ -1312,8 +1332,8 @@ "failedExit": "Install failed (exit {{code}})", "heading": "CLI Binary", "help": "Installing the global CLI lets you run fn and fusion from any terminal. Automations and scripts work without it via npx, but a global install is faster and more convenient.", - "installWithNpm": "Install with npm", "installing": "Installing…", + "installWithNpm": "Install with npm", "notOnPath": "Neither fn nor fusion was found on PATH.", "orCopyLabel": "Or copy and run yourself:", "refresh": "Refresh", @@ -1329,9 +1349,11 @@ "actionsTitle": "Column actions", "archiveAllDoneAriaLabel": "Archive all done tasks", "archiveAllDoneTitle": "Archive all done tasks", - "archiveAllMessage": "Archive all {{count}} done tasks?", + "archiveAllMessage_one": "Archive all {{count}} done tasks?", + "archiveAllMessage_other": "Archive all {{count}} done tasks?", "archiveAllTitle": "Archive All Done", - "archivedTasks": "Archived {{count}} tasks", + "archivedTasks_one": "Archived {{count}} tasks", + "archivedTasks_other": "Archived {{count}} tasks", "autoMerge": "Auto-merge", "autoMergeDisabled": "Auto-merge disabled", "autoMergeEnabled": "Auto-merge enabled", @@ -1342,26 +1364,36 @@ "expandArchivedTitle": "Expand archived tasks", "failedToArchive": "Failed to archive tasks", "keepProgress": "Keep Progress", - "loadMore": "Load {{count}} more ({{remaining}} remaining)", + "loadMore_one": "Load {{count}} more ({{remaining}} remaining)", + "loadMore_other": "Load {{count}} more ({{remaining}} remaining)", "moveAllToTodo": "Move All to Todo", - "moveAllToTodoMessage": "Move all {{count}} {{columnLabel}} task{{plural}} to Todo?", + "moveAllToTodoMessage_one": "Move all {{count}} {{columnLabel}} task{{plural}} to Todo?", + "moveAllToTodoMessage_other": "Move all {{count}} {{columnLabel}} task{{plural}} to Todo?", "moveAllToTodoTitle": "Move All to Todo", + "movedToPlanning_one": "Moved {{count}} task{{plural}} to planning for replanning", + "movedToPlanning_other": "Moved {{count}} task{{plural}} to planning for replanning", + "movedToTodo_one": "Moved {{count}} task{{plural}} to Todo", + "movedToTodo_other": "Moved {{count}} task{{plural}} to Todo", "movePartialFailure": "Moved {{moved}} of {{total}} tasks; {{failed}} failed", - "moveToTodoHint": "Move {{count}} task{{plural}} to Todo", + "moveToTodoHint_one": "Move {{count}} task{{plural}} to Todo", + "moveToTodoHint_other": "Move {{count}} task{{plural}} to Todo", "moveToTodoPartialFailure": "Moved {{moved}} of {{total}} tasks to Todo; {{failed}} failed", - "movedToPlanning": "Moved {{count}} task{{plural}} to planning for replanning", - "movedToTodo": "Moved {{count}} task{{plural}} to Todo", "newTask": "New Task", "noManuallyPausableTasks": "No manually pausable tasks", "noTasks": "No tasks", "noTasksInColumn": "No tasks in this column", - "pauseHint": "Pause {{count}} active unassigned task{{plural}}", + "pauseHint_one": "Pause {{count}} active unassigned task{{plural}}", + "pauseHint_other": "Pause {{count}} active unassigned task{{plural}}", "preserveProgressMessage": "This task has completed steps. Keep progress before moving?", "preserveProgressMoveTodoMessage": "Some tasks have completed steps. Keep progress before moving to Todo?", "preserveProgressTitle": "Preserve Progress?", + "promote": "Promote", + "promoting": "Promoting…", "replanAll": "Replan All", - "replanAllHint": "Move {{count}} task{{plural}} to Planning", - "replanAllMessage": "Move all {{count}} todo task{{plural}} back to planning to be replanned?", + "replanAllHint_one": "Move {{count}} task{{plural}} to Planning", + "replanAllHint_other": "Move {{count}} task{{plural}} to Planning", + "replanAllMessage_one": "Move all {{count}} todo task{{plural}} back to planning to be replanned?", + "replanAllMessage_other": "Move all {{count}} todo task{{plural}} back to planning to be replanned?", "replanAllTitle": "Replan All Tasks", "resetProgress": "Reset Progress", "resetProgressConfirm": "Reset Progress", @@ -1369,10 +1401,12 @@ "resetProgressMoveTodoMessage": "Reset step progress for tasks before moving to Todo?", "resetProgressTitle": "Reset Progress?", "stopAll": "Stop All", - "stopAllMessage": "Stop all {{count}} {{columnLabel}} task{{plural}}?", + "stopAllMessage_one": "Stop all {{count}} {{columnLabel}} task{{plural}}?", + "stopAllMessage_other": "Stop all {{count}} {{columnLabel}} task{{plural}}?", "stopAllTitle": "Stop All Tasks", "stopPartialFailure": "Stopped {{paused}} of {{total}} tasks; {{failed}} failed", - "stoppedTasks": "Stopped {{count}} task{{plural}}" + "stoppedTasks_one": "Stopped {{count}} task{{plural}}", + "stoppedTasks_other": "Stopped {{count}} task{{plural}}" }, "comments": { "addButton": "Add Comment", @@ -1387,7 +1421,8 @@ "updatedSuccess": "Comment updated" }, "commit": { - "filesChanged": "Files Changed ({{count}})" + "filesChanged_one": "Files Changed ({{count}})", + "filesChanged_other": "Files Changed ({{count}})" }, "commitDiff": { "error": "Error loading commit diff: {{error}}", @@ -1398,6 +1433,7 @@ "noSha": "No commit SHA available." }, "common": { + "archive": "Archive", "back": "Back", "cancel": "Cancel", "close": "Close", @@ -1419,11 +1455,13 @@ "save": "Save", "saveAndTest": "Save & Test", "saving": "Saving...", + "skip": "Skip", "somethingWentWrong": "Something went wrong while loading this view.", "stop": "Stop", "test": "Test", "testing": "Testing…", "total": "total", + "tryAgain": "Try Again", "unableToLoadData": "Unable to load data", "unknown": "Unknown", "unsavedChanges": "Unsaved changes", @@ -1436,8 +1474,8 @@ "messagePlaceholder": "Type your message…", "newMessageTitle": "New Message", "noAgentsAvailable": "No agents available", - "replyTitle": "Reply", "replyingToLabel": "Replying to:", + "replyTitle": "Reply", "selectAgent": "Select agent…", "sendingButton": "Sending…", "toLabel": "To:", @@ -1464,30 +1502,19 @@ "createRoom": { "create": "Create room", "creating": "Creating...", - "duplicate": "A room with this name already exists.", "failedCreate": "Failed to create room.", "failedLoadAgents": "Failed to load agents.", "loadingAgents": "Loading agents...", - "lowercase": "Use lowercase letters only.", - "maxLength": "Room names can be at most 80 characters.", "members": "Members", "nameLabel": "Room name", - "nameRequired": "Room name is required.", "noAgents": "No agents in this project yet.", - "noEdgeChars": "Room names cannot start or end with a hyphen or underscore.", "noMatch": "No agents match your search.", "searchAgents": "Search agents", "selectMember": "Select at least one member.", - "title": "Create room", - "validChars": "Use lowercase letters, numbers, hyphens, or underscores only." + "title": "Create room" }, "dashboard": { "initializingDashboard": "Initializing dashboard...", - "loaderSteps": { - "project": "Selecting project", - "projects": "Loading projects", - "tasks": "Fetching tasks" - }, "loadingMessage": "Loading Fusion dashboard", "loadingProgress": "Dashboard loading progress", "updatingMessage": "Updating Fusion dashboard", @@ -1537,15 +1564,18 @@ "filterBySeverity": "Filter logs by severity", "info": "Info", "lines": "{{count}} lines", - "loadOlderLogs": "Load older logs", + "lines_one": "{{count}} lines", + "lines_other": "{{count}} lines", "loading": "Loading...", "loadingConfig": "Loading dev server configuration...", "loadingLogs": "Loading logs…", "loadingOlderLogs": "Loading older logs…", + "loadOlderLogs": "Load older logs", "logs": "Logs", "lostConnection": "Lost log stream connection.", "manual": "Manual", - "matchCount": "{{count}} match", + "matchCount_one": "{{count}} match", + "matchCount_other": "{{count}} matches", "newLogs": "New logs", "noLogsYet": "No logs yet. Start the dev server to see output.", "noMatchesSearch": "No log lines match your search.", @@ -1712,7 +1742,8 @@ "clearSearch": "Clear search", "collapse": "Collapse", "collapseContent": "Collapse content", - "docCount": "{{count}} doc{{plural}}", + "docCount_one": "{{count}} doc{{plural}}", + "docCount_other": "{{count}} doc{{plural}}", "documentsCreatedIn": "Documents are created in task detail tabs.", "expand": "Expand", "expandContent": "Expand content", @@ -1732,7 +1763,8 @@ "plain": "Plain", "projectFiles": "project files", "projectFilesTab": "Project Files", - "resultCount": "{{count}} result{{plural}}", + "resultCount_one": "{{count}} result{{plural}}", + "resultCount_other": "{{count}} result{{plural}}", "retry": "Retry", "retryLoading": "Retry loading documents", "searchProjectFiles": "Search project markdown files…", @@ -1811,21 +1843,26 @@ }, "executor": { "blocked": "Blocked", - "daysAgo": "{{count}}d ago", + "daysAgo_one": "{{count}}d ago", + "daysAgo_other": "{{count}}d ago", "escalated": "Escalated", "escalatedSuffix": " (escalated)", "hideProjectDir": "Hide project directory", - "hoursAgo": "{{count}}h ago", + "hoursAgo_one": "{{count}}h ago", + "hoursAgo_other": "{{count}}h ago", "inReview": "In Review", "justNow": "just now", "loading": "Loading...", - "minutesAgo": "{{count}}m ago", + "minutesAgo_one": "{{count}}m ago", + "minutesAgo_other": "{{count}}m ago", "noActivity": "no activity", - "overlapBottleneck": "{{status}} overlap bottleneck {{blockerId}}: {{count}} todo blocked via blockedBy (threshold {{threshold}})", + "overlapBottleneck_one": "{{status}} overlap bottleneck {{blockerId}}: {{count}} todo blocked via blockedBy (threshold {{threshold}})", + "overlapBottleneck_other": "{{status}} overlap bottleneck {{blockerId}}: {{count}} todo blocked via blockedBy (threshold {{threshold}})", "overlapQueue": "Overlap queue", "queued": "Queued", "running": "Running", - "secondsAgo": "{{count}}s ago", + "secondsAgo_one": "{{count}}s ago", + "secondsAgo_other": "{{count}}s ago", "showProjectDir": "Show project directory", "stateIdle": "Idle", "statePaused": "Paused", @@ -1906,8 +1943,10 @@ "advancesHelpItem2": "reachable / subsumed / orphaned / superseded — already handled (including history rewrites where equivalent content already landed, original SHAs disappeared, or HEAD is already aligned to the rewritten integration tip).", "advancesHelpItem3": "pending + off / not run — auto-sync is disabled in Settings; the branch ref moved but your worktree didn't follow.", "advancesHelpItem4": "pending + stash-failed / would-conflict / similar — auto-sync tried but couldn't reconcile (usually local edits collide with the new commit).", - "advancesNeedAction": "{{count}} need action", - "aheadOfUpstream": "{{count}} commit(s) ahead of upstream", + "advancesNeedAction_one": "{{count}} need action", + "advancesNeedAction_other": "{{count}} need action", + "aheadOfUpstream_one": "{{count}} commit(s) ahead of upstream", + "aheadOfUpstream_other": "{{count}} commit(s) ahead of upstream", "aligned": "Aligned", "apply": "Apply", "applyStashKeep": "Apply stash (keep)", @@ -1923,7 +1962,8 @@ "backToIssuesList": "Back to issues list", "backToPullsList": "Back to pull requests list", "baseHead": "Base: HEAD", - "behindUpstream": "{{count}} commit(s) behind upstream", + "behindUpstream_one": "{{count}} commit(s) behind upstream", + "behindUpstream_other": "{{count}} commit(s) behind upstream", "branchLabel": "Branch:", "cancel": "Cancel", "capturedAt": "Captured:", @@ -1936,16 +1976,20 @@ "commentLast": "Last:", "commit": "Commit", "commitMessagePlaceholder": "Commit message...", - "commitStagedChanges": "Commit staged changes", "commitsOnBranch": "Commits on {{name}}", - "commitsToPull": "{{count}} to pull", - "commitsToPush": "{{count}} to push", - "commitsToPushHeader": "Commits to Push ({{count}})", + "commitStagedChanges": "Commit staged changes", + "commitsToPull_one": "{{count}} to pull", + "commitsToPull_other": "{{count}} to pull", + "commitsToPush_one": "{{count}} to push", + "commitsToPush_other": "{{count}} to push", + "commitsToPushHeader_one": "Commits to Push ({{count}})", + "commitsToPushHeader_other": "Commits to Push ({{count}})", "committedHash": "Committed: {{hash}}", + "conflictedCount_one": "{{count}} conflicted", + "conflictedCount_other": "{{count}} conflicted", "conflictReclaimFailed": "Failed to queue conflict reclaim", "conflictReclaimQueued": "Conflict reclaim queued", "conflictReclaimUnavailable": "Conflict reclaim unavailable", - "conflictedCount": "{{count}} conflicted", "conflictsButton": "Conflicts", "copiedButton": "Copied", "copiedLabel": "Copied {{label}}", @@ -1962,9 +2006,9 @@ "couldNotLoadIssues": "Could not load issues", "couldNotLoadPulls": "Could not load pull requests", "create": "Create", + "createdBranch": "Created branch {{name}}", "createPrButton": "Create PR", "createPrTitle": "Create a PR for this task", - "createdBranch": "Created branch {{name}}", "defaultBadge": "default", "deleteBranch": "Delete", "deleteBranchMessage": "Delete branch \"{{name}}\"?", @@ -1972,10 +2016,12 @@ "deletedBranch": "Deleted branch {{name}}", "detectingRemotes": "Detecting…", "diffColon": "diff:", - "discardChangesMessage": "Discard changes to {{count}} file(s)? This cannot be undone.", + "discardChangesMessage_one": "Discard changes to {{count}} file(s)? This cannot be undone.", + "discardChangesMessage_other": "Discard changes to {{count}} file(s)? This cannot be undone.", "discardChangesTitle": "Discard Changes", + "discardedFiles_one": "Discarded changes to {{count}} file(s)", + "discardedFiles_other": "Discarded changes to {{count}} file(s)", "discardSelected": "Discard selected", - "discardedFiles": "Discarded changes to {{count}} file(s)", "dismiss": "Dismiss", "dismissPrError": "Dismiss PR error", "dropStash": "Drop stash", @@ -2011,9 +2057,9 @@ "fetch": "Fetch", "fetchCompleted": "Fetch completed", "fetchFailed": "Fetch failed", + "fetchingFromGitHub": "Fetching the latest list from GitHub.", "fetchLabel": "Fetch:", "fetchUrlLabel": "fetch URL", - "fetchingFromGitHub": "Fetching the latest list from GitHub.", "filterBranches": "Filter branches...", "filterByLabelsLabel": "Filter by labels", "filterByLabelsPlaceholder": "Filter: bug,enhancement…", @@ -2025,24 +2071,27 @@ "forceDeletedBranch": "Force deleted branch {{name}}", "fullShaAbbrev": "full", "ghAuthLoginHint": "Run {{code}} to enable PR creation.", - "headAheadOfIntegration": "HEAD has {{count}} commit(s) not on {{branch}}", - "headAheadOfOriginIntegration": "HEAD has {{count}} commit(s) not on origin/{{branch}}", + "headAheadOfIntegration_one": "HEAD has {{count}} commit(s) not on {{branch}}", + "headAheadOfIntegration_other": "HEAD has {{count}} commit(s) not on {{branch}}", + "headAheadOfOriginIntegration_one": "HEAD has {{count}} commit(s) not on origin/{{branch}}", + "headAheadOfOriginIntegration_other": "HEAD has {{count}} commit(s) not on origin/{{branch}}", "headVsIntegration": "HEAD vs {{branch}}", "headVsOriginIntegration": "HEAD vs origin/{{branch}}", "hide": "Hide", "hideExplanation": "Hide explanation", "import": "Import", + "imported": "Imported", + "importedCount_one": "{{count}} imported", + "importedCount_other": "{{count}} imported", "importFromGitHub": "Import from GitHub", "importSubtitle": "Choose a detected remote, load open issues or pull requests, and import one into the board.", "importTypeAriaLabel": "Import type", - "imported": "Imported", - "importedCount": "{{count}} imported", - "integrationAheadOfHead": "{{branch}} has {{count}} commit(s) HEAD doesn't", - "issueCount": "{{count}} issue", + "integrationAheadOfHead_one": "{{branch}} has {{count}} commit(s) HEAD doesn't", + "integrationAheadOfHead_other": "{{branch}} has {{count}} commit(s) HEAD doesn't", + "issueCount_one": "{{count}} issue", + "issueCount_other": "{{count}} issues", "load": "Load", "loadFromRepoAriaLabel": "Load {{tab}} from repository", - "loadMoreCommits": "Load more commits", - "loadTabTitle": "Load {{tab}}", "loading": "Loading…", "loadingAriaLabel": "Loading {{tab}}", "loadingCommits": "Loading commits...", @@ -2051,23 +2100,28 @@ "loadingPulls": "Loading open pull requests…", "loadingStashDiff": "Loading stash diff…", "loadingTitle": "Loading…", - "localAheadOfOriginIntegration": "Local {{branch}} is {{count}} commit(s) ahead of origin/{{branch}}", - "localBehindOriginIntegration": "Local {{branch}} is {{count}} commit(s) behind origin/{{branch}}", + "loadMoreCommits": "Load more commits", + "loadTabTitle": "Load {{tab}}", + "localAheadOfOriginIntegration_one": "Local {{branch}} is {{count}} commit(s) ahead of origin/{{branch}}", + "localAheadOfOriginIntegration_other": "Local {{branch}} is {{count}} commit(s) ahead of origin/{{branch}}", + "localBehindOriginIntegration_one": "Local {{branch}} is {{count}} commit(s) behind origin/{{branch}}", + "localBehindOriginIntegration_other": "Local {{branch}} is {{count}} commit(s) behind origin/{{branch}}", "localVsOrigin": "Local {{branch}} vs origin", "manualPrFlowHint": "Use the footer action to run PR-first completion for this task.", "mergeBadge": "merge", "mergeConflictDetected": "Merge conflict detected. Resolve/rebase branch and retry reclaim.", + "mergedTaskDone": "Merged — task moved to Done", "mergeLabel": "Merge", "mergePrButton": "Merge pull request", "mergeStrategyMerge": "merge", "mergeStrategyRebase": "rebase", "mergeStrategySquash": "squash", - "mergedTaskDone": "Merged — task moved to Done", "mergingPrHint": "Merging pull request…", "mergingStatus": "Merging…", "modalTitle": "Git Manager", "modified": "Modified", - "modifiedCount": "{{count}} modified", + "modifiedCount_one": "{{count}} modified", + "modifiedCount_other": "{{count}} modified", "newBranchName": "New branch name", "noAheadCommitsFound": "No ahead commits found (may need to fetch first)", "noBranchesFound": "No branches found", @@ -2081,9 +2135,7 @@ "noMatchingBranches": "No matching branches", "noMatchingCommits": "No matching commits", "noOpenIssues": "No open issues found", - "noOpenIssuesFound": "No open issues found", "noOpenPulls": "No open pull requests found", - "noOpenPullsFound": "No open pull requests found", "noOriginTracking": "no origin tracking", "noPullSelected": "No pull request selected", "noPullSelectedHint": "Choose a pull request from the list to inspect its details.", @@ -2097,14 +2149,15 @@ "noStagedChanges": "No staged changes", "noStagedChangesToCommit": "No staged changes to commit", "noStashes": "No stashes", - "noUnstagedChanges": "No unstaged changes", + "nothingLoadedInstructions": "Select a repository and click Load to start reviewing import candidates.", + "nothingLoadedYet": "Nothing loaded yet", "notOnIntegrationBranch": "(not on {{branch}})", "notOnIntegrationBranchBtn": "Not on integration branch ({{branch}})", "notOnIntegrationBranchTitle": "Currently on a non-integration branch", - "nothingLoadedInstructions": "Select a repository and click Load to start reviewing import candidates.", - "nothingLoadedYet": "Nothing loaded yet", + "noUnstagedChanges": "No unstaged changes", "openPullsFrom": "Open pull requests from {{remote}}", - "originIntegrationAheadOfHead": "origin/{{branch}} has {{count}} commit(s) HEAD doesn't", + "originIntegrationAheadOfHead_one": "origin/{{branch}} has {{count}} commit(s) HEAD doesn't", + "originIntegrationAheadOfHead_other": "origin/{{branch}} has {{count}} commit(s) HEAD doesn't", "pop": "Pop", "popStashTitle": "Pop stash (apply and drop)", "prAuthUnavailable": "PR auth unavailable — run 'gh auth login'", @@ -2116,34 +2169,36 @@ "summary": "{{passing}} passing, {{failing}} failing, {{pending}} pending", "viewDetails": "View details" }, - "prMergeFailed": "Failed to merge pull request", + "previewHeading": "Preview", + "previewIssueMeta": "Issue #{{number}}", + "previewPullMeta": "Pull Request #{{number}}", "prMerged": "Pull request merged", + "prMergeFailed": "Failed to merge pull request", + "projectRootNotAvailable": "Project root path not available", "prRefreshFailed": "Failed to refresh PR", "prStatusRefreshed": "PR status refreshed", "prUnlinkConfirm": "Unlink PR #{{number}} from this task? The PR will not be closed.", "prUnlinked": "Unlinked PR #{{number}}", - "previewHeading": "Preview", - "previewIssueMeta": "Issue #{{number}}", - "previewPullMeta": "Pull Request #{{number}}", - "projectRootNotAvailable": "Project root path not available", "pull": "Pull", "pullCompleted": "Pull completed", - "pullCount": "{{count}} pull request", + "pullCount_one": "{{count}} pull request", + "pullCount_other": "{{count}} pull requests", "pullFailed": "Pull failed", "pullOptions": "Pull options", "pullOptionsMenu": "Pull options menu", "pullRebase": "Pull --rebase", "pullRebaseCompleted": "Pull --rebase completed", "pullRequestHeading": "Pull Request", - "pullRequestsCount": "{{count}} pull requests", + "pullRequestsCount_one": "{{count}} pull requests", + "pullRequestsCount_other": "{{count}} pull requests", "push": "Push", "pushCompleted": "Push completed", "pushFailed": "Push failed", "pushLabel": "Push:", "pushUrlLabel": "push URL", - "reCheckConflicts": "Re-check conflicts", "recentCommitsOnRemote": "Recent commits on {{remote}}", "recentIntegrationAdvances": "Recent integration-branch advances", + "reCheckConflicts": "Re-check conflicts", "refresh": "Refresh", "refreshPrStatus": "Refresh PR status", "refreshToCheckMerge": "Refresh PR status to check merge readiness", @@ -2176,24 +2231,28 @@ "sectionStashes": "Stashes", "sectionStatus": "Status", "sectionWorktrees": "Worktrees", + "selectedRemote": "selected remote", "selectFileToViewDiff": "Select a file to view its diff", "selectIssueAriaLabel": "Select issue #{{number}}", "selectPullAriaLabel": "Select pull request #{{number}}", "selectRemoteAriaLabel": "Select Git remote", "selectRemotePlaceholder": "Select remote…", "selectRemoteToViewDetails": "Select a remote to view details", - "selectedRemote": "selected remote", "sidebarAriaLabel": "Git Manager Sections", "stageAll": "Stage All", "stageAllAndCommit": "Stage All & Commit", "stageAllAndCommitTitle": "Stage all and commit", - "stageCount": "Stage ({{count}})", + "stageCount_one": "Stage ({{count}})", + "stageCount_other": "Stage ({{count}})", + "staged": "Staged", + "stagedChanges_one": "Staged Changes ({{count}})", + "stagedChanges_other": "Staged Changes ({{count}})", + "stagedCount_one": "{{count}} staged", + "stagedCount_other": "{{count}} staged", + "stagedFiles_one": "Staged {{count}} file(s)", + "stagedFiles_other": "Staged {{count}} file(s)", "stageFile": "Stage file", "stageSelected": "Stage selected", - "staged": "Staged", - "stagedChanges": "Staged Changes ({{count}})", - "stagedCount": "{{count}} staged", - "stagedFiles": "Staged {{count}} file(s)", "staleIndexWarning": "Stale index detected. HEAD has advanced (typically because Fusion's merger updated the integration-branch ref) but the index still reflects the previous tip — `git status` will report the new commits inverted as \"staged changes.\" Enable mergeAdvanceAutoSync in Settings to have the merger reconcile automatically, or run git reset --hard HEAD to snap forward manually.", "stash": "Stash", "stashApplied": "Stash applied", @@ -2220,17 +2279,17 @@ "statusLabelWorkingTree": "Working Tree", "switchedToBranch": "Switched to {{name}}", "sync": "Sync", + "synced": "Synced", + "syncedWithOrigin": "Synced with origin (pull --rebase + push)", + "syncedWorktreeToIntegrationTip": "Synced worktree to local integration tip", "syncFailed": "Sync failed", + "syncing": "Syncing…", "syncLocalTip": "Sync local tip", "syncLocalTipTitle": "Sync working tree to local integration tip (same as banner Pull)", "syncOriginTitle": "Pull --rebase from origin, then push current branch", "syncWithOriginFailed": "Sync with origin failed", "syncWorkingTree": "Sync working tree", "syncWorkingTreeTitle": "Pull the integration branch into your working tree (auto-stashes uncommitted edits and restores them)", - "synced": "Synced", - "syncedWithOrigin": "Synced with origin (pull --rebase + push)", - "syncedWorktreeToIntegrationTip": "Synced worktree to local integration tip", - "syncing": "Syncing…", "tabIssues": "Issues", "tabPullRequests": "Pull Requests", "tip": "tip", @@ -2239,14 +2298,18 @@ "unlinkButton": "Unlink", "unresolvedMergeConflicts": "Unresolved merge conflicts", "unstageAll": "Unstage All", - "unstageCount": "Unstage ({{count}})", + "unstageCount_one": "Unstage ({{count}})", + "unstageCount_other": "Unstage ({{count}})", + "unstaged": "Unstaged", + "unstagedChanges_one": "Unstaged Changes ({{count}})", + "unstagedChanges_other": "Unstaged Changes ({{count}})", + "unstagedFiles_one": "Unstaged {{count}} file(s)", + "unstagedFiles_other": "Unstaged {{count}} file(s)", "unstageFile": "Unstage file", "unstageSelected": "Unstage selected", - "unstaged": "Unstaged", - "unstagedChanges": "Unstaged Changes ({{count}})", - "unstagedFiles": "Unstaged {{count}} file(s)", "untracked": "Untracked", - "untrackedCount": "{{count}} untracked", + "untrackedCount_one": "{{count}} untracked", + "untrackedCount_other": "{{count}} untracked", "upToDate": "Up to date", "view": "View", "viewOnGithub": "View on GitHub", @@ -2256,18 +2319,21 @@ "workingTreeModified": "Modified", "worktreeBadgeBare": "bare", "worktreeBadgeMain": "main", - "worktreesInUse": "{{count}} in use", - "worktreesTotal": "{{count}} total" + "worktreesInUse_one": "{{count}} in use", + "worktreesInUse_other": "{{count}} in use", + "worktreesTotal_one": "{{count}} total", + "worktreesTotal_other": "{{count}} total" }, "goals": { - "activeCount": "{{count}} active goals", + "activeCount_one": "{{count}} active goals", + "activeCount_other": "{{count}} active goals", "addGoal": "Add Goal", "archive": "Archive", "capError": "Cannot activate more than 5 goals. Resolve an active goal before activating another.", "capWarning": "Approaching the 5-active goal cap. Keep active goals focused.", "createError": "Unable to create goal right now. Please try again.", - "draftWithAi": "Draft with AI", "drafting": "Drafting…", + "draftWithAi": "Draft with AI", "emptyState": "No goals yet. Add one to begin tracking strategic outcomes.", "labelDescription": "Description", "labelTitle": "Title", @@ -2281,6 +2347,7 @@ "updateError": "Unable to update goal status right now. Please try again." }, "groupTask": { + "abandonGroup": "Abandon group", "ariaLabel": "Branch group details", "autoMergeEnabled": "Auto-merge enabled", "completionText": "{{landed}} of {{total}} members finished", @@ -2290,12 +2357,16 @@ "mergeIntoMain": "Merge group into main", "openPR": "Open PR", "openTask": "Open task", + "prClosed": "Group PR closed", + "prMerged": "Group PR merged", "sharedBranch": "Shared branch", "status": "Status", "title": "Branch Group {{id}}", "unavailable": "Branch group unavailable" }, "header": { + "activePlanningSessions_one": "{{count}} active planning session", + "activePlanningSessions_other": "{{count}} active planning sessions", "addFirstScript": "Add your first script", "additionalHeaderActions": "Additional header actions", "agentsView": "Agents view", @@ -2321,7 +2392,8 @@ "localNode": "Local", "mailbox": "Mailbox", "mailboxView": "Mailbox view", - "mailboxWithCount": "Mailbox ({{count}})", + "mailboxWithCount_one": "Mailbox ({{count}})", + "mailboxWithCount_other": "Mailbox ({{count}})", "manageProjects": "Manage Projects", "manageScripts": "Manage Scripts...", "memoryView": "Memory", @@ -2330,10 +2402,10 @@ "moreHeaderActions": "More header actions", "moreViews": "More views", "noBaseBranch": "No base branch", + "nodes": "Nodes", "noScriptsAddOne": "No scripts — add one…", "noScriptsConfigured": "No scripts configured", "noWorkingBranch": "No working branch", - "nodes": "Nodes", "openSearch": "Open search", "openTerminal": "Open Terminal", "pauseTriage": "Pause triage", @@ -2343,7 +2415,8 @@ "reliabilityView": "Reliability", "researchView": "Research", "resumePlanningSession": "Resume planning session", - "resumePlanningSessionCount": "Resume planning session ({{count}})", + "resumePlanningSessionCount_one": "Resume planning session ({{count}})", + "resumePlanningSessionCount_other": "Resume planning session ({{count}})", "resumeScheduling": "Resume scheduling", "scripts": "Scripts", "scriptsSubmenu": "Scripts submenu", @@ -2364,7 +2437,8 @@ "terminal": "Terminal", "todosView": "Todos", "unreadChatResponse": "Unread chat response", - "unreadMessages": "{{count}} unread messages", + "unreadMessages_one": "{{count}} unread messages", + "unreadMessages_other": "{{count}} unread messages", "viewActivityLog": "View Activity Log", "viewProjects": "View Projects", "viewUsage": "View usage", @@ -2373,12 +2447,6 @@ }, "health": { "activeTasks": "Active Tasks", - "anomaly": { - "duplicateActiveId": "Duplicate active task ID", - "idInBothStorages": "Task ID present in active and archived storage", - "sequenceOverlap": "Allocator next sequence overlaps an existing task ID", - "unknownPrefix": "Task row uses a prefix outside allocator state" - }, "anomalyBody": "Fusion found allocator state that can cause task IDs to be reused or overwrite live task records.", "anomalyDetected": "Task ID integrity anomaly detected", "completed": "Completed", @@ -2436,26 +2504,23 @@ "collapse": "Collapse", "collapseDescription": "Collapse description", "collapseTaskOptions": "Collapse advanced task options", - "connecting": "Connecting", "creating": "Creating...", "custom": "Custom", "deps": "Deps", "editingDescription": "Editing Description", "enableBrowserVerification": "Enable browser verification workflow step", "enterDescriptionFirst": "Enter a description first", - "error": "Error", "expand": "Expand", "expandDescription": "Expand description", "expandTaskOptions": "Expand advanced task options", "hintEnterEsc": "Enter to create · Esc to cancel", "loadingAgents": "Loading agents...", - "model": "model", + "model_one": "model", + "model_other": "model", "models": "Models", "noAgentsAvailable": "No agents available", - "noExistingTasks": "No existing tasks", "node": "Node", - "offline": "Offline", - "online": "Online", + "noExistingTasks": "No existing tasks", "openPlanningMode": "Open planning mode with current description", "plan": "Plan", "preset": "Preset", @@ -2475,39 +2540,22 @@ "allInsights": "All Insights", "alreadyRunning": "Insight generation is already running. Showing the active run.", "alreadyRunningShort": "Insight generation is already running", - "archiveLabel": "Archive this insight", - "archiveTitle": "Archive this insight", "archived": "Archived \"{{title}}\"", "archivedMsg": "Insight archived: {{title}}", + "archiveLabel": "Archive this insight", + "archiveTitle": "Archive this insight", "archiving": "Archiving \"{{title}}\"...", "backlogHealth": "Backlog Health", - "category": { - "architecture": "Architecture", - "competitive_analysis": "Competitive Analysis", - "dependency": "Dependencies", - "documentation": "Documentation", - "features": "Features", - "other": "Other", - "performance": "Performance", - "quality": "Quality", - "reliability": "Reliability", - "research": "Research", - "security": "Security", - "testability": "Testability", - "trends": "Trends", - "ux": "User Experience", - "workflow": "Workflow" - }, "configureModel": "Configure insight generation model", "configureModelTitle": "Configure model", "createTaskLabel": "Create task from this insight", "createTaskTitle": "Create task from this insight", "creatingTask": "Creating task from \"{{title}}\"...", - "dismissLabel": "Dismiss this insight", - "dismissTitle": "Dismiss this insight", "dismissed": "Dismissed \"{{title}}\"", "dismissedMsg": "Insight dismissed: {{title}}", "dismissing": "Dismissing \"{{title}}\"...", + "dismissLabel": "Dismiss this insight", + "dismissTitle": "Dismiss this insight", "failedToArchive": "Failed to archive insight", "failedToCreateTask": "Failed to create task", "failedToDismiss": "Failed to dismiss insight", @@ -2516,6 +2564,7 @@ "failedToUnarchive": "Failed to unarchive insight", "generateDescription": "Generate insights to get AI-powered recommendations for your project.", "generateFirst": "Generate First Insights", + "generateInsights": "Generate new insights", "generateInsightsBtn": "Generate Insights", "generating": "Generating...", "generatingInsights": "Generating insights...", @@ -2531,16 +2580,17 @@ "runCompleted": "{{created}} created, {{updated}} updated", "showAllInsights": "Show all insights", "showArchived": "Show archived insights", - "showArchivedLabel": "Show Archived ({{count}})", + "showArchivedLabel_one": "Show Archived ({{count}})", + "showArchivedLabel_other": "Show Archived ({{count}})", "showBacklogHealth": "Show only backlog health insights", "taskCreated": "Task created from \"{{title}}\"", "taskCreatedMsg": "Task created: {{title}}", "taskCreationUnavailable": "Task creation is unavailable in this view", "title": "Insights", - "unarchiveLabel": "Unarchive this insight", - "unarchiveTitle": "Unarchive this insight", "unarchived": "Unarchived \"{{title}}\"", "unarchivedMsg": "Insight unarchived: {{title}}", + "unarchiveLabel": "Unarchive this insight", + "unarchiveTitle": "Unarchive this insight", "unarchiving": "Unarchiving \"{{title}}\"...", "usePlanningDefault": "Use planning default" }, @@ -2570,8 +2620,8 @@ "preparingQuestion": "Preparing next question...", "progressText": "Question {{progress}} of ~6", "reconnecting": "Reconnecting…", - "refineScope": "Refine {{label}} scope with AI", "refinedScope": "Refined Scope", + "refineScope": "Refine {{label}} scope with AI", "sendToBackground": "Send to background", "sessionActiveAnotherTab": "Session is active in another tab.", "showThinking": "Show thinking", @@ -2586,6 +2636,10 @@ "verificationCriteria": "Verification Criteria", "yes": "Yes" }, + "lane": { + "collapse": "Collapse {{name}} lane", + "expand": "Expand {{name}} lane" + }, "listView": { "apply": "Apply", "applying": "Applying...", @@ -2593,16 +2647,18 @@ "archiveSelectedTitle": "Archive selected tasks that are in Done", "archiveUnavailable": "Archive action is unavailable", "archiveViaButton": "Tasks can only be archived via the archive button", - "bulkArchiveDone": "Archive {{count}} Done", - "bulkArchiveMessage": "Archive {{count}} selected task(s)?", + "bulkArchiveDone_one": "Archive {{count}} Done", + "bulkArchiveDone_other": "Archive {{count}} Done", + "bulkArchiveMessage_one": "Archive {{count}} selected task(s)?", + "bulkArchiveMessage_other": "Archive {{count}} selected task(s)?", "bulkArchiveNoTasks": "No selected tasks can be archived (only done tasks)", "bulkArchiveSummary": "Archived {{archived}} · {{skipped}} skipped · {{failed}} failed", "bulkArchiveTitle": "Archive Selected Tasks", "bulkDeleteAll": "Delete All", "bulkDeleteArchiveSummary": "Archived {{archived}}, deleted {{deleted}}, failed {{failed}}", - "bulkDeleteMessage": "Delete {{count}} selected task(s)?", + "bulkDeleteMessage_one": "Delete {{count}} selected task(s)?", + "bulkDeleteMessage_other": "Delete {{count}} selected task(s)?", "bulkDeleteNoTasks": "No selected tasks can be deleted (archived tasks are excluded)", - "bulkDeleteSummary": "Deleted {{deleted}} task(s) · {{skipped}} archived skipped · {{failed}} failed", "bulkDeleteSummary_one": "Deleted {{count}} task · {{skipped}} archived skipped · {{failed}} failed", "bulkDeleteSummary_other": "Deleted {{count}} tasks · {{skipped}} archived skipped · {{failed}} failed", "bulkDeleteTitle": "Delete Selected Tasks", @@ -2616,7 +2672,8 @@ "bulkUnpauseSummary": "Unpaused {{unpaused}} · {{skipped}} skipped · {{failed}} failed", "bulkUpdateFailed": "Failed to update models", "bulkUpdateNoTasks": "No valid tasks to update (archived tasks cannot be modified)", - "bulkUpdateSuccess": "Updated {{count}} task(s)", + "bulkUpdateSuccess_one": "Updated {{count}} task(s)", + "bulkUpdateSuccess_other": "Updated {{count}} task(s)", "cancelMove": "Cancel Move", "clear": "Clear", "clearColumnFilter": "Clear column filter", @@ -2636,7 +2693,8 @@ "filterChip": "Filter: {{column}}", "forceDelete": "Force Delete", "forceDeleteTitle": "Force Delete Task", - "hidden": "{{count}} hidden", + "hidden_one": "{{count}} hidden", + "hidden_other": "{{count}} hidden", "hideDone": "Hide Done", "hideDoneTitle": "Hide done tasks", "keepProgress": "Keep Progress", @@ -2646,18 +2704,18 @@ "listControlsLabel": "List controls", "newTask": "+ New Task", "noChange": "No change", - "noTasks": "No tasks", - "noTasksMatch": "No tasks match your filter", - "noTasksYet": "No tasks yet", "nodeOverrideLabel": "Node Override", "nodeStatusConnecting": "Connecting", "nodeStatusError": "Error", "nodeStatusOffline": "Offline", "nodeStatusOnline": "Online", + "noTasks": "No tasks", + "noTasksMatch": "No tasks match your filter", + "noTasksYet": "No tasks yet", + "pausedByAgent": "paused by agent", "pauseSelected": "Pause selected", "pauseSelectedTitle": "Pause all selected tasks that are not already paused", "pauseUnavailable": "Pause action is unavailable", - "pausedByAgent": "paused by agent", "preserveProgressMessage": "This task has completed steps. Keep progress before moving?", "preserveProgressTitle": "Preserve Progress?", "resetProgress": "Reset Progress", @@ -2666,9 +2724,10 @@ "resizeSidebar": "Resize task list sidebar", "reviewerModel": "Reviewer Model", "selectAll": "Select all visible tasks", + "selectedCount_one": "{{count}} selected", + "selectedCount_other": "{{count}} selected", "selectTask": "Select {{taskId}}", "selectTaskPrompt": "Select a task to view details", - "selectedCount": "{{count}} selected", "showAll": "Show all", "showAllTitle": "Show all tasks", "showDone": "Show Done", @@ -2677,8 +2736,10 @@ "staleOnlyTitle": "Show stale tasks only", "stalePausedReview": "Stale paused review", "stalePausedReviewTitle": "Show stale paused review tasks only", - "stats": "{{count}} of {{total}} tasks", - "statsInColumn": "{{count}} of {{total}} tasks in {{column}}", + "stats_one": "{{count}} of {{total}} tasks", + "stats_other": "{{count}} of {{total}} tasks", + "statsInColumn_one": "{{count}} of {{total}} tasks in {{column}}", + "statsInColumn_other": "{{count}} of {{total}} tasks in {{column}}", "statusMergingFix": "Merging fixes…", "stuck": "Stuck", "taskCreationUnavailable": "Task creation not available", @@ -2690,8 +2751,6 @@ }, "mailbox": { "agent": "Agent", - "agentById": "Agent: {{id}}", - "agentByName": "Agent: {{name}}", "agents": "Agents", "agentsTab": "Agents", "ago": "ago", @@ -2702,8 +2761,8 @@ "approvalDeny": "Deny", "approvalRequested": "Requested", "approvalRequester": "Requester", - "approvalTask": "Task", "approvals": "Approvals", + "approvalTask": "Task", "back": "Back", "backButton": "← Back", "closeAriaLabel": "Close", @@ -2732,8 +2791,9 @@ "markAllRead": "Mark all read", "markAllReadButton": "Mark all read", "markAllReadTitle": "Mark all as read", + "markedAsRead_one": "Marked {{count}} messages as read", + "markedAsRead_other": "Marked {{count}} messages as read", "markReadFailed": "Failed to mark messages as read", - "markedAsRead": "Marked {{count}} messages as read", "messageDeleted": "Message deleted", "messageSent": "Message sent", "noAgentMessages": "No agent-to-agent messages", @@ -2753,15 +2813,18 @@ "refreshTitle": "Refresh", "reply": "Reply", "replyButton": "Reply", - "replyLoadFailed": "Failed to load replied message. Click to retry.", "replyingTo": "Replying to", "replyingToMessage": "Replying to message", + "replyLoadFailed": "Failed to load replied message. Click to retry.", "selectMessageToRead": "Select a message to read", "system": "System", - "timeDaysAgo": "{{count}}d ago", - "timeHoursAgo": "{{count}}h ago", + "timeDaysAgo_one": "{{count}}d ago", + "timeDaysAgo_other": "{{count}}d ago", + "timeHoursAgo_one": "{{count}}h ago", + "timeHoursAgo_other": "{{count}}h ago", "timeJustNow": "Just now", - "timeMinsAgo": "{{count}}m ago", + "timeMinsAgo_one": "{{count}}m ago", + "timeMinsAgo_other": "{{count}}m ago", "title": "Mailbox", "to": "To", "toLabel": "To:", @@ -2771,7 +2834,6 @@ "typeSystem": "System", "typeUserToAgent": "You → Agent", "user": "User", - "userLabel": "User: {{id}}", "you": "You" }, "memory": { @@ -2789,32 +2851,33 @@ "capReadable": "Readable", "capWritable": "Writable", "categories": "Categories", - "charCount": "{{count}} characters", + "charCount_one": "{{count}} characters", + "charCount_other": "{{count}} characters", "compactFailed": "Failed to compact memory", - "compactSelectedFile": "Compact Selected File", "compacting": "Compacting…", "compactionThresholdHint": "Memory will be compacted when it exceeds this character count", "compactionThresholdLabel": "Compaction Threshold (chars)", + "compactSelectedFile": "Compact Selected File", "currentBackendTitle": "Current Backend", "description": "Working memory, long-term insights, and engine status", "disabledMessage": "Memory is currently disabled. Enable memory tools in Settings to edit these automations.", + "dreaming": "Dreaming…", "dreamNow": "Dream Now", "dreamNowHint": "Manually trigger dream processing now.", "dreamProcessingComplete": "Dream processing completed", "dreamProcessingFailed": "Failed to run dream processing", - "dreaming": "Dreaming…", "dreamsEnabledHint": "Turns daily notes into DREAMS.md and promotes reusable lessons into MEMORY.md.", "dreamsEnabledLabel": "Process dreams from daily memory", "dreamsScheduleHint": "Cron expression for dream processing.", "dreamsScheduleLabel": "Dream Schedule", - "editRaw": "Edit Raw", "editorDefaultDescription": "Edits the selected memory file.", "editorLabel": "Memory Editor", - "extractInsightsFailed": "Failed to extract insights", - "extractNow": "Extract Now", + "editRaw": "Edit Raw", "extracting": "Extracting…", + "extractInsightsFailed": "Failed to extract insights", "extractionFailed": "Failed", "extractionSuccess": "Success", + "extractNow": "Extract Now", "fileCompacted": "Memory file compacted", "fileLabel": "Memory File", "fileSummary": "{{size}} bytes · updated {{updatedAt}}", @@ -2824,13 +2887,15 @@ "healthIssues": "Issues Found", "healthStatusTitle": "Health Status", "healthWarning": "Warning", - "insightCount": "{{count}} insights", - "insightsExtracted": "{{count}} insights extracted", + "insightCount_one": "{{count}} insights", + "insightCount_other": "{{count}} insights", + "insightsExtracted_one": "{{count}} insights extracted", + "insightsExtracted_other": "{{count}} insights extracted", "insightsMemoryLabel": "Insights Memory", "insightsSaved": "Insights saved", + "installing": "Installing…", "installQmd": "Install qmd", "installQmdFailed": "Failed to install qmd", - "installing": "Installing…", "lastExtractionLabel": "Last Extraction", "lastUpdated": "Last Updated", "layerDaily": "Daily", @@ -2854,9 +2919,9 @@ "qmdAvailableOnPath": "qmd is available on PATH.", "qmdChecking": "Checking", "qmdCheckingAvailability": "Checking qmd availability…", + "qmdInstalled": "Installed", "qmdInstallSuccess": "qmd installed successfully", "qmdInstallUnavailable": "qmd install finished, but qmd is still unavailable", - "qmdInstalled": "Installed", "qmdIntegrationTitle": "QMD Integration", "qmdNotInstalled": "qmd is not installed. Search will use local files. Install indexed retrieval:", "qmdPathUsed": "qmd path used", @@ -2875,7 +2940,8 @@ "saveSettingsFailed": "Failed to save memory settings", "saving": "Saving…", "searchPlaceholder": "Search memory with qmd", - "sectionCount": "{{count}} sections", + "sectionCount_one": "{{count}} sections", + "sectionCount_other": "{{count}} sections", "settingsNote": "Note: Change backend type in", "settingsNoteLink": "Settings → Memory", "settingsNoteToast": "Open Settings → Memory to change backend type", @@ -2884,12 +2950,13 @@ "tabEngines": "Engines", "tabInsights": "Insights", "tabWorking": "Working Memory", + "testing": "Testing…", "testMemorySearchTitle": "Test Memory Search", - "testResultCount": "{{count}} result for \"{{query}}\"", + "testResultCount_one": "{{count}} result for \"{{query}}\"", + "testResultCount_other": "{{count}} results for \"{{query}}\"", "testResultStatus": "qmd {{qmdStatus}} · {{fallbackStatus}}", "testRetrieval": "Test Retrieval", "testSearchHint": "Runs the same qmd-backed memory_search path agents use.", - "testing": "Testing…", "title": "Memory", "totalInsights": "Total Insights", "workingMemoryLabel": "Working Memory" @@ -2913,16 +2980,16 @@ "pr": "PR", "pulling": "Pulling…", "pushForceWithLease": "Push (force-with-lease)", - "pushHeading": "Push {{branch}} to origin — ahead by {{count}} commit{{plural}}.", + "pushHeading_one": "Push {{branch}} to origin — ahead by {{count}} commit{{plural}}.", + "pushHeading_other": "Push {{branch}} to origin — ahead by {{count}} commit{{plural}}.", + "pushing": "Pushing…", "pushSuccess": "Pushed to origin/{{branch}} @ {{sha}}.", "pushToOrigin": "Push to origin", - "pushing": "Pushing…", "recordedNoConfirm": "Recorded without local merge confirmation", "shortstatTitle": "Final commit shortstat; for the full landed diff across all task commits, see the Changes tab.", "smartPull": "Smart Pull", "status": "Status", - "title": "Merge Details", - "unknown": "Unknown" + "title": "Merge Details" }, "mesh": { "ariaLabel": "Node mesh topology visualization", @@ -2947,23 +3014,24 @@ "addAssertion": "Add assertion", "addContext": "Add any extra context or direction...", "addFeature": "Add feature", + "additionalComments": "Additional comments (optional)", "addMilestone": "Add Milestone", "addSlice": "Add slice", - "additionalComments": "Additional comments (optional)", "aiThinking": "AI is thinking...", "aiValidatedAtRuntime": "AI-validated at runtime", "aiValidatedMissionGate": "AI-validated mission gate", "allFeaturesLinked": "All features already linked", "approvePlan": "Approve Plan", - "assertionCreateFailed": "Failed to create assertion", "assertionCreated": "Assertion created", + "assertionCreateFailed": "Failed to create assertion", "assertionFieldsRequired": "Title and assertion text are required", "assertionTextEditPlaceholder": "Assertion text", "assertionTextPlaceholder": "Assertion text (what should be true when complete)", "assertionTitlePlaceholder": "Assertion title", - "assertionUpdateFailed": "Failed to update assertion", "assertionUpdated": "Assertion updated", - "attemptRetries": "Attempt {{attempt}} · {{count}} {{label}} left", + "assertionUpdateFailed": "Failed to update assertion", + "attemptRetries_one": "Attempt {{attempt}} · {{count}} {{label}} left", + "attemptRetries_other": "Attempt {{attempt}} · {{count}} {{label}} left", "autopilotActivatingSlice": "Activating slice", "autopilotCompleting": "Completing", "autopilotDescription": "When on, Fusion automatically activates the next slice and plans its features as work completes.", @@ -2973,11 +3041,6 @@ "autopilotLabel": "Autopilot", "autopilotLastActivation": "Last activation {{time}}", "autopilotOff": "Off", - "autopilotStateActivating": "Activating slice", - "autopilotStateCompleting": "Completing", - "autopilotStateInactive": "Off", - "autopilotStateUnknown": "Unknown", - "autopilotStateWatching": "Watching", "autopilotUpdateFailed": "Failed to update autopilot", "autopilotWatching": "Autopilot watching", "autopilotWatchingSince": "Watching since {{time}}", @@ -3009,20 +3072,20 @@ "confirmSlicePlaceholder": "How to confirm this slice is done...", "contractAssertions": "Contract assertions (AI-validated)", "createButton": "Create", - "createTask": "Create Task", "created": "Mission created", "createdFromInterview": "Mission created from AI interview", + "createTask": "Create Task", "creatingMission": "Creating Mission...", "defaultInterviewTitle": "Mission interview", "deleteAssertion": "Delete assertion", "deleteButton": "Delete", "deleteConfirm": "Delete this {{type}}? This cannot be undone.", + "deleted": "Mission deleted", "deleteFailed": "Failed to delete mission", "deleteFeature": "Delete feature", "deleteMilestone": "Delete milestone", "deleteMission": "Delete mission", "deleteSlice": "Delete slice", - "deleted": "Mission deleted", "describeGoal": "Describe what you want to build. The AI will interview you to understand scope, constraints, and requirements, then produce a structured plan with milestones, slices, and features.", "descriptionLabel": "Mission Description", "descriptionOptional": "Description (optional)", @@ -3047,23 +3110,24 @@ "failedLoadModels": "Failed to load models", "featureCreated": "Feature created", "featureCriteriaAwaitingSync": "Feature criteria awaiting assertion sync", - "featureDeleteFailed": "Failed to delete feature", "featureDeleted": "Feature deleted", - "featureLinkFailed": "Failed to link feature", - "featureLinkTaskFailed": "Failed to link feature to task", + "featureDeleteFailed": "Failed to delete feature", "featureLinkedToAssertion": "Feature linked to assertion", "featureLinkedToTask": "Feature linked to task", + "featureLinkFailed": "Failed to link feature", + "featureLinkTaskFailed": "Failed to link feature to task", "featureSaveFailed": "Failed to save feature", + "featuresCount_one": "{{count}} features", + "featuresCount_other": "{{count}} features", "featureTitlePlaceholder": "Feature title", "featureTitleRequired": "Feature title is required", - "featureTriageFailed": "Failed to triage feature", "featureTriaged": "Feature triaged — task created", - "featureUnlinkFailed": "Failed to unlink feature", - "featureUnlinkFromAssertionFailed": "Failed to unlink feature", + "featureTriageFailed": "Failed to triage feature", "featureUnlinkedFromAssertion": "Feature unlinked from assertion", "featureUnlinkedFromTask": "Feature unlinked from task", + "featureUnlinkFailed": "Failed to unlink feature", + "featureUnlinkFromAssertionFailed": "Failed to unlink feature", "featureUpdated": "Feature updated", - "featuresCount": "{{count}} features", "filterAll": "All events", "filterAutopilot": "Autopilot events", "filterErrors": "Errors & warnings", @@ -3074,9 +3138,6 @@ "generatedFixFeatures": "Generated fix features:", "generatedFixFeaturesTitle": "Generated Fix Features", "generatedFromFeature": "Generated from feature: {{id}}", - "helperTextActive": "Stopping pauses linked tasks and marks the mission blocked.", - "helperTextBlocked": "Resuming re-activates the mission and continues execution.", - "helperTextPlanning": "Starting activates the first slice so work can begin.", "hideDetails": "Hide details", "hideMetadata": "Hide metadata", "hideThinking": "Hide thinking", @@ -3090,42 +3151,39 @@ "interviewErrored": "Interview hit an error. Retry from this list item.", "interviewGenerating": "Generating mission hierarchy from interview context.", "interviewInProgress": "Interview in progress", - "interviewStatusAwaitingInput": "Awaiting input", - "interviewStatusComplete": "Plan ready", - "interviewStatusError": "Needs retry", - "interviewStatusGenerating": "Generating plan", - "interviewStatusNeedsRetry": "Needs retry", - "interviewStatusPlanReady": "Plan ready", "interviewWaiting": "Interview is waiting for your next response.", "lastValidatorStatus": "Last {{status}}", "linkAFeature": "Link a feature", "linkButton": "Link", - "linkFeatureButton": "Link Feature", - "linkFeatureToTask": "Link feature to task:", - "linkToTask": "Link to task", - "linkedCount": "{{count}} linked", - "linkedFeaturesCount": "{{count}} linked features", + "linkedCount_one": "{{count}} linked", + "linkedCount_other": "{{count}} linked", + "linkedFeaturesCount_one": "{{count}} linked feature", + "linkedFeaturesCount_other": "{{count}} linked features", "linkedFeaturesLabel": "Linked Features", "linkedGoals": "Linked goals", "linkedGoalsTitle": "Linked Goals", + "linkFeatureButton": "Link Feature", + "linkFeatureToTask": "Link feature to task:", + "linkToTask": "Link to task", "loadActivityFailed": "Failed to load mission activity", "loadDetailFailed": "Failed to load mission details", "loadFailed": "Failed to load missions", - "loadMore": "Load more", "loadingActivity": "Loading mission activity...", "loadingMissionDetails": "Loading mission details...", "loadingMissions": "Loading missions...", "loadingModels": "Loading models…", + "loadMore": "Load more", "loopState": "Loop state: {{state}}", "milestoneCreated": "Milestone created", - "milestoneDeleteFailed": "Failed to delete milestone", "milestoneDeleted": "Milestone deleted", + "milestoneDeleteFailed": "Failed to delete milestone", "milestoneDescriptionPlaceholder": "Milestone description...", "milestoneSaveFailed": "Failed to save milestone", + "milestonesCount_one": "{{count}} milestones", + "milestonesCount_other": "{{count}} milestones", "milestoneTitlePlaceholder": "Milestone title", "milestoneTitleRequired": "Milestone title is required", "milestoneUpdated": "Milestone updated", - "milestonesCount": "{{count}} milestones", "missionHealthAriaLabel": "Mission health: {{state}}", "missionInterviewInProgressDesc": "Mission interview is still in progress. Open this mission to continue planning.", "missionList": "Mission list", @@ -3143,44 +3201,45 @@ "noMilestonesYet": "No milestones yet. Add one to get started.", "noMissionsYetBody": "Missions are large initiatives that bundle milestones, slices, and features into a single plan. Plan a mission to break down a goal end-to-end and let agents work through it autopilot-style.", "noMissionsYetTitle": "No missions yet", + "none": "None", "noSlicesYet": "No slices yet", "noValidationRunsYet": "No validation runs yet.", - "none": "None", "openMissionAriaLabel": "Open mission {{title}}", "orSelect": "Or select:", "planMilestone": "Plan milestone", "planNewMission": "Plan New Mission", + "planningModel": "Planning Model", "planReady": "Mission Plan Ready", "planSlice": "Plan slice", "planStateNeedsUpdate": "Needs update", "planStateNotPlanned": "Not planned", "planStatePlanned": "Planned", "planTitle": "Plan Mission with AI", - "planningModel": "Planning Model", "prepareQuestion": "Preparing next question...", - "progressText": "Question {{count}} of ~6", + "progressText_one": "Question {{count}} of ~6", + "progressText_other": "Question {{count}} of ~6", "reconnecting": "Reconnecting…", - "relativeTimeDays": "{{count}}d ago", - "relativeTimeHours": "{{count}}h ago", + "relativeTimeDays_one": "{{count}}d ago", + "relativeTimeDays_other": "{{count}}d ago", + "relativeTimeHours_one": "{{count}}h ago", + "relativeTimeHours_other": "{{count}}h ago", "relativeTimeJustNow": "just now", - "relativeTimeMinutes": "{{count}}m ago", + "relativeTimeMinutes_one": "{{count}}m ago", + "relativeTimeMinutes_other": "{{count}}m ago", "removeFeature": "Remove feature", "removeMilestone": "Remove milestone", "removeSlice": "Remove slice", "resizeSidebar": "Resize mission sidebar", + "resumed": "Mission resumed", "resumeFailed": "Failed to resume mission", "resumeInterviewAriaLabel": "Resume interview {{title}}", "resumeMission": "Resume mission", - "resumed": "Mission resumed", "retries": "retries", "retry": "retry", "retryBudgetTitle": "Implementation attempts and remaining retry budget", "retrying": "Retrying...", "roadmapLabel": "Roadmap", "run": "Run:", - "runHelperActive": "Stopping pauses linked tasks and marks the mission blocked.", - "runHelperBlocked": "Resuming re-activates the mission and continues execution.", - "runHelperPlanning": "Starting activates the first slice so work can begin.", "runSettings": "Mission run settings", "runSettingsTitle": "Mission run settings", "saveButton": "Save", @@ -3194,25 +3253,27 @@ "showMetadata": "Show metadata", "showThinking": "Show thinking", "showValidationRounds": "Show validation rounds", - "sliceActivateFailed": "Failed to activate slice", "sliceActivated": "Slice activated", + "sliceActivateFailed": "Failed to activate slice", "sliceCreated": "Slice created", - "sliceDeleteFailed": "Failed to delete slice", "sliceDeleted": "Slice deleted", + "sliceDeleteFailed": "Failed to delete slice", "sliceSaveFailed": "Failed to save slice", + "slicesCount_one": "{{count}} slices", + "slicesCount_other": "{{count}} slices", "sliceTitlePlaceholder": "Slice title", "sliceTitleRequired": "Slice title is required", + "sliceTriaged_one": "Triaged {{count}} feature", + "sliceTriaged_other": "Triaged {{count}} features", "sliceTriageFailed": "Failed to triage slice features", - "sliceTriaged": "Triaged {{count}} features", "sliceUpdated": "Slice updated", "sliceVerification": "Slice Verification", - "slicesCount": "{{count}} slices", "source": "Source:", + "started": "Mission started — first slice activated", "startFailed": "Failed to start mission", "startInterview": "Start Interview", "startMission": "Start mission", "startOver": "Start Over", - "started": "Mission started — first slice activated", "statusActive": "Active", "statusArchived": "Archived", "statusBlocked": "Blocked", @@ -3227,9 +3288,11 @@ "statusTriaged": "Triaged", "stopFailed": "Failed to stop mission", "stopMission": "Stop mission", - "stopped": "Mission stopped ({{count}} tasks paused)", + "stopped_one": "Mission stopped ({{count}} task paused)", + "stopped_other": "Mission stopped ({{count}} tasks paused)", "summaryStats": "{{milestones}} milestones, {{features}} features. Review and edit before approving.", - "tabActivity": "Activity ({{count}})", + "tabActivity_one": "Activity ({{count}})", + "tabActivity_other": "Activity ({{count}})", "tabStructure": "Structure", "takeControl": "Take Control", "takingControl": "Taking control...", @@ -3237,7 +3300,8 @@ "targetBranchPlaceholder": "e.g. main", "taskIdPlaceholder": "Task ID (e.g., FN-001)", "taskIdRequired": "Task ID is required", - "tasksFailed": "{{count}} failed", + "tasksFailed_one": "{{count}} failed", + "tasksFailed_other": "{{count}} failed", "title": "Missions", "titleLabel": "Mission Title", "titleRequired": "Mission title is required", @@ -3247,21 +3311,23 @@ "triageCreateTask": "Triage — create task", "tryExample": "Try an example:", "typeAnswer": "Type your answer here...", + "unlinkedBadge": "Unlinked", "unlinkFeature": "Unlink feature", "unlinkTask": "Unlink task", - "unlinkedBadge": "Unlinked", "untitled": "Untitled", "updateButton": "Update", "updated": "Mission updated", "validateFeature": "Validate feature", - "validationRoundsCount": "{{count}} rounds", - "validationRoundsLabel": "Validation rounds ({{count}})", + "validationRoundsCount_one": "{{count}} round", + "validationRoundsCount_other": "{{count}} rounds", + "validationRoundsLabel_one": "Validation rounds ({{count}})", + "validationRoundsLabel_other": "Validation rounds ({{count}})", "validationRuns": "Validation Runs", "validationState": "Validation state", "validationStateNotStarted": "Not started", "validationTelemetry": "Validation Telemetry", - "validationTriggerFailed": "Failed to trigger validation", "validationTriggered": "Validation triggered", + "validationTriggerFailed": "Failed to trigger validation", "verification": "Verification:", "verificationCriteria": "Verification Criteria", "viewMissionFailures": "View mission failures", @@ -3276,26 +3342,13 @@ "noChange": "No change", "selectPlaceholder": "Select a model…" }, - "modelSelection": { - "choose": "Choose models for this task. If not selected, default models will be used.", - "custom": "Custom", - "executorModel": "Executor Model", - "executorPlaceholder": "Select executor model…", - "loading": "Loading models…", - "noModels": "No models available. Configure authentication in Settings to enable model selection.", - "preset": "Preset", - "reviewerModel": "Reviewer Model", - "reviewerPlaceholder": "Select reviewer model…", - "title": "Select Models", - "useDefault": "Use default", - "usingDefault": "Using default" - }, "models": { "addProviderToFavoritesAriaLabel": "Add {{provider}} to favorites", "addToFavorites": "Add to favorites", "addToFavoritesAriaLabel": "Add {{name}} to favorites", "clearFilter": "Clear filter", - "count": "{{count}} model", + "count_one": "{{count}} model", + "count_other": "{{count}} models", "descriptions": { "executor": "The AI model used to implement this task.", "override": "Override the AI models used for this task. When not specified, project or global defaults are used.", @@ -3320,8 +3373,6 @@ "thinkingLevel": "Thinking Level" }, "messages": { - "modelSetTo": "{{label}} model set to {{provider}}/{{modelId}}", - "modelSetToDefault": "{{label}} model set to default", "thinkingLevelSet": "Thinking level set to {{level}}", "thinkingLevelSetDefault": "Thinking level set to default ({{level}})", "upToDate": "Model settings are up to date.", @@ -3348,16 +3399,25 @@ "loading": "Loading available models…", "usingDefault": "Using default" }, - "targetLabels": { - "executor": "Executor", - "planning": "Planning", - "validator": "Reviewer" - }, "titles": { "configuration": "Model Configuration" }, "useDefault": "Use default" }, + "modelSelection": { + "choose": "Choose models for this task. If not selected, default models will be used.", + "custom": "Custom", + "executorModel": "Executor Model", + "executorPlaceholder": "Select executor model…", + "loading": "Loading models…", + "noModels": "No models available. Configure authentication in Settings to enable model selection.", + "preset": "Preset", + "reviewerModel": "Reviewer Model", + "reviewerPlaceholder": "Select reviewer model…", + "title": "Select Models", + "useDefault": "Use default", + "usingDefault": "Using default" + }, "nav": { "activityLog": "Activity Log", "agents": "Agents", @@ -3380,8 +3440,8 @@ "missions": "Missions", "more": "More", "moreSheetTitle": "Navigate", - "noScriptsAddOne": "No scripts — add one…", "nodes": "Nodes", + "noScriptsAddOne": "No scripts — add one…", "planning": "Planning", "primaryNavAriaLabel": "Primary navigation", "projects": "Projects", @@ -3417,28 +3477,12 @@ "noAvailableTasks": "No available tasks", "searchTasks": "Search tasks…", "selectAgent": "Select agent", - "selectedCount": "{{count}} selected", + "selectedCount_one": "{{count}} selected", + "selectedCount_other": "{{count}} selected", "taskCreated": "Created {{taskId}}", "title": "New Task", "unsavedChanges": "You have unsaved changes. Discard them?" }, - "nodeStatus": { - "connecting": "Connecting", - "error": "Error", - "local": "Local", - "offline": "Offline", - "online": "Online", - "unknown": "Unknown" - }, - "nodeSync": { - "error": { - "authSyncFailed": "Auth sync failed", - "failedToFetchStatus": "Failed to fetch sync status", - "pullFailed": "Pull settings failed", - "pushFailed": "Push settings failed", - "someRequestsFailed": "Some sync status requests failed" - } - }, "nodes": { "actions": { "connect": "Connect", @@ -3481,22 +3525,16 @@ "addDockerNode": "Add Docker Node", "addDockerNodeTitle": "Add a managed Docker node", "addFirstNode": "Add First Node", + "adding": "Adding...", "addMountButton": "Add Mount", "addNode": "Add Node", "addVariableButton": "Add Variable", - "adding": "Adding...", "apiKey": "API Key", "apiKeyMode": "API Key Mode", "apiKeyNotConfigured": "Not configured", "apiKeyPlaceholder": "Enter node API key", "attachProjects": "Attach Existing Projects", "attachProjectsHint": "Select existing projects to run on this node and provide the node-specific absolute path for each one.", - "auth": { - "differ": "Auth credentials differ", - "differProviders": "Auth credentials differ: {{providers}}", - "match": "Auth credentials match", - "notSynced": "Auth not synced" - }, "authSync": { "differ": "credentials differ", "label": "Auth sync: {{status}}", @@ -3517,9 +3555,10 @@ "containerLogs": "Container Logs", "description": "Register an existing Fusion node by providing its connection details and concurrency settings.", "discoverBeforeAdding": "Discover remote projects before adding this node.", - "discoverRemoteProjects": "Discover Remote Projects", - "discoveredCount": "Discovered {{count}} remote project{{plural}}.", + "discoveredCount_one": "Discovered {{count}} remote project{{plural}}", + "discoveredCount_other": "Discovered {{count}} remote project{{plural}}", "discovering": "Discovering...", + "discoverRemoteProjects": "Discover Remote Projects", "discoveryFailed": "Failed to discover remote projects", "dismissError": "Dismiss error", "docker": "Docker", @@ -3553,8 +3592,8 @@ "dockerPidsLimit": "PIDs limit", "dockerPort": "Port", "dockerResourceDefault": "Default", - "dockerResourceSizing": "Resource Sizing", "dockerResources": "Resources", + "dockerResourceSizing": "Resource Sizing", "dockerRetainOnDelete": "Retain on delete", "dockerStatusUnknown": "Unknown", "dockerTlsCaCert": "TLS CA cert path", @@ -3567,11 +3606,11 @@ "editButton": "Edit", "errorFetching": "Failed to fetch nodes", "errorPersistMappings": "Failed to persist project mappings", - "errorUnregisterAfterMappingFailure": "Failed to unregister node after mapping failure", "errors": { "connectFailed": "Failed to connect", "connectToNode": "Failed to connect to node" }, + "errorUnregisterAfterMappingFailure": "Failed to unregister node after mapping failure", "failedCreateDocker": "Failed to create Docker node", "failedRefresh": "Failed to refresh nodes", "failedRemove": "Failed to remove node", @@ -3582,10 +3621,6 @@ "fieldCreated": "Created", "fieldMaxConcurrent": "Max Concurrent", "fieldName": "Name", - "fieldStatus": "Status", - "fieldType": "Type", - "fieldUpdated": "Updated", - "fieldUrl": "URL", "fields": { "authKey": "Auth Key", "host": "Host / IP Address", @@ -3594,6 +3629,10 @@ "port": "Port", "url": "URL" }, + "fieldStatus": "Status", + "fieldType": "Type", + "fieldUpdated": "Updated", + "fieldUrl": "URL", "heading": "Nodes", "healthCheckButton": "Health Check", "healthCheckComplete": "Node health check complete", @@ -3623,6 +3662,7 @@ "namePlaceholder": "Build Machine", "nameRequired": "Name is required", "no": "No", + "nodeLabel": "{{name}} ({{type}}) — {{status}}", "noLogsAvailable": "No logs available", "noMatch": "No exact remote name match. Enter this path manually.", "noProjects": "No projects are currently registered.", @@ -3630,7 +3670,6 @@ "noProjectsDiscovered": "No projects discovered on remote node.", "noProjectsRunning": "No projects are running on this node.", "noRegistered": "No nodes are registered yet.", - "nodeLabel": "{{name}} ({{type}}) — {{status}}", "offline": "Offline", "online": "Online", "pathDiscovered": "Remote-authoritative path discovered: {{path}}", @@ -3642,22 +3681,23 @@ "optional": "Optional" }, "provideManually": "Provide key manually", + "pulling": "Pulling...", "pullSettings": "Pull Settings", "pullSettingsFailed": "Pull settings failed", "pullSettingsSuccess": "Settings pulled successfully", - "pulling": "Pulling...", + "pushing": "Pushing...", "pushSettings": "Push Settings", "pushSettingsFailed": "Push settings failed", "pushSettingsSuccess": "Settings pushed successfully", - "pushing": "Pushing...", "reachableUrl": "Reachable URL / Hostname", "readOnly": "Read-only", "refresh": "Refresh", - "refreshStatus": "Refresh Status", "refreshing": "Refreshing...", - "registerFailed": "Failed to register node", + "refreshStatus": "Refresh Status", "registered": "Node \"{{name}}\" registered", - "registeredCount": "{{count}} registered", + "registeredCount_one": "{{count}} registered", + "registeredCount_other": "{{count}} registered", + "registerFailed": "Failed to register node", "remote": "Remote", "removeButton": "Remove", "removed": "Node removed", @@ -3674,18 +3714,6 @@ "sectionSettingsSync": "Settings Sync", "sectionSyncHistory": "Sync History", "startButton": "Start", - "status": { - "connecting": "Connecting", - "creating": "Creating", - "deleting": "Deleting", - "error": "Error", - "exited": "Exited", - "offline": "Offline", - "online": "Online", - "recreating": "Recreating", - "running": "Running", - "stopped": "Stopped" - }, "statusConnecting": "Connecting", "statusError": "Error", "statusOffline": "Offline", @@ -3699,10 +3727,10 @@ "syncAuthFailed": "Auth sync failed", "syncAuthSuccess": "Auth credentials synced successfully", "syncDifferences": "Differences:", - "syncLastSync": "Last sync:", - "syncNeverSynced": "Never synced", "synced": "Synced", "syncing": "Syncing...", + "syncLastSync": "Last sync:", + "syncNeverSynced": "Never synced", "total": "Total", "type": { "local": "Local", @@ -3722,6 +3750,18 @@ "viewLogsButton": "View Logs", "yes": "Yes" }, + "nodeStatus": { + "local": "Local" + }, + "nodeSync": { + "error": { + "authSyncFailed": "Auth sync failed", + "failedToFetchStatus": "Failed to fetch sync status", + "pullFailed": "Pull settings failed", + "pushFailed": "Push settings failed", + "someRequestsFailed": "Some sync status requests failed" + } + }, "onboarding": { "authToken": "Auth token (optional)", "continue": "Continue", @@ -3736,8 +3776,8 @@ "remoteServer": "Remote Server", "resumeOnboarding": "Resume onboarding", "saving": "Saving…", - "scanQr": "Scan QR", "scanning": "Scanning…", + "scanQr": "Scan QR", "serverUrl": "Server URL", "serverUrlPlaceholder": "https://your-fusion-host", "stepContinue": "step. Continue where you left off to complete your dashboard setup.", @@ -3797,10 +3837,10 @@ "companyHelp": "Select a Paperclip company.", "companyIdRequired": "Company ID is required to mint a Paperclip API key.", "companyLabel": "Company", - "connectToPopulate": "Connect to populate", "connected": "Connected.", "connectedAsAgent": "Connected as {{agentName}}{{companyInfo}}.", "connectionModeAriaLabel": "Paperclip connection mode", + "connectToPopulate": "Connect to populate", "description": "Drive a Paperclip agent (employee) in a Paperclip company. Each prompt dispatches a task-shaped request; governance, budgets, and approvals are enforced by Paperclip. Expect seconds-to-minutes latency per turn.", "docsLink": "Paperclip docs", "githubLink": "GitHub", @@ -3808,16 +3848,6 @@ "goalIdLabel": "Goal ID (optional)", "mintButton": "Mint API key via paperclipai", "mintFailed": "Mint failed: {{reason}}. Run `paperclipai onboard` first if your CLI isn't authenticated.", - "mode": { - "issue-per-prompt": "Issue per prompt", - "rolling-issue": "Rolling issue (default)", - "wakeup-only": "Wakeup only (advanced)" - }, - "modeHelp": { - "issue-per-prompt": "Each prompt creates a new top-level Paperclip issue. Maximally explicit; tends to clutter the board.", - "rolling-issue": "One Paperclip issue per Fusion session; subsequent prompts are added as comments. Closest to a chat experience.", - "wakeup-only": "No issue side-effects; the prompt is delivered via the wakeup payload only. Requires the agent's prompt template to know how to handle a payload-driven wake." - }, "modeLabel": "Conversation mode", "name": "Paperclip", "noAgentsDiscovered": "No agents discovered", @@ -3860,13 +3890,13 @@ "filterSkills": "Skills:", "filterThemes": "Themes:", "installFailed": "Failed to install package: {{error}}", - "installSuccess": "Package installed successfully", "installing": "Installing…", + "installSuccess": "Package installed successfully", "loadExtensionsFailed": "Failed to load extensions: {{error}}", - "loadSettingsFailed": "Failed to load Pi settings: {{error}}", "loading": "Loading Pi settings…", "loadingExtensions": "Loading extensions…", "loadingFailed": "Failed to load Pi settings.", + "loadSettingsFailed": "Failed to load Pi settings: {{error}}", "noExtensions": "No extensions discovered.", "noPackages": "No packages configured.", "noPackagesHelp": "Add a package source above to get started.", @@ -3876,8 +3906,8 @@ "refreshExtensions": "Refresh extensions", "reinstallButton": "Reinstall Fusion skill", "reinstallFailed": "Failed to reinstall Fusion skill: {{error}}", - "reinstallSuccess": "Fusion skill reinstalled successfully", "reinstalling": "Reinstalling Fusion…", + "reinstallSuccess": "Fusion skill reinstalled successfully", "removeFailed": "Failed to remove package: {{error}}", "removePackage": "Remove package", "removePackageLabel": "Remove package {{label}}", @@ -3893,9 +3923,9 @@ "updateSettingsFailed": "Failed to update settings: {{error}}" }, "planning": { - "addSubtask": "Add subtask", "additionalComments": "Additional comments (optional)", "additionalCommentsPlaceholder": "Add any extra context or direction...", + "addSubtask": "Add subtask", "advancedSettings": "Advanced planning settings", "aiThinking": "AI is thinking...", "archiveSession": "Archive session", @@ -3910,10 +3940,10 @@ "branchNameRequired": "Branch name is required for this branch strategy.", "branchProjectDefault": "Use project/default branch", "branchStrategy": "Branch strategy", - "breakIntoTasks": "Break into Tasks", - "breakIntoTasksTitle": "Break the plan into multiple tasks with dependencies", "breakdownSubheading": "Review and edit the subtasks generated from your plan. Adjust titles, descriptions, sizes, priorities, and dependencies before creating.", "breakingDown": "Breaking down...", + "breakIntoTasks": "Break into Tasks", + "breakIntoTasksTitle": "Break the plan into multiple tasks with dependencies", "collapse": "Collapse", "continue": "Continue", "createSingleTask": "Create Single Task", @@ -3977,11 +4007,15 @@ "questionsLabel": "Questions", "reconnecting": "Reconnecting…", "refineFurther": "Refine Further", - "relativeTimeDays": "{{count}}d ago", - "relativeTimeHours": "{{count}}h ago", + "relativeTimeDays_one": "{{count}}d ago", + "relativeTimeDays_other": "{{count}}d ago", + "relativeTimeHours_one": "{{count}}h ago", + "relativeTimeHours_other": "{{count}}h ago", "relativeTimeJustNow": "just now", - "relativeTimeMinutes": "{{count}}m ago", - "relativeTimeWeeks": "{{count}}w ago", + "relativeTimeMinutes_one": "{{count}}m ago", + "relativeTimeMinutes_other": "{{count}}m ago", + "relativeTimeWeeks_one": "{{count}}w ago", + "relativeTimeWeeks_other": "{{count}}w ago", "remove": "Remove", "retryFailed": "Retry failed. Please try again.", "retrying": "Retrying...", @@ -4024,30 +4058,13 @@ }, "plugins": { "addItem": "Add Item", - "agentBrowser": { - "groupBrowser": "Browser", - "groupGeneral": "General", - "groupPromptContributions": "Prompt Contributions", - "groupSkills": "Skills", - "labelAllowedDomains": "Allowed Domains", - "labelCommandTimeoutMs": "Command Timeout (ms)", - "labelEnabled": "Enable Agent Browser", - "labelHeadlessMode": "Headless Mode", - "labelInstallChannel": "Install Channel", - "labelPromptExecutorSystem": "Executor System Prompt", - "labelPromptExecutorTask": "Executor Task Prompt", - "labelPromptHeartbeat": "Heartbeat Prompt", - "labelPromptReviewer": "Reviewer Prompt", - "labelPromptTriage": "Triage Prompt", - "labelSkillExposure": "Skill Exposure" - }, "aiScanDisabled": "AI scan on load disabled", "aiScanEnabled": "AI scan on load enabled", "aiScanHint": "Turning this on only updates configuration. Use Rescan and Reload to run it now.", "author": "Author:", "backToList": "Back to plugin list", - "builtinInstallFailed": "Failed to install {{name}}: {{error}}", "builtinInstalledGlobally": "{{name}} installed globally", + "builtinInstallFailed": "Failed to install {{name}}: {{error}}", "builtinMetadataOnly": "Built-in metadata only", "builtinNoPackage": "{{name}} is built in and does not have an installable package yet", "builtinPluginRecommendations": "Built-in plugin recommendations", @@ -4057,34 +4074,35 @@ "checkingSetup": "Checking setup...", "componentUnavailable": "Plugin component unavailable", "couldNotResolve": "The dashboard could not resolve this plugin surface from the static host registry.", + "disabledForProject": "{{name}} disabled for this project", "disableInProject": "Disable in Project", "disablePlugin": "Disable {{name}}", "disablePluginFailed": "Failed to disable plugin: {{error}}", - "disabledForProject": "{{name}} disabled for this project", "droidOnboardingTip": "Tip: Enable Droid CLI to reuse your Factory AI subscription without adding an API key.", "droidRecommendDesc": "Use your local Droid CLI session as an AI provider in Fusion.", "droidRecommendTitle": "Enable Droid CLI", "enableAiScanBeforeLoad": "Enable AI scan before load/reload", "enableAiSecurityScan": "Enable AI security scan on load", + "enabledForProject": "{{name}} enabled for this project", "enableFailed": "Failed to enable {{name}}: {{error}}", "enableInProject": "Enable in Project", "enablePlugin": "Enable {{name}}", "enablePluginFailed": "Failed to enable plugin: {{error}}", - "enabledForProject": "{{name}} enabled for this project", "experimental": "Experimental", - "findings": "Findings ({{count}})", + "findings_one": "Findings ({{count}})", + "findings_other": "Findings ({{count}})", "homepage": "Homepage:", "install": "Install", + "installedGlobally": "Plugin installed globally", + "installedPlugins": "Installed Plugins", "installFailed": "Failed to install plugin: {{error}}", "installHint": "Browse to a plugin package root (contains manifest.json) or a built dist directory.", + "installing": "Installing...", "installNamed": "Install {{name}}", "installPathPlaceholder": "Absolute path to plugin directory or dist folder", "installPathRequired": "Please enter a plugin path", "installPluginGlobally": "Install Plugin Globally", "installSetup": "Install Setup", - "installedGlobally": "Plugin installed globally", - "installedPlugins": "Installed Plugins", - "installing": "Installing...", "loadFailed": "Failed to load plugins: {{error}}", "loading": "Loading...", "loadingPlugins": "Loading plugins...", @@ -4098,8 +4116,8 @@ "refresh": "Refresh", "refreshPluginList": "Refresh plugin list", "reload": "Reload", - "reloadFailed": "Failed to reload plugin: {{error}}", "reloaded": "{{name}} reloaded", + "reloadFailed": "Failed to reload plugin: {{error}}", "reloading": "Reloading...", "removeItem": "Remove item", "rescanAndReload": "Rescan and Reload", @@ -4109,11 +4127,11 @@ "saveSettingsFailed": "Failed to save settings: {{error}}", "securityScan": "Security Scan", "selectOption": "Select...", - "settingUp": "Setting up...", "settings": "Settings", "settingsSaved": "Settings saved", - "setupInstallFailed": "Failed to install {{name}} setup: {{error}}", + "settingUp": "Setting up...", "setupInstalled": "{{name}} setup installed", + "setupInstallFailed": "Failed to install {{name}} setup: {{error}}", "setupReady": "Setup ready", "setupRequired": "Setup required", "startPluginToCheckSetup": "Start plugin to check setup", @@ -4121,11 +4139,11 @@ "statusInstalled": "Installed", "statusNotInstalled": "Not installed", "uninstallConfirm": "Are you sure you want to uninstall \"{{name}}\" globally (all projects)?", + "uninstalledGlobally": "{{name}} uninstalled globally", "uninstallFailed": "Failed to uninstall plugin: {{error}}", "uninstallGlobally": "Uninstall Globally", "uninstallGloballyTitle": "Uninstall globally", "uninstallTitle": "Uninstall Plugin Globally", - "uninstalledGlobally": "{{name}} uninstalled globally", "unknownError": "unknown error", "updateFailed": "Failed to update plugin: {{error}}", "version": "Version:" @@ -4151,7 +4169,6 @@ "createPr": "Create PR", "createTitle": "Create Pull Request", "dismissError": "Dismiss PR error", - "loadingMetadata": "Loading PR metadata…", "noConflicts": "No merge conflicts detected.", "preflightChecks": "Pre-flight checks", "previewTitle": "Diff & commit preview", @@ -4176,15 +4193,19 @@ "confirm": "Confirm", "confirmRemove": "Confirm remove", "confirmRemoveProject": "Confirm remove project", - "daysAgo": "{{count}}d ago", - "hoursAgo": "{{count}}h ago", + "daysAgo_one": "{{count}}d ago", + "daysAgo_other": "{{count}}d ago", + "hoursAgo_one": "{{count}}h ago", + "hoursAgo_other": "{{count}}h ago", "justNow": "Just now", "lastActivity": "Last activity:", - "minutesAgo": "{{count}}m ago", - "moreItems": "+{{count}} more", + "minutesAgo_one": "{{count}}m ago", + "minutesAgo_other": "{{count}}m ago", + "moreItems_one": "+{{count}} more", + "moreItems_other": "+{{count}} more", "never": "Never", - "noHealthData": "No health data available", "nodeAvailability": "Project node availability", + "noHealthData": "No health data available", "open": "Open", "openProject": "Open project", "pause": "Pause", @@ -4199,20 +4220,13 @@ "emptyHint": "Try a different base path or add a project manually", "noDbWarning": "No fn database found - will be initialized", "registerAll": "Register All", - "registerSelected": "Register Selected ({{count}})", "registering": "Registering...", - "selectAll": "Select All ({{count}})", - "selectedCount": "{{count}} selected" - }, - "projectSelector": { - "allProjects": "All Projects", - "ariaLabel": "Select project", - "clearSearch": "Clear search", - "noResults": "No projects match your search", - "recent": "Recent", - "searchPlaceholder": "Search projects...", - "selectProject": "Select Project", - "viewAll": "View All Projects" + "registerSelected_one": "Register Selected ({{count}})", + "registerSelected_other": "Register Selected ({{count}})", + "selectAll_one": "Select All ({{count}})", + "selectAll_other": "Select All ({{count}})", + "selectedCount_one": "{{count}} selected", + "selectedCount_other": "{{count}} selected" }, "projects": { "actions": { @@ -4237,9 +4251,9 @@ "filterByNode": "Filter by node", "filterErrored": "Errored", "filterPaused": "Paused", + "nodesLabel": "Nodes", "noMatch": "No projects match the current filter", "noProjectsFound": "No Projects Found", - "nodesLabel": "Nodes", "setup": { "success": "Project {{name}} registered successfully" }, @@ -4254,13 +4268,24 @@ "title": "Projects", "totalLabel": "Total" }, + "projectSelector": { + "allProjects": "All Projects", + "ariaLabel": "Select project", + "clearSearch": "Clear search", + "noResults": "No projects match your search", + "recent": "Recent", + "searchPlaceholder": "Search projects...", + "selectProject": "Select Project", + "viewAll": "View All Projects" + }, "providers": { "actions": { "addModel": "+ Add model", + "detecting": "Detecting…", "detectModels": "Detect Models", "detectModelsTitle": "Call the provider's /models endpoint to discover available models", - "detecting": "Detecting…", - "removeModel": "Remove model", + "removeModel_one": "Remove model", + "removeModel_other": "Remove model", "save": "Save Provider", "saving": "Saving..." }, @@ -4280,9 +4305,9 @@ "noModels": "No models found. The provider may require an API key.", "urlRequired": "Base URL is required to detect models." }, + "detecting": "Detecting…", "detectModels": "Detect Models", "detectTitle": "Auto-detect models from the provider's /models endpoint", - "detecting": "Detecting…", "editLabel": "Edit {{name}}", "failedDelete": "Failed to delete provider.", "failedDetect": "Failed to detect models", @@ -4297,6 +4322,7 @@ "maxTokens": "Max tokens", "modelId": "Model ID", "modelName": "Display name", + "modelNameLabel": "Model name", "models": "Models", "name": "Display Name", "reasoning": "Reasoning" @@ -4360,8 +4386,10 @@ "p95": "P95", "p95Raw": "P95 raw: {{value}} ms", "reason": "Reason: {{reason}}", - "sampleCount": "Sample count: {{count}}", - "samples": "Samples: {{count}}" + "sampleCount_one": "Sample count: {{count}}", + "sampleCount_other": "Sample count: {{count}}", + "samples_one": "Samples: {{count}}", + "samples_other": "Samples: {{count}}" }, "failureRate": "Failure rate: {{rate}}", "heading": "Reliability", @@ -4370,12 +4398,14 @@ "insufficientData": "Insufficient data — {{reason}}", "mergeAttempts": { "heading": "Merge attempts", - "histogramTotal": "Histogram total: {{count}}", + "histogramTotal_one": "Histogram total: {{count}}", + "histogramTotal_other": "Histogram total: {{count}}", "max": "Max", "mean": "Mean", "moreStats": "More stats", "reason": "Reason: {{reason}}", - "tasksCounted": "Tasks counted: {{count}}" + "tasksCounted_one": "Tasks counted: {{count}}", + "tasksCounted_other": "Tasks counted: {{count}}" }, "reason": "Reason: {{reason}}", "resetBaseline": "Reset baseline: {{date}}", @@ -4413,11 +4443,11 @@ "enrichTaskButton": "Enrich Task", "enrichTaskTitle": "Enrich existing task", "enterTaskId": "Enter task ID", + "exportedFile": "Exported {{filename}}", "exportFailed": "Export failed", "exportHtml": "Export HTML", "exportJson": "Export JSON", "exportMd": "Export MD", - "exportedFile": "Exported {{filename}}", "findingLabel": "Finding:", "loadingRuns": "Loading research runs…", "loadingTasks": "Loading tasks…", @@ -4434,11 +4464,6 @@ "priorityLow": "Low", "priorityNormal": "Normal", "priorityUrgent": "Urgent", - "providerGitHub": "GitHub", - "providerLlmSynthesis": "LLM Synthesis", - "providerLocalDocs": "Local Docs", - "providerPageFetch": "Page Fetch", - "providerWebSearch": "Web Search", "providersLabel": "Providers", "queryLabel": "Query", "runCancelled": "Run cancelled", @@ -4461,7 +4486,8 @@ "viewLabel": "Research view" }, "routine": { - "andMore": "…and {{count}} more", + "andMore_one": "…and {{count}} more", + "andMore_other": "…and {{count}} more", "delete": "Delete", "deleteMessage": "Delete routine {{name}}? This cannot be undone.", "deleteName": "Delete {{name}}", @@ -4474,11 +4500,13 @@ "enableName": "Enable {{name}}", "resultFailed": "Failed", "resultSuccess": "Success", - "runHistory": "Run History ({{count}})", + "runHistory_one": "Run History ({{count}})", + "runHistory_other": "Run History ({{count}})", "runNameNow": "Run {{name}} now", - "runNow": "Run now", "running": "Running…", - "stepCount": "{{count}} step" + "runNow": "Run now", + "stepCount_one": "{{count}} step", + "stepCount_other": "{{count}} steps" }, "routing": { "cannotChangeWhileActive": "Node override cannot be changed while the task is active.", @@ -4494,11 +4522,6 @@ "overrideSection": "Node Override", "overrideSetTo": "Override set to", "overrideUpdated": "Node override updated", - "policyLabel": { - "block": "Block execution", - "fallback": "Fall back to local", - "notConfigured": "Not configured" - }, "selectLabel": "Select execution node", "source": { "noRouting": "No routing", @@ -4532,11 +4555,13 @@ "advancedMode": "Multi-Step", "advancedModeHelp": "Run multiple steps sequentially (commands and AI prompts)", "aiPromptType": "AI Prompt", - "andMore": "…and {{count}} more", + "andMore_one": "…and {{count}} more", + "andMore_other": "…and {{count}} more", "apiEndpointHint": "API endpoint path that triggers this routine", "apiEndpointLabel": "API Endpoint", "apiEndpointPlaceholder": "/api/routine/my-routine", - "automationCount": "{{count}} automation{{plural}}", + "automationCount_one": "{{count}} automation{{plural}}", + "automationCount_other": "{{count}} automation{{plural}}", "cancelButton": "Cancel", "catchUpPolicyHint": "What to do when a scheduled run is missed", "catchUpPolicyLabel": "Catch-up Policy", @@ -4591,10 +4616,10 @@ "editTitle": "Edit Schedule", "emptyStateDescription": "Create an automation with a schedule, webhook, API, or manual trigger.", "enable": "Enable", - "enableName": "Enable {{name}}", "enabledHelp": "When disabled, the schedule will not run automatically", "enabledHint": "When disabled, the routine will not run automatically", "enabledLabel": "Enabled", + "enableName": "Enable {{name}}", "errorApiEndpointRequired": "API endpoint is required", "errorCommandRequired": "Command is required", "errorCronInvalid": "Invalid cron format — expected 5 fields (e.g. '0 */6 * * *')", @@ -4607,9 +4632,9 @@ "errorStepCommandRequired": "Step {{n}}: Command is required", "errorStepNameRequired": "Step {{n}}: Name is required", "errorStepPromptRequired": "Step {{n}}: Prompt is required", - "errorStepTaskDescRequired": "Step {{n}}: Task description is required", "errorStepsEditing": "Please save or cancel all step edits before saving the routine", "errorStepsRequired": "At least one step is required", + "errorStepTaskDescRequired": "Step {{n}}: Task description is required", "errorTaskDescriptionRequired": "Task description is required", "errorTimeoutMin": "Timeout must be at least 1 second (1000ms)", "errorWebhookPathRequired": "Webhook path is required", @@ -4626,13 +4651,13 @@ "frequencyLabel": "Frequency", "global": "Global", "globalScope": "Global", - "globalScopeTitle": "Global scope", "globalScoped": "This schedule will be created at global scope.", + "globalScopeTitle": "Global scope", "loadRoutinesError": "Failed to load routines", "manualTriggerInfo": "This routine will be triggered manually via the dashboard or API.", "modeAriaLabel": "Execution mode", - "modeLabel": "Execution Mode", "model": "Model", + "modeLabel": "Execution Mode", "modelConsistency": "Both model provider and model ID must be set, or both must be empty", "modelDropdownLabel": "Model", "modelHelp": "AI model for this prompt. Uses default if not selected.", @@ -4656,9 +4681,9 @@ "project": "Project", "projectRequired": "Project-specific entries require an active project.", "projectScope": "Project", + "projectScoped": "This schedule will be scoped to the current project.", "projectScopeDisabled": "Select a project to enable project scope", "projectScopeTitle": "Project scope", - "projectScoped": "This schedule will be scoped to the current project.", "prompt": "Prompt", "promptHelp": "AI prompt to execute. Provide clear instructions for the task.", "promptHint": "AI prompt to execute.", @@ -4675,10 +4700,11 @@ "routineSuccess": "\"{{name}}\" completed successfully", "routineUpdated": "Routine updated", "runError": "Failed to run routine", - "runHistory": "Run History ({{count}})", + "runHistory_one": "Run History ({{count}})", + "runHistory_other": "Run History ({{count}})", "runNameNow": "Run {{name}} now", - "runNow": "Run now", "running": "Running…", + "runNow": "Run now", "saveChanges": "Save Changes", "saveStep": "Save Step", "saving": "Saving…", @@ -4695,15 +4721,16 @@ "simpleMode": "Simple", "simpleModeHelp": "Run a single shell command or AI prompt", "stepCommandRequired": "Step {{index}}: Command is required", - "stepCount": "{{count}} step", + "stepCount_one": "{{count}} step", + "stepCount_other": "{{count}} steps", "stepName": "Step Name", "stepNamePlaceholder": "e.g. Run tests", "stepNameRequired": "Step {{index}}: Name is required", "stepPromptRequired": "Step {{index}}: Prompt is required", - "stepType": "Step Type", "steps": "Steps", "stepsEditing": "Please save or cancel all step edits before saving the schedule", "stepsRequired": "At least one step is required", + "stepType": "Step Type", "targetColumn": "Target Column", "targetColumnHelp": "Column where the new task will be created", "targetColumnLabel": "Target Column", @@ -4780,7 +4807,8 @@ "saving": "Saving...", "scriptAlreadyExists": "A script with this name already exists", "scriptCommandRequired": "Script command is required", - "scriptCount": "{{count}} script", + "scriptCount_one": "{{count}} script", + "scriptCount_other": "{{count}} scripts", "scriptCreated": "Script created", "scriptDeleted": "Script deleted", "scriptName": "Script Name", @@ -4852,20 +4880,17 @@ "failed": "Failed", "headerAwaitingAndErrorPlural": "{{awaitingCount}} AI sessions need your input, {{errorCount}} failed", "headerAwaitingAndErrorSingular": "{{awaitingCount}} AI session needs your input, {{errorCount}} failed", - "headerAwaitingPlural": "{{count}} AI sessions need your input", - "headerAwaitingSingular": "{{count}} AI session needs your input", - "headerErrorPlural": "{{count}} AI sessions failed", - "headerErrorSingular": "{{count}} AI session failed", + "headerAwaitingPlural_one": "", + "headerAwaitingPlural_other": "", + "headerAwaitingSingular_one": "", + "headerAwaitingSingular_other": "", + "headerErrorPlural_one": "", + "headerErrorPlural_other": "", + "headerErrorSingular_one": "", + "headerErrorSingular_other": "", "regionLabel": "AI sessions needing input or failed", "resume": "Resume", - "retry": "Retry", - "typeLabel": { - "milestoneInterview": "Milestone Interview", - "missionInterview": "Mission Interview", - "planning": "Planning", - "sliceInterview": "Slice Interview", - "subtask": "Subtask Breakdown" - } + "retry": "Retry" }, "settings": { "actions": { @@ -4876,7 +4901,6 @@ "language": "Language", "languageAuto": "Auto", "languageAutoHint": "Follow the browser language", - "languageHint": "Choose the language for the {{brand}} interface.", "title": "Appearance" }, "auth": { @@ -4930,8 +4954,8 @@ "general": { "learnMore": "Learn more", "settingsSaved": "Settings saved", - "upToDate": "You're up to date ✓", - "updateAvailablePrefix": "v{{version}} available" + "updateAvailablePrefix": "v{{version}} available", + "upToDate": "You're up to date ✓" }, "header": { "discord": "Discord" @@ -4941,8 +4965,8 @@ "exportBtn": "Export", "exportTitle": "Export settings to JSON file", "importBtn": "Import", - "importTitle": "Import Settings", "importing": "Importing…", + "importTitle": "Import Settings", "loadingFile": "Loading…", "reviewPrompt": "Review the settings to be imported:" }, @@ -4951,17 +4975,17 @@ "keepRemote": "Keep Remote", "loading": "Loading…", "memory": { - "compactSelectedFile": "Compact Selected File", "compacting": "Compacting…", + "compactSelectedFile": "Compact Selected File", "dreamCompleted": "Dream processing completed", "dreamNow": "Dream Now", - "installQmd": "Install qmd", "installing": "Installing…", + "installQmd": "Install qmd", "memoryCompacted": "Memory file compacted", "memorySaved": "Memory saved", "saveMemory": "Save Memory", - "testRetrieval": "Test Retrieval", - "testing": "Testing…" + "testing": "Testing…", + "testRetrieval": "Test Retrieval" }, "mergeManually": "Merge Manually", "mobileNav": { @@ -4976,45 +5000,14 @@ "savePreset": "Save preset" }, "nav": { - "accountHeader": "Account", - "agentPermissions": "Agent Permissions", - "appearance": "Appearance", "aria": { "global": "Global setting", "project": "Project setting" }, - "authentication": "Authentication", - "backups": "Backups", - "commands": "Commands", - "experimental": "Experimental Features", - "globalGeneral": "General", - "globalHeader": "Global", - "globalModels": "Models", - "hermesRuntime": "Hermes", - "memory": "Memory", - "merge": "Merge", - "nodeRouting": "Node Routing", - "nodeSync": "Node Sync", - "notifications": "Notifications", - "openclawRuntime": "OpenClaw", - "paperclipRuntime": "Paperclip", - "plugins": "Plugins", - "projectGeneral": "Project General", - "projectHeader": "Project", - "projectModels": "Project Models", - "prompts": "Prompts", - "remote": "Remote Access", - "researchGlobal": "Research Defaults", - "researchProject": "Research", - "runtimesHeader": "Runtimes", - "scheduledEvals": "Scheduled Evals", - "scheduling": "Scheduling", - "secrets": "Secrets", "tooltip": { "global": "Shared across all projects", "project": "Specific to this project" - }, - "worktrees": "Worktrees" + } }, "notifications": { "sending": "Sending…", @@ -5028,10 +5021,10 @@ "restarting": "Restarting…", "shortLivedTokenGenerated": "Short-lived token generated", "startFresh": "Start Fresh", - "startTunnel": "Start Tunnel", "starting": "Starting…", - "stopTunnel": "Stop Tunnel", + "startTunnel": "Start Tunnel", "stopping": "Stopping…", + "stopTunnel": "Stop Tunnel", "tunnelRestarted": "Remote tunnel restarted", "tunnelStarted": "Remote tunnel started", "tunnelStopped": "Remote tunnel stopped", @@ -5039,8 +5032,8 @@ }, "resolveAllLocal": "Resolve All: Keep Local", "resolveAllRemote": "Resolve All: Keep Remote", - "resolveFailed": "Failed to resolve conflicts", "resolvedSuccess": "Settings conflicts resolved successfully", + "resolveFailed": "Failed to resolve conflicts", "resolving": "Resolving...", "scheduling": { "selectCurrentDir": "Select current directory", @@ -5068,46 +5061,11 @@ "aiSetupDescription": "Fusion uses AI models to plan, write, and review code for you. Connect an AI provider below to get started — you can use a hosted service or enter an API key.", "allProvidersShown": "All currently available providers are already shown above.", "allSet": "All Set!", - "apiKeyFormatError": "{{providerName}} keys should follow this format: {{hint}} (e.g. {{example}})", "apiKeyFormatHint": "Format: {{hint}}", "apiKeyHint": "Key: {{keyHint}}", - "apiKeyLabel": { - "fallback": "API Key", - "kimiCoding": "Kimi API Key", - "minimax": "MiniMax API Key", - "ollama": "Ollama Endpoint", - "openai": "OpenAI API Key", - "openrouter": "OpenRouter API Key", - "zai": "Zhipu AI API Key" - }, - "apiKeyPlaceholder": { - "fallback": "Enter API key", - "kimiCoding": "Enter your Kimi API key", - "minimax": "Enter your MiniMax API key", - "zai": "Enter your Zhipu AI API key" - }, "apiKeyRemoved": "API key removed", - "apiKeyRequired": "API key is required", "apiKeySaved": "✓ API key saved", "apiKeySavedToast": "API key saved", - "apiKeySetup": { - "fallback": "Enter your API key for this provider.", - "kimiCoding": "Create your API key in the Moonshot platform account settings.", - "minimax": "Generate an API key from the MiniMax platform developer console.", - "ollama": "Enter your Ollama endpoint URL (for example http://localhost:11434).", - "openai": "Create an API key from your OpenAI dashboard under API keys.", - "openrouter": "Create an API key from your OpenRouter account key management page.", - "zai": "Create an API key in the Zhipu AI open platform account settings." - }, - "apiKeyUsage": { - "fallback": "Used by Fusion to authenticate requests to this provider", - "kimiCoding": "Used for Kimi/Moonshot AI models in task execution and planning", - "minimax": "Used for MiniMax models in task execution", - "ollama": "Connects to your local Ollama instance", - "openai": "Used for GPT models in task execution and planning", - "openrouter": "Routes to multiple AI model providers through a single key", - "zai": "Used for GLM models in task execution" - }, "ariaDismissRecommendations": "Dismiss recommendations", "ariaSetupRecommendations": "Setup recommendations", "authCodeAlreadySubmitted": "That authorization code was already submitted. Waiting for login…", @@ -5158,13 +5116,13 @@ "connectAiProvider": "Connect AI Provider", "connectAiProviderDesc": "Connect an AI provider to enable AI agents for task planning and code generation", "connectAnyway": "Connect anyway", + "connectedProviders": "Connected providers", "connectGitHub": "Connect GitHub", "connectGitHubAnytime": "No worries if you're not ready — connect GitHub anytime from Settings → Authentication.", "connectGitHubButton": "Connect GitHub", "connectGitHubDesc": "Connect GitHub to import issues and track pull requests", "connectOauthOptional": "Connect OAuth (optional)", "connectRemoteServer": "Connect remote Fusion server", - "connectedProviders": "Connected providers", "continueToLogin": "Continue to login", "continueWithGhCli": "Continue with gh CLI auth →", "continueWithoutGitHub": "Continue without GitHub →", @@ -5229,10 +5187,10 @@ "githubSkipped": "GitHub was skipped. You can connect anytime from Settings → Authentication.", "goBackToStep": "Go back to {{label}}", "goToDashboard": "Go to Dashboard", - "howDoIChooseModel": "How do I choose a model?", - "howDoIChooseModelBody": "Models vary in speed, capability, and cost. A good default is usually the latest model from your connected provider. You can always change this later in Settings.", "howDoesLoginWork": "How does login work?", "howDoesLoginWorkBody": "Clicking Login opens the provider's website in a new tab where you sign in. Once you authorize Fusion, this page will automatically detect the connection. Your credentials are never stored in Fusion.", + "howDoIChooseModel": "How do I choose a model?", + "howDoIChooseModelBody": "Models vary in speed, capability, and cost. A good default is usually the latest model from your connected provider. You can always change this later in Settings.", "importFromGitHub": "Import from GitHub", "importFromGitHubSubtitle": "Turn GitHub issues into tasks you can track here", "inProcess": "In-Process", @@ -5294,25 +5252,11 @@ "projectRequired": "A project is required before first-task actions are available.", "projectSelected": "Project selected — task creation and imports are available.", "projectSetupDescription": "Choose your first project before creating or importing tasks. You can register an existing local directory or clone a GitHub repository URL through the setup wizard.", - "providerDesc": { - "anthropic": "Claude models — strong at reasoning, analysis, and code", - "fallback": "AI provider — connect to start using AI models", - "gemini": "Gemini models — multimodal with strong reasoning", - "google": "Gemini models — multimodal with strong reasoning", - "kimi": "Kimi by Moonshot AI — long-context capabilities", - "kimiCoding": "Kimi by Moonshot AI — long-context capabilities", - "minimax": "MiniMax models — cost-effective for high-volume usage", - "moonshot": "Kimi by Moonshot AI — long-context capabilities", - "ollama": "Run open-source models locally on your machine", - "openai": "GPT models — versatile for a wide range of tasks", - "openaiCodex": "Codex models by OpenAI — optimized for coding tasks", - "openrouter": "OpenRouter — route requests across multiple AI providers", - "zai": "GLM models by Zhipu AI — strong multilingual support" - }, "providersConnectedSummary": "✓ {{connected}} of {{total}} provider(s) connected", - "providersSkippedSummary": "{{count}} provider(s) skipped", - "providersSkippedSummary_one": "{{count}} provider skipped", - "providersSkippedSummary_other": "{{count}} providers skipped", + "providersSkippedSummary_one_one": "{{count}} provider skipped", + "providersSkippedSummary_one_other": "{{count}} provider skipped", + "providersSkippedSummary_other_one": "{{count}} providers skipped", + "providersSkippedSummary_other_other": "{{count}} providers skipped", "quickStartProviders": "Quick start providers", "readinessAiProviderConnected": "{{name}} connected — AI agents can work on tasks", "readinessAiProviderLabel": "AI Provider", @@ -5331,8 +5275,8 @@ "readinessSummaryHeader": "Setup Summary", "recommended": "Recommended", "recommendedNextSteps": "Recommended Next Steps", - "registerProject": "Register Project", "registering": "Registering...", + "registerProject": "Register Project", "remoteServerNote": "Your native shell needs an active remote profile before dashboard handoff can complete.", "remoteServerProfileSaved": "Remote server profile saved", "removeKey": "Remove Key", @@ -5346,9 +5290,9 @@ "retry": "Retry", "reviewStep": "Review {{label}}", "runtimeNode": "Runtime Node", + "savedProfileButFailedToActivate": "Saved profile but failed to activate it", "saveKey": "Save", "saveRemoteServer": "Save remote server", - "savedProfileButFailedToActivate": "Saved profile but failed to activate it", "saving": "Saving…", "savingKey": "Saving…", "savingRemoteServer": "Saving…", @@ -5361,9 +5305,9 @@ "setToken": "Set token", "setTokenContinue": "Set Token & Continue", "setUpAi": "Set Up AI", - "setUpProject": "Set Up Project", "setupComplete": "Setup complete! Head to the board to create your first task, or explore the dashboard to see what's available.", "setupMode": "Setup Mode", + "setUpProject": "Set Up Project", "setupWizardHint": "In the setup wizard, pick an existing directory or paste a GitHub clone URL.", "skip": "Skip", "skipForNow": "Skip for now", @@ -5449,21 +5393,22 @@ "catalogUnavailable": "Catalog is temporarily unavailable. Please try again later.", "closeDetail": "Close skill detail", "closeView": "Close skills view", - "disableSkill": "Disable {{name}}", "disabled": "Skill disabled", + "disableSkill": "Disable {{name}}", "discovered": "discovered", - "discoveredCount": "{{count}} discovered skills", + "discoveredCount_one": "{{count}} discovered skills", + "discoveredCount_other": "{{count}} discovered skills", "discoveredSection": "Discovered Skills", - "enableSkill": "Enable {{name}}", "enabled": "Skill enabled", + "enableSkill": "Enable {{name}}", "filesLabel": "Files", "install": "Install", "installError": "Failed to install skill", "installFailed": "Failed to install {{name}}: {{message}}", - "installSkill": "Install {{name}}", - "installSuccess": "Installed {{name}}", "installing": "Installing…", "installsCount": "{{count}} installs", + "installSkill": "Install {{name}}", + "installSuccess": "Installed {{name}}", "loadCatalogError": "Failed to load catalog", "loadContentError": "Failed to load skill content", "loadDiscoveredError": "Failed to load discovered skills", @@ -5491,8 +5436,8 @@ "feedbackPlaceholder": "e.g., 'Add more details about error handling', 'Split this into smaller steps', 'Include tests for the API endpoints'...", "keyboardHint": "Press Ctrl+Enter (or Cmd+Enter) to save", "placeholder": "Enter task specification in Markdown...", - "requestRevision": "Request AI Revision", "requesting": "Requesting…", + "requestRevision": "Request AI Revision", "revisionHelp": "Provide feedback for the AI to improve this specification. The task will move to planning for replanning.", "revisionTitle": "Ask AI to Revise", "saving": "Saving…", @@ -5514,12 +5459,14 @@ "dropTitle": "Drop orphaned stash?", "failedToLoadDiff": "Failed to load diff", "failedToLoadOrphans": "Failed to load orphans", - "fileCount": "{{count}} files", + "fileCount_one": "{{count}} files", + "fileCount_other": "{{count}} files", "inspectDiff": "Inspect diff", "loadingDiff": "Loading diff…", "noDiffOutput": "No diff output available.", "noOrphans": "No orphaned merger autostashes found.", - "orphanCount": "{{count}} orphans", + "orphanCount_one": "{{count}} orphans", + "orphanCount_other": "{{count}} orphans", "shaLabel": "SHA", "title": "Stash Recovery", "unknownSource": "Unknown source" @@ -5592,7 +5539,8 @@ "untitled": "Untitled" }, "syncLog": { - "entryCount": "{{count}} entry", + "entryCount_one": "{{count}} entry", + "entryCount_other": "{{count}} entries", "filterAll": "All", "filterAllNodes": "All Nodes", "filterDirection": "Direction:", @@ -5624,11 +5572,12 @@ "errorLoadVitestSettings": "Failed to load vitest settings", "errorSaveVitestSettings": "Failed to save vitest settings", "footerRefreshFailed": "Latest refresh failed: {{error}}", + "killedProcesses_one": "Killed {{count}} processes", + "killedProcesses_other": "Killed {{count}} processes", "killThresholdInputAriaLabel": "Kill threshold (%)", "killThresholdLabel": "Kill threshold (%)", "killThresholdSliderAriaLabel": "Kill threshold slider (%)", "killVitest": "Kill Vitest Processes", - "killedProcesses": "Killed {{count}} processes", "lastAutoKill": "Last auto-kill: {{time}}", "loading": "Loading system stats…", "notYet": "Not yet", @@ -5672,18 +5621,14 @@ "taskChanges": { "attributionFailed": "Landed-files set may include foreign commits (attribution unavailable).", "disableWordWrap": "Disable word wrap", - "emptyWorktreeHint": "The live worktree diff is empty. Showing the last file paths captured during execution — patches unavailable.", "enableWordWrap": "Enable word wrap", "error": "Error loading changes: {{error}}", - "executionFilesHint": "These are files captured from the worktree during execution. They may differ from the files that actually landed on main. The lineage-backed diff is unavailable for this task.", "expandDiff": "Expand to full-screen diff view", "expandDiffView": "Expand diff view", - "fileCount": "{{count}} file{{plural}} changed.", - "filesChangedHeading": "Files Changed ({{count}})", - "landedFilesHint": "These are files captured from the merged commit metadata. The lineage-backed diff is unavailable for this task.", + "filesChangedHeading_one": "Files Changed ({{count}})", + "filesChangedHeading_other": "Files Changed ({{count}})", "loadError": "Failed to load task changes", "loading": "Loading changes...", - "merged": "Merged {{date}}", "mergedAt": "Merged {{date}}", "nextFile": "Next file", "noExecutionModifications": "The agent did not modify any files during execution.", @@ -5694,7 +5639,6 @@ "noWorktree": "No worktree available for this task.", "noWorktreeHint": "Changes will be shown once the task is in progress.", "previousFile": "Previous file", - "statusUnknown": "status unknown", "summaryHint": "Final commit summary: {{files}} file{{plural}} changed, +{{additions}} additions, -{{deletions}} deletions. Counts only the recorded merge/squash commit, not the full task lineage.", "toggleWordWrap": "Toggle word wrap", "unavailable": "Detailed file changes unavailable." @@ -5703,6 +5647,19 @@ "actions": { "menuBtn": "Actions" }, + "agent": { + "assignBtn": "Assign Agent", + "assignedUpdated": "Assigned agent updated", + "assignFailed": "Failed to assign agent: {{error}}", + "label": "Agent", + "loadFailed": "Failed to load agents: {{error}}", + "loadingAgents": "Loading agents...", + "noAgents": "No agents available", + "unassigned": "Agent unassigned", + "unassignFailed": "Failed to unassign agent: {{error}}", + "unassignTitle": "Unassign agent" + }, + "agentLink": "agent {{id}}", "ageStaleness": { "active": "Active", "age": "Age", @@ -5713,24 +5670,11 @@ "title": "Task age staleness", "warning": "Warning" }, - "agent": { - "assignBtn": "Assign Agent", - "assignFailed": "Failed to assign agent: {{error}}", - "assignedUpdated": "Assigned agent updated", - "label": "Agent", - "loadFailed": "Failed to load agents: {{error}}", - "loadingAgents": "Loading agents...", - "noAgents": "No agents available", - "unassignFailed": "Failed to unassign agent: {{error}}", - "unassignTitle": "Unassign agent", - "unassigned": "Agent unassigned" - }, - "agentLink": "agent {{id}}", "attachments": { "attachBtn": "Attach Screenshot", "attached": "Screenshot attached", - "deleteTitle": "Delete attachment", "deleted": "Attachment deleted", + "deleteTitle": "Delete attachment", "heading": "Attachments", "none": "(no attachments)", "uploading": "Uploading…" @@ -5749,6 +5693,8 @@ "reattachBtn": "Reattach branch", "reattached": "Reattached branch for {{id}} ({{branch}})", "reattachedResult": "Reattached {{branch}} ({{count}} commits ahead of {{base}}).", + "reattachedResult_one": "Reattached {{branch}} ({{count}} commits ahead of {{base}}).", + "reattachedResult_other": "Reattached {{branch}} ({{count}} commits ahead of {{base}}).", "reattaching": "Reattaching…", "skipped": "Branch reattachment skipped for {{id}}: {{reason}}", "skippedResult": "Reattachment skipped: {{reason}}" @@ -5764,21 +5710,21 @@ "actionLeft": "left", "allowRecreation": "Allow re-creation later (operator unlock)", "allowRecreationDesc": "Lets agents recreate this task ID without --force-resurrect. Leave unchecked to keep this task tombstoned.", + "archivedAfterUnlink": "Archived {{id}} after unlinking lineage references", "archiveInstead": "Archive Instead", "archiveUnlinkPrompt": "Archive anyway by unlinking these references first?", - "archivedAfterUnlink": "Archived {{id}} after unlinking lineage references", "ariaLabel": "Delete task", "btn": "Delete", "closeIssue": "Close Issue", "confirm": "Delete", + "deletedAfterRemovingDeps": "Deleted {{id}} after removing dependency references", + "deletedAfterUnlinkLineage": "Deleted {{id}} after unlinking lineage references", + "deletedToast": "Deleted {{id}}{{suffix}}", "deleteIssue": "Delete Issue", "deleteLinkedIssueMessage": "Delete {{issueRef}} on GitHub, or leave it unchanged?", "deleteLinkedIssueTitle": "Delete Linked GitHub Issue", "deleteUnlinkDepsPrompt": "Delete anyway by removing these dependency references first?", "deleteUnlinkLineagePrompt": "Delete anyway by unlinking these references first?", - "deletedAfterRemovingDeps": "Deleted {{id}} after removing dependency references", - "deletedAfterUnlinkLineage": "Deleted {{id}} after unlinking lineage references", - "deletedToast": "Deleted {{id}}{{suffix}}", "forceDeleteTitle": "Force Delete Task", "issueSuffix": "and {{action}} issue {{ref}}", "leaveUnchanged": "Leave Unchanged", @@ -5816,8 +5762,8 @@ "autosaveHint": "Changes autosave as you edit", "autosaving": "Autosaving…", "nodeOverrideLocked": "Execution node override is locked while a task is active/in progress.", - "saveFailed": "Save failed", "saved": "Saved", + "saveFailed": "Save failed", "saving": "Saving…", "sourceExternalIdPlaceholder": "Issue identifier", "sourceIssueHint": "Leave all fields empty to clear source issue metadata.", @@ -5892,7 +5838,8 @@ "activityHeading": "Activity", "agentLog": "Agent Log", "noActivity": "(no activity)", - "truncated": "Showing the most recent {{count}} activity entries." + "truncated_one": "Showing the most recent {{count}} activity entries.", + "truncated_other": "Showing the most recent {{count}} activity entries." }, "longestTimingEvent": "Longest timing event", "longestWorkflowStep": "Longest workflow step", @@ -5908,8 +5855,8 @@ "backToInProgress": "Back to In Progress", "cancelMove": "Cancel Move", "keepProgress": "Keep Progress", - "moveTo": "Move to {{column}}", "movedTo": "Moved to {{column}}", + "moveTo": "Move to {{column}}", "preserveProgressMessage": "This task has completed steps. Keep progress before moving?", "preserveProgressTitle": "Preserve Progress?", "resetProgress": "Reset Progress", @@ -5920,9 +5867,9 @@ "actions": "Choose Archive to move this task to archived, or Keep to continue with this task.", "archiveBtn": "Archive", "archiveConfirm": "Archive", + "archived": "Archived {{id}}", "archiveMessage": "Archive {{id}} as a duplicate of {{duplicateOf}}?", "archiveTitle": "Archive near-duplicate task", - "archived": "Archived {{id}}", "copy": "This task appears to be a near-duplicate of", "headline": "Potential duplicate detected", "keepBtn": "Keep", @@ -5941,8 +5888,8 @@ "noSteps": "No steps", "noTimedEvents": "No timed events recorded yet.", "noTokenUsage": "No token usage recorded for this task yet.", - "noWorkflowStepTimings": "No completed workflow step timings yet.", "notSet": "Not set", + "noWorkflowStepTimings": "No completed workflow step timings yet.", "outputTokens": "Output", "pause": { "pauseBtn": "Pause", @@ -5959,9 +5906,9 @@ "rebuildMessage": "Rebuild the plan for this task? The task will move to planning for replanning.", "rebuildTitle": "Rebuild Plan", "rejectBtn": "Reject Plan", + "rejected": "Plan rejected — {{id}} returned to Planning for replanning", "rejectMessage": "Reject this plan? The specification will be discarded and regenerated.", "rejectTitle": "Reject Plan", - "rejected": "Plan rejected — {{id}} returned to Planning for replanning", "replanning": "Replanning {{id}}…" }, "pr": { @@ -5983,31 +5930,18 @@ "progress": { "heading": "Progress", "noSteps": "(no steps defined)", - "stepCount": "{{count}}/{{total}} steps" + "stepCount_one": "{{count}}/{{total}} step", + "stepCount_other": "{{count}}/{{total}} steps" }, "provenance": { - "agent": "agent", - "api": "API", - "automation": "Automation", - "chatSession": "Chat Session", - "cli": "CLI", "createdBy": "Created by", - "createdVia": "Created via", - "dashboard": "Dashboard", - "duplicate": "Duplicate", - "githubImport": "GitHub Import", - "openIssue": "Open issue", - "quickChat": "Quick Chat", - "recovery": "Recovery", - "refinement": "Refinement", - "research": "Research", - "scheduledTask": "Scheduled Task", - "workflowStep": "Workflow Step" + "createdVia": "Created via" }, "recoveryState": "Recovery state", "refine": { "btn": "Refine", - "charCount": "{{count}}/2000 characters", + "charCount_one": "{{count}}/2000 characters", + "charCount_other": "{{count}}/2000 characters", "createBtn": "Create Refinement Task", "creating": "Creating...", "feedbackRequired": "Please enter feedback describing what needs refinement", @@ -6082,8 +6016,8 @@ "loading": "Loading specification…", "noPrompt": "(no prompt)", "placeholder": "Enter task specification in Markdown...", - "requestRevisionBtn": "Request AI Revision", "requesting": "Requesting…", + "requestRevisionBtn": "Request AI Revision", "revisionColumnError": "Cannot request revision: Task must be in 'triage', 'todo', 'in-progress', or 'in-review' column.", "revisionRequested": "AI revision requested. Task moved to planning.", "saving": "Saving…", @@ -6129,8 +6063,8 @@ "wallClockSinceFirst": "Wall-clock since first execution", "workflow": { "loadFailed": "Failed to load workflow results: {{error}}", - "stepsUpdateFailed": "Failed to update workflow steps: {{error}}", - "stepsUpdated": "Workflow steps updated" + "stepsUpdated": "Workflow steps updated", + "stepsUpdateFailed": "Failed to update workflow steps: {{error}}" }, "workflowRuntime": "Workflow runtime", "workflowTimedSteps": "Workflow timed steps", @@ -6181,8 +6115,8 @@ "taskForm": { "addDependencies": "Add dependencies", "attachHint": "You can also paste images or drag & drop", - "attachScreenshot": "Attach Screenshot", "attachmentsLabel": "Attachments", + "attachScreenshot": "Attach Screenshot", "autoMergeDefault": "Default (Follow project setting)", "autoMergeDisabled": "Disabled", "autoMergeEnabled": "Enabled", @@ -6205,7 +6139,8 @@ "branchStrategyLabel": "Branch strategy", "collapseDescription": "Collapse description", "dependenciesLabel": "Dependencies", - "dependenciesSelected": "{{count}} selected", + "dependenciesSelected_one": "{{count}} selected", + "dependenciesSelected_other": "{{count}} selected", "descriptionLabel": "Description", "descriptionPlaceholder": "What needs to be done?", "descriptionRefinedToast": "Description refined with AI", @@ -6228,17 +6163,11 @@ "moveDown": "Move down", "moveUp": "Move up", "noAvailableTasks": "No available tasks", - "noModelsAvailable": "No models available. Configure authentication in Settings.", "nodeDefaultOption": "Use project default / local", "nodeOverrideHint": "Task override takes priority over project default node routing.", "nodeOverrideLabel": "Execution Node Override", - "nodeStatusConnecting": "Connecting", - "nodeStatusError": "Error", - "nodeStatusOffline": "Offline", - "nodeStatusOnline": "Online", + "noModelsAvailable": "No models available. Configure authentication in Settings.", "overridePreset": "Override", - "phasePostMerge": "Post-merge", - "phasePreMerge": "Pre-merge", "planButton": "Plan", "planningLabel": "Planning", "planningModelLabel": "Planning Model", @@ -6246,10 +6175,6 @@ "presetLabel": "Preset", "presetUseDefault": "Use default", "priorityLabel": "Priority", - "priority_high": "High", - "priority_low": "Low", - "priority_normal": "Normal", - "priority_urgent": "Urgent", "refineAddDetailsDesc": "Add implementation details and context", "refineAddDetailsTitle": "Add details", "refineButton": "Refine", @@ -6264,13 +6189,13 @@ "removeImage": "Remove image", "removeStep": "Remove", "reviewDefault": "Default (Auto — triage decides)", + "reviewerLabel": "Reviewer", + "reviewerModelLabel": "Reviewer Model", "reviewLabel": "Review", "reviewLevel0": "0 — None", "reviewLevel1": "1 — Plan Only", "reviewLevel2": "2 — Plan and Code", "reviewLevel3": "3 — Full", - "reviewerLabel": "Reviewer", - "reviewerModelLabel": "Reviewer Model", "searchTasksPlaceholder": "Search tasks…", "sharedBranchPlaceholder": "e.g. clionboarding", "sharedFeatureBranchLabel": "Shared feature branch", @@ -6297,96 +6222,93 @@ "autoMergeOff": "Auto-merge off", "autoMergeOn": "Auto-merge on", "autoMergePreferenceUpdated": "Per-task auto-merge preference updated", - "completed": "Completed", "completedAtSep": " · Completed: {{timestamp}}", "createPr": "Create PR", "effective": "Effective: {{label}}", "effectiveFrozen": "Effective: {{label}} — frozen on entry to review", - "error": "Error", "errorSep": " · Error: {{message}}", "followDefault": "Follow default", - "lastRefreshed": "Last refreshed", "loadError": "Failed to load review data.", "loadingData": "Loading review data…", "markdown": "Markdown", - "never": "Never", "noCapturedFeedback": "No review feedback captured yet.", "noFeedbackDirect": "No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.", "noReviewItems": "No review items yet.", "perTaskAutoMerge": "Per-task auto-merge", "plain": "Plain", - "prSummaryLine": "{{decision}} · {{count}} review item(s)", + "prSummaryLine_one": "{{decision}} · {{count}} review item(s)", + "prSummaryLine_other": "{{decision}} · {{count}} review item(s)", "queueing": "Queueing…", "refresh": "Refresh", "refreshDataFailed": "Failed to refresh review data.", - "refreshFailed": "Refresh failed", - "refreshSourceBackground": "Background", - "refreshSourceInitialLoad": "Initial load", - "refreshSourceManual": "Manual", - "refreshStatusLine": "{{status}} · Last refreshed: {{timestamp}} · {{source}}", "refreshed": "Review refreshed", + "refreshFailed": "Refresh failed", "refreshing": "Refreshing…", + "refreshStatusLine": "{{status}} · Last refreshed: {{timestamp}} · {{source}}", "requestRevision": "Request revision", - "reviewerSummaryLine": "{{reviewer}} · {{count}} review item(s)", + "reviewerSummaryLine_one": "{{reviewer}} · {{count}} review item(s)", + "reviewerSummaryLine_other": "{{reviewer}} · {{count}} review item(s)", "revisionQueueFailed": "Failed to queue revision", "revisionStarted": "Same-task AI revision started from selected review feedback", - "selected": "Selected", "selectedAt": "Selected: {{timestamp}}", "showMarkdown": "Show formatted markdown", "showRawText": "Show raw text", - "started": "Started", "startedAtSep": " · Started: {{timestamp}}", - "upToDate": "Up to date", - "updateFailed": "Failed to update {{taskId}}: {{error}}" + "updateFailed": "Failed to update {{taskId}}: {{error}}", + "upToDate": "Up to date" }, "tasks": { "addTaskPlaceholder": "Add a task...", "agent": "Agent", "agentLabel": "Agent", "archive": "Archive", + "archived": "Archived {{taskId}}", + "archivedUnlinked": "Archived {{taskId}} after unlinking lineage references", "archiveFailed": "Failed to archive {{taskId}}: {{error}}", "archiveLineageConflict": "{{taskId}} has lineage children ({{children}}) that reference it as a source parent.\n\nArchive anyway by unlinking these references first?", "archiveTask": "Archive task", - "archived": "Archived {{taskId}}", - "archivedUnlinked": "Archived {{taskId}} after unlinking lineage references", "assignedTo": "Assigned to {{name}}", "attach": "Attach", - "attachCount": "Attach ({{count}})", - "attachFileFailed": "Failed to attach {{fileName}}: {{error}}", + "attachCount_one": "Attach ({{count}})", + "attachCount_other": "Attach ({{count}})", "attachedFile": "Attached {{fileName}} to {{taskId}}", + "attachFileFailed": "Failed to attach {{fileName}}: {{error}}", "awaitingApproval": "Awaiting Approval", "baseBranch": "Base", "blockedByTooltip": "Blocked by {{taskId}} (file overlap)", "branch": "Branch", "branchMetadata": "Branch metadata", + "branchProgress": "{{done}}/{{total}} branches", + "branchProgressTitle": "Parallel branches in progress", "cancelMove": "Cancel Move", "clearSelection": "Clear selection", "closeIssue": "Close Issue", "collapse": "Collapse", + "createdByAgent": "Created by agent", + "createdByAgentNamed": "Created by agent: {{name}}", + "createdPr": "Created PR #{{number}}", "createFailed": "Failed to create task", "createPr": "Create PR", "createPrAriaLabel": "Create pull request", "createPrTitle": "Create a PR for this task", "createTaskTitle": "Create task", - "createdByAgent": "Created by agent", - "createdByAgentNamed": "Created by agent: {{name}}", - "createdPr": "Created PR #{{number}}", "creating": "Creating...", "decisionOnly": "decision-only", "decisionOnlyTitle": "Decision-only task", "deleteConfirm": "Delete {{taskId}}?", + "deleted": "Deleted {{taskId}}{{suffix}}", + "deletedRemovedDeps": "Deleted {{taskId}} after removing dependency references", + "deletedUnlinked": "Deleted {{taskId}} after unlinking lineage references", "deleteFailed": "Failed to delete {{taskId}}: {{error}}", "deleteIssue": "Delete Issue", "deleteLinkedIssueMessage": "Delete {{issueLabel}} on GitHub, or leave it unchanged?", "deleteLinkedIssueTitle": "Delete Linked GitHub Issue", "deleteTask": "Delete task", "deleteTitle": "Delete Task", - "deleted": "Deleted {{taskId}}{{suffix}}", - "deletedRemovedDeps": "Deleted {{taskId}} after removing dependency references", - "deletedUnlinked": "Deleted {{taskId}} after unlinking lineage references", "dependencyConflict": "{{taskId}} is a dependency of {{dependentList}}.\n\nDelete anyway by removing these dependency references first?", "deps": "Deps", - "depsCount": "{{count}} deps", + "depsCount_one": "{{count}} deps", + "depsCount_other": "{{count}} deps", "descriptionPlaceholder": "Task description", "descriptionRefined": "Description refined with AI", "doneNoMerge": "Done (no merge)", @@ -6406,11 +6328,14 @@ "fanoutEscalated": "Escalated overlap", "fanoutEscalationSuffix": " · escalated after {{minutes}}m in blocking column", "fanoutHighFanoutSuffix": " (overlap bottleneck threshold: {{threshold}})", - "fanoutStale": "{{count}} stale", - "fanoutTooltip": "Blocking {{count}} active task(s); overlap blockedBy queue: {{queueCount}} todo{{highFanout}}{{escalation}}", + "fanoutStale_one": "{{count}} stale", + "fanoutStale_other": "{{count}} stale", + "fanoutTooltip_one": "Blocking {{count}} active task(s); overlap blockedBy queue: {{queueCount}} todo{{highFanout}}{{escalation}}", + "fanoutTooltip_other": "Blocking {{count}} active task(s); overlap blockedBy queue: {{queueCount}} todo{{highFanout}}{{escalation}}", "fast": "Fast", "fastMode": "Fast mode", - "filesChanged": "{{count}} file changed", + "filesChanged_one": "{{count}} file changed", + "filesChanged_other": "{{count}} files changed", "forceDeleteTitle": "Force Delete Task", "githubTrackingDefaultOff": "off", "githubTrackingDefaultOn": "on", @@ -6438,23 +6363,25 @@ "loadAgentsFailed": "Failed to load agents: {{msg}}", "loadAgentsFailedGeneric": "Failed to load agents", "loadDependencyFailed": "Failed to load dependency {{depId}}", - "loadModelsFailed": "Failed to load models", "loadingAgents": "Loading agents...", + "loadModelsFailed": "Failed to load models", "missionBadgeTitle": "Mission: {{name}}", "modelExecutor": "Executor", "modelPlan": "Plan", "modelReviewer": "Reviewer", "models": "Models", - "modelsCount": "{{count}} model", + "modelsCount_one": "{{count}} model", + "modelsCount_other": "{{count}} models", "moreOptions": "More Options", "move": "Move", + "moved": "Moved {{taskId}} to {{column}}", "moveFailed": "Failed to move {{taskId}}: {{error}}", "moveTask": "Move task", - "moved": "Moved {{taskId}} to {{column}}", "nearDuplicateTitle": "Potential near-duplicate of {{id}}", + "needsInput": "Needs input", "noAgentsAvailable": "No agents available", - "noExistingTasks": "No existing tasks", "node": "Node", + "noExistingTasks": "No existing tasks", "openRetryBreakdown": "Open retry breakdown", "paused": "paused", "pausedByAgent": "paused by agent", @@ -6482,7 +6409,8 @@ "resetProgress": "Reset Progress", "resetProgressMessage": "Reset all step progress before moving this task?", "resetProgressTitle": "Reset Progress?", - "retriesAriaLabel": "{{count}} retries", + "retriesAriaLabel_one": "{{count}} retries", + "retriesAriaLabel_other": "{{count}} retries", "retry": "Retry", "retryFailed": "Failed to retry {{taskId}}: {{error}}", "retrying": "Retrying…", @@ -6498,17 +6426,18 @@ "showSteps": "Show steps", "stalled": "Stalled", "statusMergingFix": "Merging fixes…", - "stepCount": "{{count}} step", + "stepCount_one": "{{count}} step", + "stepCount_other": "{{count}} steps", "stuck": "Stuck", "subtask": "Subtask", "subtaskButtonTitle": "Break down into AI-generated subtasks", "toggleFastMode": "Toggle fast execution mode", "unarchive": "Unarchive", + "unarchived": "Unarchived {{taskId}}", "unarchiveFailed": "Failed to unarchive {{taskId}}: {{error}}", "unarchiveTask": "Unarchive task", - "unarchived": "Unarchived {{taskId}}", - "updateFailed": "Failed to update {{taskId}}: {{error}}", "updated": "Updated {{taskId}}", + "updateFailed": "Failed to update {{taskId}}: {{error}}", "uploadFailed": "Failed to upload: {{files}}", "usingDefault": "Using default", "viewDependency": "Click to view {{depId}}", @@ -6539,37 +6468,14 @@ "statusReconnecting": "Reconnecting..." }, "theme": { - "colorTheme": { - "default": "Default" - }, + "colorTheme": "Color Theme", "colorThemeLabel": "Color theme", "currentTheme": "Current theme", - "dark": "Dark", - "darkMode": "Dark mode", - "fontSize": { - "Default": "Default", - "Large": "Large", - "Largest": "Largest", - "Small": "Small" - }, + "fontSize": "Font Size", "fontSizeLabel": "Dashboard font size", - "light": "Light", - "lightMode": "Light mode", "modeLabel": "Theme mode", "resetButton": "Reset to defaults", - "resetLabel": "Reset to default theme", - "system": "System", - "systemMode": "System mode" - }, - "time": { - "daysAgo": "{{n}}d ago", - "hoursAgo": "{{n}}h ago", - "inAMoment": "in a moment", - "inDays": "in {{n}}d", - "inHours": "in {{n}}h", - "inMinutes": "in {{n}}m", - "justNow": "just now", - "minutesAgo": "{{n}}m ago" + "resetLabel": "Reset to default theme" }, "todo": { "addItemPlaceholder": "Add a todo item", @@ -6593,6 +6499,7 @@ "failedDeleteItemToast": "Failed to delete todo item", "failedDeleteList": "Failed to delete list", "failedDeleteListToast": "Failed to delete todo list", + "failedLoadLists": "Failed to load todo lists", "failedRenameList": "Failed to rename list", "failedRenameListToast": "Failed to rename todo list", "failedReorderItems": "Failed to reorder items", @@ -6653,7 +6560,8 @@ "resetsInDaysHours": "resets in {{days}}d {{hours}}h", "resetsInHours": "resets in {{hours}}h", "resetsInMinutes": "resets in {{mins}}m", - "showHidden": "Show hidden ({{count}})", + "showHidden_one": "Show hidden ({{count}})", + "showHidden_other": "Show hidden ({{count}})", "statusError": "Error", "statusNotConfigured": "Not configured", "title": "Usage", @@ -6663,9 +6571,9 @@ }, "workflow": { "add": "Add", + "adding": "Adding...", "addTemplate": "Add template", "addWorkflowStep": "Add Workflow Step", - "adding": "Adding...", "advisoryExplanation": "Advisory workflow steps flagged non-blocking improvements:", "agentPromptLabel": "Agent Prompt", "agentPromptPlaceholder": "Leave empty to use AI refinement", @@ -6719,6 +6627,7 @@ "gateModeAdvisoryHint": "Failures are recorded as advisory and do not block merge.", "gateModeGate": "Gate", "gateModeGateHint": "Failures block merge and request remediation.", + "graphEditor": "Graph editor", "hideOutput": "Hide output", "loadingBuiltInTemplates": "Loading built-in templates...", "loadingResults": "Loading workflow results…", @@ -6727,12 +6636,12 @@ "modalAriaLabel": "Workflow Steps", "modalTitle": "Workflow Steps", "modeAiPrompt": "AI Prompt", - "modeScript": "Run Script", "modelHintCustom": "Using {{provider}}/{{modelId}}", "modelHintDefault": "Using global default model", "modelOverrideDropdownLabel": "Model override for this workflow step", "modelOverrideLabel": "Model Override", "modelOverridePlaceholder": "Select a model override…", + "modeScript": "Run Script", "moveDown": "Move down", "moveUp": "Move up", "needsReview": "Needs follow-up review.", @@ -6749,8 +6658,6 @@ "phasePreMergeHint": "Runs before merge — can block merge on failure", "plain": "Plain", "polishNotes": "Polish notes", - "postMerge": "Post-merge", - "preMerge": "Pre-merge", "promptRefined": "Prompt refined with AI", "refineWithAi": "Refine with AI", "refineWithAiAriaLabel": "Refine prompt with AI", @@ -6761,31 +6668,87 @@ "selectStepsDescription": "Select steps to run after task implementation completes", "showOutput": "Show output", "started": "Started:", - "statusAdvisory": "Advisory failure", - "statusFailed": "Failed", - "statusPassed": "Passed", - "statusRunning": "Running…", - "statusSkipped": "Skipped", - "stepCount": "{{count}} step{{count_one::count_other:s}}", + "stepCount_one": "{{count}} step", + "stepCount_other": "{{count}} steps", "stepCreated": "Workflow step created", "stepDefinitionNotFound": "Step definition not found.", "stepDeleted": "Workflow step deleted", - "stepUpdated": "Workflow step updated", "steps": "Workflow Steps", "stepsExplanation": "Pre-merge steps run after implementation, before merge. Post-merge steps run after merge succeeds.", - "summaryAdvisory": "{{count}} advisory", - "summaryFailed": "{{count}} failed", - "summaryPassed": "{{count}} passed", - "summaryRunning": "{{count}} running", + "stepUpdated": "Workflow step updated", + "summaryAdvisory_one": "{{count}} advisory", + "summaryAdvisory_other": "{{count}} advisory", + "summaryFailed_one": "{{count}} failed", + "summaryFailed_other": "{{count}} failed", + "summaryPassed_one": "{{count}} passed", + "summaryPassed_other": "{{count}} passed", + "summaryRunning_one": "{{count}} running", + "summaryRunning_other": "{{count}} running", "summarySeparator": " · ", - "summarySkipped": "{{count}} skipped", + "summarySkipped_one": "{{count}} skipped", + "summarySkipped_other": "{{count}} skipped", + "summaryStepCount_one": "{{count}} step", + "summaryStepCount_other": "{{count}} steps", "switchToMarkdown": "Switch to markdown", "switchToPlain": "Switch to plain text", - "tabMySteps": "My Workflow Steps ({{count}})", - "tabTemplates": "Templates ({{count}})", + "tabMySteps_one": "My Workflow Steps ({{count}})", + "tabMySteps_other": "My Workflow Steps ({{count}})", + "tabTemplates_one": "Templates ({{count}})", + "tabTemplates_other": "Templates ({{count}})", "templateAdded": "Added {{name}} workflow step", - "useDefault": "Use default", - "waitingForOutput": "Waiting for agent output…" + "useDefault": "Use default" + }, + "workflowColumns": { + "add": "Add column", + "compositionBlocked": "Resolve trait conflicts on highlighted columns before saving", + "empty": "No columns yet. Add a column to place nodes into board lanes.", + "moveDown": "Move column down", + "moveUp": "Move column up", + "nameLabel": "Column name", + "newColumnName": "New column", + "nodeUnplaced": "Not placed in a column", + "readOnlyHint": "Built-in workflows are read-only — duplicate to edit", + "remove": "Remove column", + "title": "Columns", + "traits": "Traits", + "traitsLoadFailed": "Failed to load traits", + "unplacedCount_one": "{{count}} nodes not placed in a column", + "unplacedCount_other": "{{count}} nodes not placed in a column" + }, + "workflowNodes": { + "advisory": "Advisory", + "failureCollect": "Collect (wait for all)", + "failureFailFast": "Fail-fast (cancel siblings)", + "failurePolicy": "On branch failure", + "gateBlocks": "Gate (blocks)", + "gateMode": "Gate mode", + "joinAll": "All branches", + "joinAny": "Any branch", + "joinMode": "Join mode", + "joinQuorum": "Quorum (n)", + "mergeBoundaryNote": "Steps before this marker run pre-merge; steps after run post-merge.", + "quorumN": "Quorum count (n)", + "releaseCapacity": "Downstream capacity", + "releaseCondition": "Release condition", + "releaseDependency": "Dependency complete", + "releaseExternal": "External event", + "releaseManual": "Manual promote", + "releaseTimer": "Timer", + "splitNote": "Branches run concurrently from this node. Execute and merge seams are not allowed inside a branch." + }, + "workflows": { + "duplicateToCustomize": "Duplicate to customize", + "readOnlyBuiltin": "Read-only built-in workflow", + "saved": "Workflow saved", + "savedNotCompilable": "Workflow saved but cannot be compiled", + "saveFailed": "Failed to save workflow", + "selectOrCreate": "Select or create a workflow to start editing." + }, + "workflowSelector": { + "switchActiveMessage": "This task has an active session. Switching workflows aborts it and re-homes the card into the new workflow's entry column. Continue?", + "switchActiveTitle": "Switch workflow?", + "switchCancel": "Cancel", + "switchConfirm": "Switch and abort" }, "workspace": { "projectRoot": "Project Root", diff --git a/packages/i18n/locales/en/cli.json b/packages/i18n/locales/en/cli.json index 64fee600be..e62aa91612 100644 --- a/packages/i18n/locales/en/cli.json +++ b/packages/i18n/locales/en/cli.json @@ -20,11 +20,12 @@ "agentRunId": "ID:", "agentRunLogsBackHint": "[Esc/q] back to runs", "agentRunLogsTitle": "Run logs ({{index}})", + "agentsFooterHints": "[s] start [x] stop [D] delete [r] refresh [Tab] focus ↑↓ select", + "agentsListTitle_one": "Agents ({{count}})", + "agentsListTitle_other": "Agents ({{count}})", + "agentsNoAgents": "No agents found.", "agentStarted": "Agent started", "agentStopped": "Agent stopped", - "agentsFooterHints": "[s] start [x] stop [D] delete [r] refresh [Tab] focus ↑↓ select", - "agentsListTitle": "Agents ({{count}})", - "agentsNoAgents": "No agents found.", "boardCreateTaskHints": "Enter to create · Esc to cancel", "boardCreateTaskNoProject": "No project selected", "boardCreateTaskTitleEmpty": "Title cannot be empty", @@ -33,6 +34,7 @@ "boardNewTaskProject": "Project: {{name}}", "boardNewTaskTitle": "New Task", "boardNewTaskTitleLabel": "Title", + "boardOtherReadOnlyHint": "custom column — move disabled here", "copiedSuccess": "✓ Copied!", "copyFailed": "✗ Copy failed", "expandedLogHeader": "Entry {{index}}/{{total}} · [Enter/Esc] close · [c] copy", @@ -43,26 +45,27 @@ "filesEmpty": "(empty)", "filesEmptyFile": "(empty file)", "filesFooterHints": "[Tab] switch pane [↑↓/jk] move [Enter] open [←/→] collapse/expand [.] hidden [w] wrap [p] project [r] reload", - "filesMoreLines": "… {{count}} more lines", + "filesMoreLines_one": "… {{count}} more lines", + "filesMoreLines_other": "… {{count}} more lines", "filesSelectProject": "Select Project", "filesSelectToPreview": "Select a file to preview", "filesTooLarge": "{{size}} — [too large to preview]", "filesUnableToRead": "Unable to read file", - "gitFetchFailed": "Fetch failed: {{output}}", "gitFetched": "Fetched", + "gitFetchFailed": "Fetch failed: {{output}}", "gitFetching": "Fetching…", "gitFooterHints": "[r] refresh {{push}}[F] fetch [↑↓] rows [←→] status▸branches{{worktrees}}▸commits▸changes [p] project [Esc/s] back", "gitNoCommits": "No commits", "gitNoProject": "No project", "gitPushDismissHint": "[Esc] dismiss", "gitPushFailed": "Push failed", + "gitPushingToOrigin": "Pushing to origin/{{branch}}", "gitPushModalAhead": "ahead", "gitPushModalBranch": "Branch:", "gitPushModalCommits": "Commits to push (oldest→newest):", "gitPushModalHints": "[Enter] push [Esc] cancel", "gitPushModalTitle": "Push to remote", "gitPushSuccessful": "Push successful", - "gitPushingToOrigin": "Pushing to origin/{{branch}}", "gitRefreshing": "refreshing", "gitWorkingTreeClean": "Working tree clean", "headerHelpQuitHint": "[?] help [q] quit", @@ -117,14 +120,13 @@ "projectSelectorChangeHint": "[p] change", "projectSelectorLabel": "Project:", "projectSelectorNavHints": "↑↓ navigate · Enter select · Esc cancel", - "projectSelectorNoProjects": "(no projects registered)", "projectSelectorNone": "(none)", + "projectSelectorNoProjects": "(no projects registered)", "projectSelectorPickTitle": "Pick a project", "qrCloseHint": "[Esc] close", "qrGenerating": "Generating QR…", "qrNoTunnelRunning": "No remote tunnel is running. Start one in Settings (g).", "qrOverlayTitle": "Remote Access — Scan to connect", - "quit": "Quit", "readyIn": "Ready in {{secs}}s", "runLogNone": "No logs captured for this run.", "runLogResult": "result:", @@ -137,16 +139,6 @@ "runStatusFailed": "Failed", "runStatusTerminated": "Terminated", "runStatusUnknown": "Unknown", - "settingAutoMerge": "Auto Merge", - "settingEnginePaused": "Engine Paused", - "settingGlobalPause": "Global Pause", - "settingMaxConcurrent": "Max Concurrent", - "settingMaxWorktrees": "Max Worktrees", - "settingMergeStrategy": "Merge Strategy", - "settingPollIntervalMs": "Poll Interval (ms)", - "settingRemoteActiveProvider": "Remote Provider", - "settingRemoteShortLivedEnabled": "Short-Lived Tokens", - "settingRemoteShortLivedTtlMs": "Short-Lived TTL (ms)", "settingsActivatedProvider": "Activated provider: {{provider}}", "settingsAdjust1": "[+/-] adjust by 1", "settingsAdjust5000ms": "[+/-] adjust by 5000ms", @@ -161,7 +153,8 @@ "settingsFooterHints": "[Tab] switch panel ↑↓ select setting [Space] toggle bool [+/-] adjust num [←/→] cycle enum [C/V/X/P/L/U/K/R] remote actions", "settingsInteractivePanelTitle": "Settings", "settingsLoadingSettings": "Loading settings…", - "settingsMoreModels": "… and {{count}} more", + "settingsMoreModels_one": "… and {{count}} more", + "settingsMoreModels_other": "… and {{count}} more", "settingsPanelTitle": "Settings", "settingsPersistentTokenRegenerated": "Persistent token regenerated", "settingsQrFetched": "QR payload fetched", diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index f902be0dcb..df3c04e712 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -4,8 +4,65 @@ "close": "Close", "save": "Save" }, + "agents": { + "ratings": { + "trendDeclining": "↓ Declining", + "trendImproving": "↑ Improving", + "trendInsufficient": "Insufficient data", + "trendStable": "→ Stable" + }, + "reflections": { + "triggerManual": "Manual", + "triggerPeriodic": "Periodic", + "triggerPostTask": "Post-Task", + "triggerUserRequested": "User Requested" + }, + "time": { + "daysAgo_one": "{{count}}d ago", + "daysAgo_other": "{{count}}d ago", + "hoursAgo_one": "{{count}}h ago", + "hoursAgo_other": "{{count}}h ago", + "inAMoment": "in a moment", + "inDays_one": "in {{count}}d", + "inDays_other": "in {{count}}d", + "inHours_one": "in {{count}}h", + "inHours_other": "in {{count}}h", + "inMinutes_one": "in {{count}}m", + "inMinutes_other": "in {{count}}m", + "justNow": "just now", + "minutesAgo_one": "{{count}}m ago", + "minutesAgo_other": "{{count}}m ago" + } + }, "archive": "Archive", + "board": { + "rejection": { + "capacityExhausted": "That column is at capacity. Try again when a slot frees up.", + "guardRejected": "This move is not allowed by the workflow.", + "mergeBlocked": "This task is blocked from completing until its merge step finishes.", + "unknownColumn": "That column doesn't exist in this task's workflow.", + "workflowMismatch": "Drag can't move a card between workflows. Use the workflow switcher instead." + } + }, "cancel": "Cancel", + "chat": { + "failedToGetResponse": "Failed to get response", + "failureReferenceId": "ID", + "failureReferenceKind": "Kind", + "failureReferenceLabel": "Reference", + "failureReferenceMetaLabel": "Label", + "openMailboxMessage": "Open mailbox message", + "toolCallArgsPrefix": "args", + "toolCallResultPrefix": "result", + "toolCallStatusCompleted": "completed", + "toolCallStatusError": "error", + "toolCallStatusErrors": "errors", + "toolCallStatusRunning": "running", + "toolCallsCount_one": "{{count}} tool calls", + "toolCallsCount_other": "{{count}} tool calls", + "toolCallsHeader": "Tool calls", + "viewFailureDetails": "View failure details" + }, "close": "Close", "columns": { "archived": "Archived", @@ -16,8 +73,162 @@ "triage": "Planning" }, "delete": "Delete", + "health": { + "anomaly": { + "duplicateActiveId": "Duplicate active task ID", + "idInBothStorages": "Task ID present in active and archived storage", + "sequenceOverlap": "Allocator next sequence overlaps an existing task ID", + "unknownPrefix": "Task row uses a prefix outside allocator state" + } + }, + "inline": { + "connecting": "Connecting", + "error": "Error", + "offline": "Offline", + "online": "Online" + }, + "merge": { + "unknown": "Unknown" + }, + "missions": { + "autopilotStateActivating": "Activating slice", + "autopilotStateCompleting": "Completing", + "autopilotStateInactive": "Off", + "autopilotStateUnknown": "Unknown", + "autopilotStateWatching": "Watching", + "interviewStatusAwaitingInput": "Awaiting input", + "interviewStatusComplete": "Plan ready", + "interviewStatusError": "Needs retry", + "interviewStatusGenerating": "Generating plan", + "runHelperActive": "Stopping pauses linked tasks and marks the mission blocked.", + "runHelperBlocked": "Resuming re-activates the mission and continues execution.", + "runHelperPlanning": "Starting activates the first slice so work can begin." + }, + "models": { + "messages": { + "modelSetTo": "{{label}} model set to {{provider}}/{{modelId}}", + "modelSetToDefault": "{{label}} model set to default" + } + }, + "nodeStatus": { + "connecting": "Connecting", + "error": "Error", + "offline": "Offline", + "online": "Online", + "unknown": "Unknown" + }, + "nodes": { + "auth": { + "differ": "Auth credentials differ", + "differProviders": "Auth credentials differ: {{providers}}", + "match": "Auth credentials match", + "notSynced": "Auth not synced" + }, + "status": { + "connecting": "Connecting", + "creating": "Creating", + "deleting": "Deleting", + "error": "Error", + "exited": "Exited", + "offline": "Offline", + "online": "Online", + "recreating": "Recreating", + "running": "Running", + "stopped": "Stopped" + } + }, "refresh": "Refresh", + "research": { + "providerGitHub": "GitHub", + "providerLlmSynthesis": "LLM Synthesis", + "providerLocalDocs": "Local Docs", + "providerPageFetch": "Page Fetch", + "providerWebSearch": "Web Search" + }, "retry": "Retry", + "routing": { + "policyLabel": { + "block": "Block execution", + "fallback": "Fall back to local", + "notConfigured": "Not configured" + } + }, + "setup": { + "apiKeyFormatError": "{{providerName}} keys should follow this format: {{hint}} (e.g. {{example}})", + "apiKeyLabel": { + "fallback": "API Key", + "kimiCoding": "Kimi API Key", + "minimax": "MiniMax API Key", + "ollama": "Ollama Endpoint", + "openai": "OpenAI API Key", + "openrouter": "OpenRouter API Key", + "zai": "Zhipu AI API Key" + }, + "apiKeyPlaceholder": { + "fallback": "Enter API key", + "kimiCoding": "Enter your Kimi API key", + "minimax": "Enter your MiniMax API key", + "zai": "Enter your Zhipu AI API key" + }, + "apiKeyRequired": "API key is required", + "apiKeySetup": { + "fallback": "Enter your API key for this provider.", + "kimiCoding": "Create your API key in the Moonshot platform account settings.", + "minimax": "Generate an API key from the MiniMax platform developer console.", + "ollama": "Enter your Ollama endpoint URL (for example http://localhost:11434).", + "openai": "Create an API key from your OpenAI dashboard under API keys.", + "openrouter": "Create an API key from your OpenRouter account key management page.", + "zai": "Create an API key in the Zhipu AI open platform account settings." + }, + "apiKeyUsage": { + "fallback": "Used by Fusion to authenticate requests to this provider", + "kimiCoding": "Used for Kimi/Moonshot AI models in task execution and planning", + "minimax": "Used for MiniMax models in task execution", + "ollama": "Connects to your local Ollama instance", + "openai": "Used for GPT models in task execution and planning", + "openrouter": "Routes to multiple AI model providers through a single key", + "zai": "Used for GLM models in task execution" + }, + "providerDesc": { + "anthropic": "Claude models — strong at reasoning, analysis, and code", + "fallback": "AI provider — connect to start using AI models", + "gemini": "Gemini models — multimodal with strong reasoning", + "google": "Gemini models — multimodal with strong reasoning", + "kimi": "Kimi by Moonshot AI — long-context capabilities", + "kimiCoding": "Kimi by Moonshot AI — long-context capabilities", + "minimax": "MiniMax models — cost-effective for high-volume usage", + "moonshot": "Kimi by Moonshot AI — long-context capabilities", + "ollama": "Run open-source models locally on your machine", + "openai": "GPT models — versatile for a wide range of tasks", + "openaiCodex": "Codex models by OpenAI — optimized for coding tasks", + "openrouter": "OpenRouter — route requests across multiple AI providers", + "zai": "GLM models by Zhipu AI — strong multilingual support" + } + }, "skip": "Skip", - "tryAgain": "Try Again" + "taskForm": { + "nodeStatusConnecting": "Connecting", + "nodeStatusError": "Error", + "nodeStatusOffline": "Offline", + "nodeStatusOnline": "Online", + "phasePostMerge": "Post-merge", + "phasePreMerge": "Pre-merge" + }, + "taskReview": { + "never": "Never", + "refreshSourceBackground": "Background", + "refreshSourceInitialLoad": "Initial load", + "refreshSourceManual": "Manual" + }, + "tryAgain": "Try Again", + "workflow": { + "postMerge": "Post-merge", + "preMerge": "Pre-merge", + "statusAdvisory": "Advisory failure", + "statusFailed": "Failed", + "statusPassed": "Passed", + "statusRunning": "Running…", + "statusSkipped": "Skipped", + "waitingForOutput": "Waiting for agent output…" + } } diff --git a/packages/i18n/locales/en/errors.json b/packages/i18n/locales/en/errors.json index 0abd051afd..0967ef424b 100644 --- a/packages/i18n/locales/en/errors.json +++ b/packages/i18n/locales/en/errors.json @@ -1,4 +1 @@ -{ - "fetchProjectsFailed": "Failed to fetch projects", - "openTaskLogsFailed": "Failed to open task logs: {{detail}}" -} +{} diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 229961d2ea..bf61429fd9 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -16,7 +16,6 @@ "dismissOAuth": "Descartar el banner de reinicio de sesión de OAuth", "done": "Hecho", "edit": "Editar", - "generateInsights": "Generar nueva información", "no": "No", "openSettings": "Abrir configuración", "pull": "Obtener", @@ -66,10 +65,13 @@ "notMerged": "No fusionado", "refresh": "Actualizar", "time": { - "daysAgo": "hace {{count}} d", - "hoursAgo": "hace {{count}} h", + "daysAgo_one": "", + "daysAgo_other": "", + "hoursAgo_one": "", + "hoursAgo_other": "", "justNow": "Ahora mismo", - "minutesAgo": "hace {{count}} min" + "minutesAgo_one": "", + "minutesAgo_other": "" }, "title": "Registro de actividad" }, @@ -95,26 +97,30 @@ "hideToolCallsResults": "Ocultar llamadas de herramientas y resultados", "hideToolOutput": "Ocultar salida de la herramienta", "live": "En directo", - "loadMore": "Cargar más", "loading": "Cargando registros de agentes…", "loadingMore": "Cargando…", + "loadMore": "Cargar más", "markdown": "Markdown", "plain": "Texto plano", "planning": "Planificación", "reviewer": "Revisor", "showFormattedMarkdown": "Mostrar markdown formateado", + "showing": "Mostrando {{visible}} de {{total}} entradas", "showOutput": "Mostrar salida", "showRawText": "Mostrar texto sin formato", "showToolCallsResults": "Mostrar llamadas de herramientas y resultados", "showToolOutput": "Mostrar salida de la herramienta", - "showing": "Mostrando {{visible}} de {{total}} entradas", "switchMarkdown": "Cambiar a modo markdown", "switchPlainText": "Cambiar a modo texto plano", - "timeDaysAgo": "hace {{count}} d", - "timeHoursAgo": "hace {{count}} h", + "timeDaysAgo_one": "", + "timeDaysAgo_other": "", + "timeHoursAgo_one": "", + "timeHoursAgo_other": "", "timeJustNow": "ahora mismo", - "timeMinutesAgo": "hace {{count}} min", - "toolEntriesHidden": "{{count}} entradas de herramientas ocultas", + "timeMinutesAgo_one": "", + "timeMinutesAgo_other": "", + "toolEntriesHidden_one": "", + "toolEntriesHidden_other": "", "toolsOff": "Herramientas: Desactivado", "toolsOn": "Herramientas: Activado", "usingDefault": "Usando predeterminado" @@ -239,15 +245,6 @@ "promptDefault": "Predeterminado: {{preview}}", "templateName": "Por ejemplo Mi ejecutor personalizado" }, - "roles": { - "custom": "Agente personalizado", - "engineer": "Agente ingeniero", - "executor": "Agente ejecutor", - "merger": "Agente de fusión", - "reviewer": "Agente revisor", - "scheduler": "Agente programador", - "triage": "Agente de clasificación" - }, "sections": { "builtinTemplates": "Plantillas integradas", "customTemplates": "Plantillas personalizadas" @@ -281,7 +278,8 @@ }, "agents": { "activate": "Activar", - "activeAgents": "Agentes activos ({{count}})", + "activeAgents_one": "", + "activeAgents_other": "", "activePrefix": "Activo: ", "advancedSettingsDesc": "Opciones de configuración avanzadas para este agente.", "advancedSettingsTitle": "Configuración avanzada", @@ -291,15 +289,16 @@ "agentMail": "Correo del agente", "agentModelLabel": "Modelo del agente", "agentPlural": "agentes", + "agentsFound_one": "", + "agentsFound_other": "", "agentSingular": "agente", - "agentSoulLabel": "Alma del agente", - "agentsFound": "{{count}} agente{{plural}} encontrado{{plural}}", "agentsLabel": "Agentes", + "agentSoulLabel": "Alma del agente", "aiInterview": "Entrevista con IA", "allChangesSaved": "Todos los cambios guardados", - "allTime": "Todo el tiempo", "allowParallelExecution": "Permitir ejecución paralela", "allowParallelExecutionHint": "Permitir que este agente ejecute múltiples heartbeats simultáneamente.", + "allTime": "Todo el tiempo", "alreadyOnDefault": "Ya en valor predeterminado", "applyPreset": "Aplicar preajuste", "assignedSkills": "Habilidades asignadas", @@ -330,12 +329,12 @@ "bulkActions": "Acciones masivas", "bulkActionsLoadFailed": "Error al cargar acciones masivas de agentes: {{error}}", "bulkAgentActions": "Acciones masivas de agentes", - "bulkConfirmMessage": "¿{{action}} {{count}} agente(s)?", + "bulkConfirmMessage_one": "", + "bulkConfirmMessage_other": "", "bulkNoEligible": "Sin agentes elegibles", - "bulkResult": "{{action}} {{count}} agente(s)", - "bulkResultWithFailures": "{{action}} {{count}} agente(s), {{failed}} fallido(s)", "bulkResult_one": "{{action}} {{successCount}} {{agentWord}}; omitido {{skippedCount}}", "bulkResult_other": "{{action}} {{successCount}} {{agentWord}}; omitido {{skippedCount}}", + "bulkResultWithFailures": "{{action}} {{count}} agente(s), {{failed}} fallido(s)", "bundleDescription": "Configure cómo se gestiona el paquete de código de este agente.", "bundleEntryFileHint": "El archivo de entrada del paquete gestionado.", "bundleEntryFileLabel": "Archivo de entrada", @@ -381,9 +380,9 @@ "copyId": "Copiar ID", "create": "Crear", "createAgent": "Crear agente", + "created": "Agente «{{name}}» creado", "createError": "Error al crear el agente", "createSuccess": "Agente «{{name}}» creado", - "created": "Agente «{{name}}» creado", "creating": "Creando…", "creatingAgent": "Creando agente...", "currentAgent": "Agente actual", @@ -400,12 +399,12 @@ "delete": "Eliminar", "deleteAgent": "Eliminar agente", "deleteConfirm": "¿Eliminar el agente \"{{name}}\"? Esta acción no se puede deshacer.", + "deleted": "Agente \"{{name}}\" eliminado", "deleteError": "Error al eliminar el agente: {{error}}", "deleteFailed": "Error al eliminar el agente: {{error}}", "deleteMessage": "¿Eliminar el agente «{{name}}»? Esta acción no se puede deshacer.", "deleteSuccess": "Agente «{{name}}» eliminado", "deleteTitle": "Eliminar agente", - "deleted": "Agente \"{{name}}\" eliminado", "deletionNotAvailable": "La eliminación no está disponible mientras el agente está en ejecución.", "deletionPermanent": "Esto eliminará permanentemente el agente y todos los datos asociados.", "details": "Detalles", @@ -473,6 +472,8 @@ "healthError": "Error", "heartbeat": "Latido:", "heartbeatAndHealth": "Heartbeat y salud", + "heartbeatClampedToMin_one": "", + "heartbeatClampedToMin_other": "", "heartbeatCustom": "Ejecución de heartbeat personalizada", "heartbeatEnabled": "Heartbeat habilitado", "heartbeatEnabledHint": "Permita que este agente se ejecute en un heartbeat programado.", @@ -483,12 +484,12 @@ "heartbeatFileLoadFailed": "Error al cargar el archivo de heartbeat", "heartbeatFilePlaceholder": "Contenido del procedimiento de heartbeat...", "heartbeatFilePreviewMode": "Modo vista previa", - "heartbeatFileSaveFailed": "Error al guardar el archivo de heartbeat", "heartbeatFileSaved": "Archivo de heartbeat guardado", + "heartbeatFileSaveFailed": "Error al guardar el archivo de heartbeat", "heartbeatIntervalHint": "Con qué frecuencia se ejecuta el heartbeat, en segundos.", "heartbeatIntervalLabel": "Intervalo de heartbeat (s)", - "heartbeatIntervalUpdateFailed": "Error al actualizar el intervalo de latido: {{error}}", "heartbeatIntervalUpdated": "Intervalo de latido de {{name}} actualizado a {{interval}}", + "heartbeatIntervalUpdateFailed": "Error al actualizar el intervalo de latido: {{error}}", "heartbeatMustBeNumber": "El intervalo de latido debe ser un número válido", "heartbeatMustBePositive": "El intervalo de latido debe ser mayor que 0", "heartbeatOverdue": "Latido vencido {{elapsed}}", @@ -512,8 +513,8 @@ "heartbeatSpeedPreset": "Ajuste predefinido de velocidad de latido", "heartbeatSpeedSaveFailed": "Error al guardar el multiplicador de latido: {{error}}", "heartbeatSpeedSet": "Velocidad de latido establecida en ×{{value}}", - "heartbeatStartFailed": "Error al iniciar el heartbeat", "heartbeatStarted": "Heartbeat iniciado", + "heartbeatStartFailed": "Error al iniciar el heartbeat", "heartbeatTimeoutHint": "Tiempo máximo en segundos que puede tardar una ejecución de heartbeat antes de ser terminada.", "heartbeatTimeoutLabel": "Tiempo de espera de heartbeat (s)", "heartbeatUpgradeFailed": "Error al actualizar el procedimiento de heartbeat", @@ -527,16 +528,18 @@ "importButton": "Importar {{label}}", "importComplete": "Importación completa", "importDescription": "Importe agentes desde un paquete de Agent Companies. Explore el catálogo companies.sh para descubrir agentes publicados, cargue un archivo AGENTS.md, seleccione un directorio o pegue el contenido del manifiesto.", - "importingAgents": "Importando {{count}} agente{{plural}}...", + "importingAgents_one": "", + "importingAgents_other": "", "importingAgentsAndSkills": "Importando {{agentCount}} agente{{agentPlural}} y {{skillCount}} habilidad{{skillPlural}}...", - "importingSkills": "Importando {{count}} habilidad{{plural}}...", - "inProgress": "En progreso", + "importingSkills_one": "", + "importingSkills_other": "", "inbox": "Bandeja de entrada", - "inheritProjectDefault": "Heredar predeterminado del proyecto", "inheritingProjectDefault": "Heredando el valor predeterminado del proyecto", + "inheritProjectDefault": "Heredar predeterminado del proyecto", "inlineMemoryFieldHint": "Esta memoria está integrada directamente en el contexto del agente.", "inlineMemoryHint": "Memoria corta inyectada en cada heartbeat.", "inlineMemoryLabel": "Memoria en línea", + "inProgress": "En progreso", "input": "Entrada", "inputTokens": "Tokens de entrada", "installs": "instalaciones", @@ -544,15 +547,15 @@ "instructionsEmptyPreview": "Sin instrucciones aún — cambie al modo edición para agregar.", "instructionsFileEditorDesc": "Edite directamente el archivo de instrucciones vinculado.", "instructionsFileEditorTitle": "Archivo de instrucciones", - "instructionsFileSaveFailed": "Error al guardar el archivo de instrucciones", "instructionsFileSaved": "Archivo de instrucciones guardado", + "instructionsFileSaveFailed": "Error al guardar el archivo de instrucciones", "instructionsHint": "Estas instrucciones se anteponen a cada prompt que recibe este agente.", "instructionsPathHint": "Ruta a un archivo markdown que contiene las instrucciones de este agente.", "instructionsPathLabel": "Ruta del archivo de instrucciones", "instructionsPathPlaceholder": "p. ej. .fusion/agents/reviewer.md", "instructionsPlaceholder": "Ingrese instrucciones para este agente...", - "instructionsSaveFailed": "Error al guardar las instrucciones", "instructionsSaved": "Instrucciones guardadas", + "instructionsSaveFailed": "Error al guardar las instrucciones", "instructionsTextPlaceholder": "Agregar instrucciones de comportamiento personalizadas…", "instructionsTitle": "Instrucciones", "intentPrompt": "¿Qué desea que haga este agente?", @@ -574,7 +577,6 @@ "liveLogs": "Registros en vivo", "liveRun": "Ejecución en vivo", "loadError": "Error al cargar agentes: {{error}}", - "loadTasksFailed": "Error al cargar las tareas", "loading": "Cargando agente...", "loadingAgents": "Cargando agentes…", "loadingCompanies": "Cargando empresas…", @@ -594,15 +596,17 @@ "loadingRuntimes": "Cargando entornos de ejecución…", "loadingSkillContent": "Cargando contenido de habilidad...", "loadingTasks": "Cargando tareas...", - "logEntries": "entradas de registro", + "loadTasksFailed": "Error al cargar las tareas", + "logEntries_one": "", + "logEntries_other": "", "logsWillAppear": "Los registros aparecerán aquí una vez que el agente comience a ejecutarse.", "logsWillAppearActive": "Los registros aparecerán aquí.", + "mailboxLoadFailed": "Error al cargar el buzón", "mailFrom": "De", "mailSent": "Enviado", "mailTo": "Para", "mailToLabel": "Para", "mailType": "Tipo", - "mailboxLoadFailed": "Error al cargar el buzón", "manifestContent": "Contenido del manifiesto", "manifestPlaceholder": "---\nname: CEO\ntitle: Director Ejecutivo\nreportsTo: null\nskills:\n - review\n---\nInstrucciones del agente aquí...", "maxConcurrentRunsHint": "Número máximo de heartbeats que pueden ejecutarse simultáneamente.", @@ -616,8 +620,8 @@ "memoryFileMeta": "{{size}} bytes · actualizado {{date}}", "memoryFilePlaceholder": "Contenido del archivo de memoria...", "memoryFilePreviewMode": "Modo vista previa", - "memoryFileSaveFailed": "Error al guardar el archivo de memoria", "memoryFileSaved": "Archivo de memoria guardado", + "memoryFileSaveFailed": "Error al guardar el archivo de memoria", "memoryFilesHint": "Archivos almacenados en las capas de memoria del agente.", "memoryFilesHintSuffix": "Seleccione un archivo para ver o editar su contenido.", "memoryFilesLabel": "Archivos de memoria", @@ -630,8 +634,8 @@ "memoryLayerLongTermDesc": "Hechos y conocimientos persistentes conservados entre sesiones.", "memoryPlaceholder": "Privado para este agente — preferencias duraderas, hábitos de trabajo y contexto que debe conservar entre tareas…", "memoryReadOnly": "Solo lectura", - "memorySaveFailed": "Error al guardar la memoria", "memorySaved": "Memoria guardada", + "memorySaveFailed": "Error al guardar la memoria", "memoryTitle": "Memoria", "memoryTooLong": "El contenido de la memoria es demasiado largo", "messageResponseModeHint": "Cuándo responde este agente a los mensajes entrantes.", @@ -672,6 +676,7 @@ "noLogsForRun": "Sin registros para esta ejecución", "noManager": "Sin responsable", "noMemoryFiles": "Sin archivos de memoria", + "noneUsingBuiltIn": "Ninguno (usando integrado)", "noOutboxMessages": "No hay mensajes en la bandeja de salida", "noOutputCaptured": "Sin salida capturada", "noPausedEligible": "Sin agentes pausados elegibles para reanudar", @@ -684,11 +689,9 @@ "noSkillsInPackage": "Sin habilidades en el paquete", "noTasksAssigned": "Sin tareas asignadas", "noTokenUsageYet": "No hay uso de tokens registrado aún. Los totales de tokens aparecerán aquí una vez que los agentes se ejecuten.", - "noneUsingBuiltIn": "Ninguno (usando integrado)", "notScheduled": "No programado", "notSelected": "No seleccionado", "off": "Desactivado", - "onHeartbeat": "En heartbeat", "onboarding": { "applyDraftAgent": "Aplicar borrador al formulario del agente", "applyDraftSettings": "Aplicar borrador al formulario de configuración", @@ -729,9 +732,9 @@ "updatedDraftReady": "Borrador actualizado listo para revisión", "yes": "sí" }, + "onHeartbeat": "En heartbeat", "openDetails": "Abrir detalles de {{name}}", "optional": "(opcional)", - "orPasteManifest": "o pegue el contenido del manifiesto", "orgChartCanvas": "Lienzo del organigrama", "orgChartCenter": "Centrar organigrama", "orgChartEmployees": "Empleados de {{name}}", @@ -740,6 +743,7 @@ "orgChartView": "Vista de organigrama", "orgChartZoomIn": "Acercar organigrama", "orgChartZoomOut": "Alejar organigrama", + "orPasteManifest": "o pegue el contenido del manifiesto", "outbox": "Bandeja de salida", "output": "Salida", "outputTokens": "Tokens de salida", @@ -750,13 +754,21 @@ "pauseAgentsFailed": "Error al pausar los agentes: {{error}}", "pauseAll": "Pausar todo", "pauseAllAgents": "Pausar todos los agentes", + "pauseAllConfirm_one": "", + "pauseAllConfirm_other": "", "pauseAllTitle": "Pausar todos los agentes", - "pauseCountHint": "{{count}} agente(s) activo(s) será pausado", "pauseCountHint_one": "Pausar {{count}} agente activo/en ejecución", "pauseCountHint_other": "Pausar {{count}} agentes activos/en ejecución", + "pauseCountHint_one_one": "", + "pauseCountHint_one_other": "", + "pauseCountHint_other_one": "", + "pauseCountHint_other_other": "", "pausedPast": "pausado", + "pausedSummary_one": "", + "pausedSummary_other": "", "pendingApprovals": "Aprobaciones pendientes", - "pendingApprovalsCount": "{{count}} pendiente(s)", + "pendingApprovalsCount_one": "", + "pendingApprovalsCount_other": "", "performance": { "avgDuration": "Duración media", "noData": "Aún no hay datos de rendimiento", @@ -774,6 +786,7 @@ "preview": "Vista previa", "promptSize": "Tamaño del prompt", "promptSizeChart": "Gráfico de tamaño de prompt", + "provideManifest": "", "ratings": { "addError": "Error al añadir la valoración: {{error}}", "addRating": "Añadir valoración", @@ -786,7 +799,8 @@ "categorySelect": "Seleccionar categoría...", "categorySpeed": "Velocidad", "commentPlaceholder": "Comentario opcional...", - "count": "{{count}} valoraciones", + "count_one": "", + "count_other": "", "deleteError": "Error al eliminar la valoración: {{error}}", "deleteRating": "Eliminar valoración", "deleteSuccess": "Valoración eliminada", @@ -794,14 +808,11 @@ "loadError": "Error al cargar las valoraciones: {{error}}", "loading": "Cargando valoraciones...", "noRatings": "Aún no hay valoraciones", - "starCount": "{{count}} estrella", + "starCount_one": "", + "starCount_other": "", "submitRating": "Enviar valoración", "submitting": "Enviando...", - "title": "Valoraciones de usuarios", - "trendDeclining": "↓ Declinando", - "trendImproving": "↑ Mejorando", - "trendInsufficient": "Datos insuficientes", - "trendStable": "→ Estable" + "title": "Valoraciones de usuarios" }, "recentRuns": "Ejecuciones recientes", "reflections": { @@ -818,18 +829,14 @@ "metricAvgDuration": "Duración media:", "metricErrors": "Errores:", "metricFailed": "Fallidas:", - "metricTasks": "Tareas:", "metrics": "Métricas", + "metricTasks": "Tareas:", "noReflections": "Aún no hay reflexiones", + "reflecting": "Reflexionando...", "reflectNow": "Reflexionar ahora", "reflectNowTitle": "Generar una reflexión manual", - "reflecting": "Reflexionando...", "sectionTitle": "Rendimiento, reflexiones y valoraciones", - "suggestedImprovements": "Mejoras sugeridas", - "triggerManual": "Manual", - "triggerPeriodic": "Periódico", - "triggerPostTask": "Post-tarea", - "triggerUserRequested": "Solicitado por el usuario" + "suggestedImprovements": "Mejoras sugeridas" }, "refresh": "Actualizar", "removeAvatar": "Eliminar avatar", @@ -842,19 +849,29 @@ "resetDayWeekly": "Día de la semana (0=Dom)", "resetting": "Restableciendo...", "result": "Resultado", - "resultCreated": "{{count}} creado{{plural}}", - "resultErrors": "{{count}} error{{plural}}", - "resultSkipped": "{{count}} omitido{{plural}} (ya existe{{plural}})", + "resultCreated_one": "", + "resultCreated_other": "", + "resultErrors_one": "", + "resultErrors_other": "", + "resultSkipped_one": "", + "resultSkipped_other": "", "resume": "Reanudar", "resumeAction": "Reanudar", "resumeAgentsFailed": "Error al reanudar los agentes: {{error}}", "resumeAll": "Reanudar todo", "resumeAllAgents": "Reanudar todos los agentes", + "resumeAllConfirm_one": "", + "resumeAllConfirm_other": "", "resumeAllTitle": "Reanudar todos los agentes", - "resumeCountHint": "{{count}} agente(s) pausado(s) será reanudado", "resumeCountHint_one": "Reanudar {{count}} agente pausado", "resumeCountHint_other": "Reanudar {{count}} agentes pausados", + "resumeCountHint_one_one": "", + "resumeCountHint_one_other": "", + "resumeCountHint_other_one": "", + "resumeCountHint_other_other": "", "resumedPast": "reanudado", + "resumedSummary_one": "", + "resumedSummary_other": "", "retry": "Reintentar", "reviewConfiguration": "Revisar configuración generada", "reviewHint": "Revisa la configuración de tu agente antes de crear.", @@ -868,20 +885,18 @@ "roleReviewer": "Revisor", "roleScheduler": "Programador", "roleTriage": "Triaje", + "roleUpdated": "Rol del agente actualizado a {{role}}", "roleUpdateError": "Error al actualizar el rol: {{error}}", "roleUpdateFailed": "Error al actualizar el rol: {{error}}", "roleUpdateSuccess": "Rol del agente actualizado a {{role}}", - "roleUpdated": "Rol del agente actualizado a {{role}}", "runAriaLabel": "Ejecución {{id}}", "runDetailsFailed": "Error al cargar los detalles de ejecución", "runMissedHeartbeat": "Ejecutar heartbeat perdido", "runMissedHeartbeatHint": "Activar una ejecución si el agente pierde un heartbeat programado.", + "running": "En ejecución", "runNow": "Ejecutar ahora", "runNowAria": "Ejecutar ahora para {{name}}", "runNowFor": "Ejecutar ahora para {{name}}", - "runStarted": "Ejecución iniciada", - "runStopped": "Ejecución detenida", - "running": "En ejecución", "runs": { "empty": "Sin ejecuciones aún", "loading": "Cargando ejecuciones…", @@ -890,9 +905,12 @@ "stopMessage": "¿Detener esta ejecución?", "stopTitle": "Detener ejecución" }, - "runsCount": "{{count}} ejecución(es)", + "runsCount_one": "", + "runsCount_other": "", "runsSuccessRate": "{{rate}}% de tasa de éxito", + "runStarted": "Ejecución iniciada", "runsToday": "Ejecuciones hoy", + "runStopped": "Ejecución detenida", "runtime": "Ejecución", "runtimeEmpty": "No hay entornos de ejecución de plugin disponibles", "runtimeLabel": "Entorno de ejecución", @@ -925,29 +943,37 @@ "selectAllAgents": "Seleccionar todos los agentes", "selectAllSkills": "Seleccionar todas las habilidades", "selectAnAgent": "Selecciona un agente", + "selectCompany": "", "selectDirectory": "Seleccionar directorio", + "selected": "Seleccionado:", + "selectedAgentLabel_one": "", + "selectedAgentLabel_other": "", + "selectedSkillLabel_one": "", + "selectedSkillLabel_other": "", "selectMemoryFile": "Seleccionar un archivo de memoria", "selectModel": "Modelo", "selectModelPlaceholder": "Seleccionar un modelo…", "selectRuntime": "Seleccionar un entorno de ejecución", "selectSkill": "Seleccionar habilidad {{name}}", - "selected": "Seleccionado:", - "selectedAgentLabel": "{{count}} Agente{{plural}}", - "selectedSkillLabel": "{{count}} Habilidad{{plural}}", "setHeartbeatAria": "Establecer intervalo de latido para {{name}}", - "settingsSaveFailed": "Error al guardar la configuración", "settingsSaved": "Configuración guardada", + "settingsSaveFailed": "Error al guardar la configuración", "setupModeAriaLabel": "Modo de configuración del agente", "showSystemAgents": "Mostrar agentes del sistema", "skills": "Habilidades", "skillsDescription": "Administre las habilidades disponibles para este agente.", - "skillsErrors": "{{count}} habilidad{{plural}} error{{pluralError}}", - "skillsFound": "{{count}} habilidad{{plural}} encontrada{{plural}}", + "skillsErrors_one": "", + "skillsErrors_other": "", + "skillsFound_one": "", + "skillsFound_other": "", "skillsHint": "Habilidades opcionales para asignar a este agente", - "skillsImported": "{{count}} habilidad{{plural}} importada{{plural}}", + "skillsImported_one": "", + "skillsImported_other": "", "skillsNone": "Sin habilidades asignadas", - "skillsSelected": "{{count}} habilidad seleccionada", - "skillsSkipped": "{{count}} habilidad{{plural}} omitida{{plural}} (ya existe{{plural}})", + "skillsSelected_one": "", + "skillsSelected_other": "", + "skillsSkipped_one": "", + "skillsSkipped_other": "", "skillsTitle": "Habilidades", "skipHeartbeatWhenIdle": "Omitir heartbeat cuando está inactivo", "skipHeartbeatWhenIdleHint": "Evite ejecutar heartbeats cuando el agente no tenga nada que hacer.", @@ -955,23 +981,23 @@ "soulEmptyPreview": "Sin alma aún — cambie al modo edición para agregar una.", "soulHint": "Describa quién es este agente — su carácter, tono y valores.", "soulPlaceholder": "Describe la personalidad y el estilo de comunicación del agente…", - "soulSaveFailed": "Error al guardar el alma", "soulSaved": "Alma guardada", + "soulSaveFailed": "Error al guardar el alma", "soulTitle": "Alma", "soulTooLong": "El contenido del alma es demasiado largo", "start": "Iniciar", - "startOnboarding": "Comenzar incorporación", "starting": "Iniciando...", + "startOnboarding": "Comenzar incorporación", "stateActive": "Activo", "stateAll": "Todos los estados", "stateError": "Error", "stateIdle": "Inactivo", "statePaused": "En pausa", "stateRunning": "En ejecución", + "stateUpdated": "Estado del agente actualizado a {{state}}", "stateUpdateError": "Error al actualizar el estado: {{error}}", "stateUpdateFailed": "Error al actualizar el estado: {{error}}", "stateUpdateSuccess": "Estado del agente actualizado a {{state}}", - "stateUpdated": "Estado del agente actualizado a {{state}}", "status": "Estado", "statusCount": "{{activeCount}} activo · {{runningCount}} en ejecución", "step": "Paso {{number}}{{total}}: {{name}}", @@ -1012,16 +1038,6 @@ "thinkingMinimal": "Mínimo", "thinkingOff": "Desactivado", "throughput": "Rendimiento", - "time": { - "daysAgo": "hace {{count}}d", - "hoursAgo": "hace {{count}}h", - "inAMoment": "en un momento", - "inDays": "en {{count}}d", - "inHours": "en {{count}}h", - "inMinutes": "en {{count}}m", - "justNow": "ahora mismo", - "minutesAgo": "hace {{count}}m" - }, "title": "Agentes", "titleLabel": "Título", "titlePlaceholder": "p. ej. Revisor de código senior", @@ -1070,11 +1086,12 @@ }, "approval": { "dismissBanner": "Descartar pancarta de notificación de aprobación", - "needAttention": "{{count}} {{noun}} de aprobación necesita tu atención", + "needAttention_one": "", + "needAttention_other": "", "openMailbox": "Abrir buzón", "requestPlural": "solicitudes", - "requestSingular": "solicitud", - "requests": "Solicitudes de aprobación" + "requests": "Solicitudes de aprobación", + "requestSingular": "solicitud" }, "auth": { "clearAndRetry": "Borrar token e intentar de nuevo", @@ -1100,9 +1117,12 @@ "confirmMessage": "Esta sesión está activa en otra pestaña. ¿Abrir de todos modos?", "confirmTitle": "Abrir sesión activa", "dismissButton": "Descartar", - "pillLabel": "IA {{count}}", - "pillTitle": "{{count}} tarea de IA en segundo plano", - "pillTitleWithInput": "{{count}} tarea de IA en segundo plano ({{needsInput}} necesita entrada)", + "pillLabel_one": "", + "pillLabel_other": "", + "pillTitle_one": "", + "pillTitle_other": "", + "pillTitleWithInput_one": "", + "pillTitleWithInput_other": "", "popoverHeader": "Tareas de fondo", "status": { "activeElsewhere": "activo en otra pestaña", @@ -1123,10 +1143,19 @@ "done": "Hecho", "inProgress": "En progreso", "inReview": "En revisión", + "rejection": { + "capacityExhausted": "", + "guardRejected": "", + "mergeBlocked": "", + "promoteRejected": "", + "unknownColumn": "", + "workflowMismatch": "" + }, "todo": "Por hacer", "triage": "Triaje" }, "branchGroup": { + "abandonGroup": "", "autoMergeEnabled": "Fusión automática habilitada", "collapseLabel": "Contraer grupo de ramas", "completionText": "{{landed}} de {{total}} miembros completados", @@ -1185,13 +1214,8 @@ "failedToCreateSession": "Error al crear la sesión de chat", "failedToDeleteConversation": "Error al eliminar la conversación", "failedToDeleteRoom": "Error al eliminar el canal", - "failedToGetResponse": "No se pudo obtener respuesta", "failedToSendRoomMessage": "Error al enviar el mensaje al canal", "failureDetails": "Detalles del error", - "failureReferenceId": "ID", - "failureReferenceKind": "Tipo", - "failureReferenceLabel": "Referencia", - "failureReferenceMetaLabel": "Etiqueta", "helpMessageContent": "Comandos disponibles:\n- `/new` o `/clear` — Borrar la conversación y empezar de nuevo\n- `/skill:{name}` — Usar una habilidad específica\n- `/help` — Mostrar esta ayuda", "jumpToLatest": "Más reciente", "latest": "Últimos", @@ -1222,14 +1246,16 @@ "noRoomsYet": "Aún no hay canales.", "noSkillsAvailable": "No hay habilidades disponibles", "noSkillsFound": "No se encontraron habilidades", - "openMailboxMessage": "Abrir mensaje del buzón", "openQuickChat": "Abrir chat rápido", "queuedMessage": "En cola: {{preview}}", "quickChatTitle": "Chat rápido", - "relativeTimeDays": "hace {{count}} d", - "relativeTimeHours": "hace {{count}} h", + "relativeTimeDays_one": "", + "relativeTimeDays_other": "", + "relativeTimeHours_one": "", + "relativeTimeHours_other": "", "relativeTimeJustNow": "ahora mismo", - "relativeTimeMinutes": "hace {{count}} min", + "relativeTimeMinutes_one": "", + "relativeTimeMinutes_other": "", "removeAttachment": "Eliminar {{name}}", "resizePanelBottom": "Redimensionar panel desde abajo", "resizePanelBottomLeft": "Redimensionar panel desde la esquina inferior izquierda", @@ -1242,7 +1268,8 @@ "resizeSidebar": "Redimensionar barra lateral", "responseCopied": "Respuesta copiada", "responseFailed": "La respuesta falló", - "roomMemberCount": "{{count}} miembro", + "roomMemberCount_one": "", + "roomMemberCount_other": "", "roomsGroupLabel": "Canales", "scopeDirect": "Directo", "scopeRooms": "Canales", @@ -1269,19 +1296,12 @@ "thinking": "Pensando", "thinkingLabel": "Pensando", "thinkingStatus": "Pensando…", - "toolCallArgsPrefix": "args", - "toolCallResultPrefix": "resultado", - "toolCallStatusCompleted": "completado", - "toolCallStatusError": "error", - "toolCallStatusErrors": "errores", - "toolCallStatusRunning": "ejecutando", "toolCalls": "Llamadas a herramientas", - "toolCallsCount": "{{count}} llamadas a herramientas", - "toolCallsHeader": "Llamadas a herramientas", + "toolCallsCount_one": "", + "toolCallsCount_other": "", "typeMessage": "Escribe un mensaje...", "unreadMessages": "Mensajes no leídos", "untitledSession": "Sin título", - "viewFailureDetails": "Ver detalles del error", "you": "Tú" }, "chatRooms": { @@ -1295,8 +1315,8 @@ "cli": { "installBody": "Obtén los comandos {{fn}} y {{fusion}} en tu terminal para poder conducir Fusion desde cualquier lugar. Un clic abajo o copia el comando en tu shell.", "installButton": "Instalar con npm", - "installTitle": "Instalar el CLI de Fusion", "installing": "Instalando…", + "installTitle": "Instalar el CLI de Fusion", "openSettings": "Abrir configuración", "updateButton": "Actualizar con npm", "updateTitle": "Actualizar el CLI de Fusion", @@ -1312,8 +1332,8 @@ "failedExit": "Instalación fallida (código de salida {{code}})", "heading": "Binario CLI", "help": "Instalar la CLI global le permite ejecutar fn y fusion desde cualquier terminal. Las automatizaciones y scripts funcionan sin ella a través de npx, pero una instalación global es más rápida y conveniente.", - "installWithNpm": "Instalar con npm", "installing": "Instalando…", + "installWithNpm": "Instalar con npm", "notOnPath": "Ni fn ni fusion se encontraron en PATH.", "orCopyLabel": "O copie y ejecute usted mismo:", "refresh": "Actualizar", @@ -1329,9 +1349,11 @@ "actionsTitle": "Acciones de columna", "archiveAllDoneAriaLabel": "Archivar todas las tareas completadas", "archiveAllDoneTitle": "Archivar todas las tareas completadas", - "archiveAllMessage": "¿Archivar todas las {{count}} tareas completadas?", + "archiveAllMessage_one": "", + "archiveAllMessage_other": "", "archiveAllTitle": "Archivar todo completado", - "archivedTasks": "Se archivaron {{count}} tareas", + "archivedTasks_one": "", + "archivedTasks_other": "", "autoMerge": "Fusión automática", "autoMergeDisabled": "Fusión automática desactivada", "autoMergeEnabled": "Fusión automática activada", @@ -1342,26 +1364,36 @@ "expandArchivedTitle": "Expandir tareas archivadas", "failedToArchive": "Error al archivar tareas", "keepProgress": "Preservar progreso", - "loadMore": "Cargar {{count}} más ({{remaining}} restantes)", + "loadMore_one": "", + "loadMore_other": "", "moveAllToTodo": "Mover todo a Tareas pendientes", - "moveAllToTodoMessage": "¿Mover todas las {{count}} tareas {{columnLabel}}{{plural}} a Tareas pendientes?", + "moveAllToTodoMessage_one": "", + "moveAllToTodoMessage_other": "", "moveAllToTodoTitle": "Mover todo a Tareas pendientes", + "movedToPlanning_one": "", + "movedToPlanning_other": "", + "movedToTodo_one": "", + "movedToTodo_other": "", "movePartialFailure": "Se movieron {{moved}} de {{total}} tareas; {{failed}} fallaron", - "moveToTodoHint": "Mover {{count}} tareas{{plural}} a Tareas pendientes", + "moveToTodoHint_one": "", + "moveToTodoHint_other": "", "moveToTodoPartialFailure": "Se movieron {{moved}} de {{total}} tareas a Tareas pendientes; {{failed}} fallaron", - "movedToPlanning": "Se movieron {{count}} tareas{{plural}} a planificación para replanificación", - "movedToTodo": "Se movieron {{count}} tareas{{plural}} a Tareas pendientes", "newTask": "Nueva tarea", "noManuallyPausableTasks": "Sin tareas pausables manualmente", "noTasks": "Sin tareas", "noTasksInColumn": "Sin tareas en esta columna", - "pauseHint": "Pausar {{count}} tareas activas no asignadas{{plural}}", + "pauseHint_one": "", + "pauseHint_other": "", "preserveProgressMessage": "Esta tarea tiene pasos completados. ¿Preservar progreso antes de mover?", "preserveProgressMoveTodoMessage": "Algunas tareas tienen pasos completados. ¿Preservar progreso antes de mover a Tareas pendientes?", "preserveProgressTitle": "¿Preservar progreso?", + "promote": "", + "promoting": "", "replanAll": "Replanificar todo", - "replanAllHint": "Mover {{count}} tareas{{plural}} a Planificación", - "replanAllMessage": "¿Mover todas las {{count}} tareas por hacer{{plural}} a planificación para ser replanificadas?", + "replanAllHint_one": "", + "replanAllHint_other": "", + "replanAllMessage_one": "", + "replanAllMessage_other": "", "replanAllTitle": "Replanificar todas las tareas", "resetProgress": "Restablecer progreso", "resetProgressConfirm": "Restablecer progreso", @@ -1369,10 +1401,12 @@ "resetProgressMoveTodoMessage": "¿Restablecer el progreso de pasos de las tareas antes de mover a Tareas pendientes?", "resetProgressTitle": "¿Restablecer progreso?", "stopAll": "Detener todo", - "stopAllMessage": "¿Detener todas las {{count}} tareas {{columnLabel}}{{plural}}?", + "stopAllMessage_one": "", + "stopAllMessage_other": "", "stopAllTitle": "Detener todas las tareas", "stopPartialFailure": "Se detuvieron {{paused}} de {{total}} tareas; {{failed}} fallaron", - "stoppedTasks": "Se detuvieron {{count}} tareas{{plural}}" + "stoppedTasks_one": "", + "stoppedTasks_other": "" }, "comments": { "addButton": "Añadir comentario", @@ -1387,7 +1421,8 @@ "updatedSuccess": "Comentario actualizado" }, "commit": { - "filesChanged": "Archivos modificados ({{count}})" + "filesChanged_one": "", + "filesChanged_other": "" }, "commitDiff": { "error": "Error al cargar la diferencia de commit: {{error}}", @@ -1398,6 +1433,7 @@ "noSha": "No hay SHA de commit disponible." }, "common": { + "archive": "", "back": "Volver", "cancel": "Cancelar", "close": "Cerrar", @@ -1419,11 +1455,13 @@ "save": "Guardar", "saveAndTest": "Guardar y probar", "saving": "Guardando…", + "skip": "", "somethingWentWrong": "Algo salió mal al cargar esta vista.", "stop": "Detener", "test": "Probar", "testing": "Probando…", "total": "total", + "tryAgain": "", "unableToLoadData": "No se pueden cargar los datos", "unknown": "Desconocido", "unsavedChanges": "Cambios sin guardar", @@ -1436,8 +1474,8 @@ "messagePlaceholder": "Escriba su mensaje…", "newMessageTitle": "Nuevo mensaje", "noAgentsAvailable": "No hay agentes disponibles", - "replyTitle": "Responder", "replyingToLabel": "Respondiendo a:", + "replyTitle": "Responder", "selectAgent": "Seleccionar agente…", "sendingButton": "Enviando…", "toLabel": "Para:", @@ -1464,30 +1502,19 @@ "createRoom": { "create": "Crear sala", "creating": "Creando...", - "duplicate": "Ya existe una sala con este nombre.", "failedCreate": "Error al crear la sala.", "failedLoadAgents": "Error al cargar los agentes.", "loadingAgents": "Cargando agentes...", - "lowercase": "Use solo letras minúsculas.", - "maxLength": "Los nombres de sala pueden tener un máximo de 80 caracteres.", "members": "Miembros", "nameLabel": "Nombre de sala", - "nameRequired": "Se requiere el nombre de la sala.", "noAgents": "Aún no hay agentes en este proyecto.", - "noEdgeChars": "Los nombres de sala no pueden comenzar ni terminar con un guión o guion bajo.", "noMatch": "Ningún agente coincide con tu búsqueda.", "searchAgents": "Buscar agentes", "selectMember": "Selecciona al menos un miembro.", - "title": "Crear sala", - "validChars": "Use solo letras minúsculas, números, guiones o guiones bajos." + "title": "Crear sala" }, "dashboard": { "initializingDashboard": "Inicializando panel...", - "loaderSteps": { - "project": "Seleccionando proyecto", - "projects": "Cargando proyectos", - "tasks": "Obteniendo tareas" - }, "loadingMessage": "Cargando panel de Fusion", "loadingProgress": "Progreso de carga del panel", "updatingMessage": "Actualizando panel de Fusion", @@ -1537,15 +1564,18 @@ "filterBySeverity": "Filtrar registros por severidad", "info": "Información", "lines": "líneas", - "loadOlderLogs": "Cargar registros más antiguos", + "lines_one": "", + "lines_other": "", "loading": "Cargando...", "loadingConfig": "Cargando configuración del servidor de desarrollo...", "loadingLogs": "Cargando registros…", "loadingOlderLogs": "Cargando registros más antiguos…", + "loadOlderLogs": "Cargar registros más antiguos", "logs": "Registros", "lostConnection": "Conexión de flujo de registro perdida.", "manual": "Manual", - "matchCount": "{{count}} coincidencia", + "matchCount_one": "", + "matchCount_other": "", "newLogs": "Nuevos registros", "noLogsYet": "Sin registros aún. Inicia el servidor de desarrollo para ver la salida.", "noMatchesSearch": "Ninguna línea de registro coincide con tu búsqueda.", @@ -1712,7 +1742,8 @@ "clearSearch": "Borrar búsqueda", "collapse": "Contraer", "collapseContent": "Contraer contenido", - "docCount": "{{count}} doc{{plural}}", + "docCount_one": "", + "docCount_other": "", "documentsCreatedIn": "Los documentos se crean en las pestañas de detalle de tareas.", "expand": "Expandir", "expandContent": "Expandir contenido", @@ -1732,7 +1763,8 @@ "plain": "Bruto", "projectFiles": "archivos del proyecto", "projectFilesTab": "Archivos del proyecto", - "resultCount": "{{count}} resultado{{plural}}", + "resultCount_one": "", + "resultCount_other": "", "retry": "Reintentar", "retryLoading": "Reintentar cargar documentos", "searchProjectFiles": "Buscar archivos markdown del proyecto…", @@ -1811,21 +1843,26 @@ }, "executor": { "blocked": "Bloqueado", - "daysAgo": "hace {{count}}d", + "daysAgo_one": "", + "daysAgo_other": "", "escalated": "Escalado", "escalatedSuffix": " (escalado)", "hideProjectDir": "Ocultar directorio del proyecto", - "hoursAgo": "hace {{count}}h", + "hoursAgo_one": "", + "hoursAgo_other": "", "inReview": "En revisión", "justNow": "hace un momento", "loading": "Cargando...", - "minutesAgo": "hace {{count}}m", + "minutesAgo_one": "", + "minutesAgo_other": "", "noActivity": "sin actividad", - "overlapBottleneck": "Cuello de botella de superposición {{status}} {{blockerId}}: {{count}} tareas bloqueadas a través de blockedBy (umbral {{threshold}})", + "overlapBottleneck_one": "", + "overlapBottleneck_other": "", "overlapQueue": "Cola de superposición", "queued": "En cola", "running": "En ejecución", - "secondsAgo": "hace {{count}}s", + "secondsAgo_one": "", + "secondsAgo_other": "", "showProjectDir": "Mostrar directorio del proyecto", "stateIdle": "Inactivo", "statePaused": "En pausa", @@ -1906,8 +1943,10 @@ "advancesHelpItem2": "reachable / subsumed / orphaned / superseded — ya gestionado (incluidas las reescrituras de historial donde el contenido equivalente ya llegó, los SHAs originales desaparecieron o HEAD ya está alineado con la punta de integración reescrita).", "advancesHelpItem3": "pending + off / not run — la sincronización automática está deshabilitada en Configuración; la referencia de la rama se movió pero tu árbol de trabajo no la siguió.", "advancesHelpItem4": "pending + stash-failed / would-conflict / similar — la sincronización automática lo intentó pero no pudo reconciliar (generalmente las ediciones locales colisionan con el nuevo commit).", - "advancesNeedAction": "{{count}} requiere(n) acción", - "aheadOfUpstream": "{{count}} commit(s) por delante del upstream", + "advancesNeedAction_one": "", + "advancesNeedAction_other": "", + "aheadOfUpstream_one": "", + "aheadOfUpstream_other": "", "aligned": "Alineado", "apply": "Aplicar", "applyStashKeep": "Aplicar stash (conservar)", @@ -1923,7 +1962,8 @@ "backToIssuesList": "Volver a la lista de issues", "backToPullsList": "Volver a la lista de pull requests", "baseHead": "Base: HEAD", - "behindUpstream": "{{count}} commit(s) por detrás del upstream", + "behindUpstream_one": "", + "behindUpstream_other": "", "branchLabel": "Rama:", "cancel": "Cancelar", "capturedAt": "Capturado:", @@ -1936,16 +1976,20 @@ "commentLast": "Último:", "commit": "Confirmar", "commitMessagePlaceholder": "Mensaje del commit…", - "commitStagedChanges": "Confirmar cambios preparados", "commitsOnBranch": "Commits en {{name}}", - "commitsToPull": "{{count}} por traer", - "commitsToPush": "{{count}} por enviar", - "commitsToPushHeader": "Commits por enviar ({{count}})", + "commitStagedChanges": "Confirmar cambios preparados", + "commitsToPull_one": "", + "commitsToPull_other": "", + "commitsToPush_one": "", + "commitsToPush_other": "", + "commitsToPushHeader_one": "", + "commitsToPushHeader_other": "", "committedHash": "Confirmado: {{hash}}", + "conflictedCount_one": "", + "conflictedCount_other": "", "conflictReclaimFailed": "Error al poner en cola la recuperación del conflicto", "conflictReclaimQueued": "Recuperación de conflicto en cola", "conflictReclaimUnavailable": "Recuperación de conflicto no disponible", - "conflictedCount": "{{count}} en conflicto", "conflictsButton": "Conflictos", "copiedButton": "Copiado", "copiedLabel": "Copiado {{label}}", @@ -1962,9 +2006,9 @@ "couldNotLoadIssues": "No se pudieron cargar los issues", "couldNotLoadPulls": "No se pudieron cargar los pull requests", "create": "Crear", + "createdBranch": "Rama {{name}} creada", "createPrButton": "Crear PR", "createPrTitle": "Crear una PR para esta tarea", - "createdBranch": "Rama {{name}} creada", "defaultBadge": "predeterminado", "deleteBranch": "Eliminar", "deleteBranchMessage": "¿Eliminar la rama «{{name}}»?", @@ -1972,10 +2016,12 @@ "deletedBranch": "Rama {{name}} eliminada", "detectingRemotes": "Detectando…", "diffColon": "diff:", - "discardChangesMessage": "¿Descartar los cambios de {{count}} archivo(s)? Esta acción no se puede deshacer.", + "discardChangesMessage_one": "", + "discardChangesMessage_other": "", "discardChangesTitle": "Descartar cambios", + "discardedFiles_one": "", + "discardedFiles_other": "", "discardSelected": "Descartar selección", - "discardedFiles": "Cambios de {{count}} archivo(s) descartados", "dismiss": "Descartar", "dismissPrError": "Cerrar error de PR", "dropStash": "Eliminar stash", @@ -2011,9 +2057,9 @@ "fetch": "Fetch", "fetchCompleted": "Fetch completado", "fetchFailed": "Error en el fetch", + "fetchingFromGitHub": "Obteniendo la lista más reciente de GitHub.", "fetchLabel": "Fetch:", "fetchUrlLabel": "URL de fetch", - "fetchingFromGitHub": "Obteniendo la lista más reciente de GitHub.", "filterBranches": "Filtrar ramas…", "filterByLabelsLabel": "Filtrar por etiquetas", "filterByLabelsPlaceholder": "Filtrar: bug,enhancement…", @@ -2025,24 +2071,27 @@ "forceDeletedBranch": "Rama {{name}} eliminada forzosamente", "fullShaAbbrev": "completo", "ghAuthLoginHint": "Ejecuta {{code}} para habilitar la creación de PR.", - "headAheadOfIntegration": "HEAD tiene {{count}} commit(s) que no están en {{branch}}", - "headAheadOfOriginIntegration": "HEAD tiene {{count}} commit(s) que no están en origin/{{branch}}", + "headAheadOfIntegration_one": "", + "headAheadOfIntegration_other": "", + "headAheadOfOriginIntegration_one": "", + "headAheadOfOriginIntegration_other": "", "headVsIntegration": "HEAD vs {{branch}}", "headVsOriginIntegration": "HEAD vs origin/{{branch}}", "hide": "Ocultar", "hideExplanation": "Ocultar explicación", "import": "Importar", + "imported": "Importado", + "importedCount_one": "", + "importedCount_other": "", "importFromGitHub": "Importar desde GitHub", "importSubtitle": "Elige un remoto detectado, carga issues o pull requests abiertos e importa uno al tablero.", "importTypeAriaLabel": "Tipo de importación", - "imported": "Importado", - "importedCount": "{{count}} importado", - "integrationAheadOfHead": "{{branch}} tiene {{count}} commit(s) que HEAD no tiene", - "issueCount": "{{count}} issue", + "integrationAheadOfHead_one": "", + "integrationAheadOfHead_other": "", + "issueCount_one": "", + "issueCount_other": "", "load": "Cargar", "loadFromRepoAriaLabel": "Cargar {{tab}} desde el repositorio", - "loadMoreCommits": "Cargar más commits", - "loadTabTitle": "Cargar {{tab}}", "loading": "Cargando…", "loadingAriaLabel": "Cargando {{tab}}", "loadingCommits": "Cargando commits…", @@ -2051,23 +2100,28 @@ "loadingPulls": "Cargando pull requests abiertos…", "loadingStashDiff": "Cargando diff del stash…", "loadingTitle": "Cargando…", - "localAheadOfOriginIntegration": "{{branch}} local va {{count}} commit(s) por delante de origin/{{branch}}", - "localBehindOriginIntegration": "{{branch}} local va {{count}} commit(s) por detrás de origin/{{branch}}", + "loadMoreCommits": "Cargar más commits", + "loadTabTitle": "Cargar {{tab}}", + "localAheadOfOriginIntegration_one": "", + "localAheadOfOriginIntegration_other": "", + "localBehindOriginIntegration_one": "", + "localBehindOriginIntegration_other": "", "localVsOrigin": "{{branch}} local vs origin", "manualPrFlowHint": "Usa la acción del pie de página para ejecutar la finalización PR-first para esta tarea.", "mergeBadge": "fusión", "mergeConflictDetected": "Conflicto de fusión detectado. Resuélvelo manualmente.", + "mergedTaskDone": "Fusionada — tarea movida a Hecho", "mergeLabel": "Fusión", "mergePrButton": "Fusionar pull request", "mergeStrategyMerge": "combinar", "mergeStrategyRebase": "reorganizar", "mergeStrategySquash": "aplastar", - "mergedTaskDone": "Fusionada — tarea movida a Hecho", "mergingPrHint": "Fusionando pull request…", "mergingStatus": "Fusionando…", "modalTitle": "Gestor de Git", "modified": "Modificado", - "modifiedCount": "{{count}} modificado(s)", + "modifiedCount_one": "", + "modifiedCount_other": "", "newBranchName": "Nombre de la nueva rama", "noAheadCommitsFound": "No se encontraron commits por delante (puede que necesites hacer fetch primero)", "noBranchesFound": "No se encontraron ramas", @@ -2081,9 +2135,7 @@ "noMatchingBranches": "Sin ramas coincidentes", "noMatchingCommits": "Sin commits coincidentes", "noOpenIssues": "No se encontraron issues abiertos", - "noOpenIssuesFound": "No se encontraron issues abiertos", "noOpenPulls": "No se encontraron pull requests abiertos", - "noOpenPullsFound": "No se encontraron pull requests abiertos", "noOriginTracking": "sin seguimiento de origin", "noPullSelected": "Ningún pull request seleccionado", "noPullSelectedHint": "Elige un pull request de la lista para inspeccionar sus detalles.", @@ -2097,14 +2149,15 @@ "noStagedChanges": "Sin cambios preparados", "noStagedChangesToCommit": "Sin cambios preparados para confirmar", "noStashes": "Sin stashes", - "noUnstagedChanges": "Sin cambios sin preparar", + "nothingLoadedInstructions": "Selecciona un repositorio y haz clic en Cargar para empezar a revisar los candidatos de importación.", + "nothingLoadedYet": "Nada cargado aún", "notOnIntegrationBranch": "(no en {{branch}})", "notOnIntegrationBranchBtn": "No en la rama de integración ({{branch}})", "notOnIntegrationBranchTitle": "Actualmente en una rama no de integración", - "nothingLoadedInstructions": "Selecciona un repositorio y haz clic en Cargar para empezar a revisar los candidatos de importación.", - "nothingLoadedYet": "Nada cargado aún", + "noUnstagedChanges": "Sin cambios sin preparar", "openPullsFrom": "Pull requests abiertos de {{remote}}", - "originIntegrationAheadOfHead": "origin/{{branch}} tiene {{count}} commit(s) que HEAD no tiene", + "originIntegrationAheadOfHead_one": "", + "originIntegrationAheadOfHead_other": "", "pop": "Desapilar", "popStashTitle": "Desapilar stash (aplicar y eliminar)", "prAuthUnavailable": "Autenticación de PR no disponible — ejecuta 'gh auth login'", @@ -2116,34 +2169,36 @@ "summary": "{{passing}} pasando, {{failing}} fallando, {{pending}} pendiente", "viewDetails": "Ver detalles" }, - "prMergeFailed": "Error al fusionar la pull request", + "previewHeading": "Vista previa", + "previewIssueMeta": "Issue #{{number}}", + "previewPullMeta": "Pull Request #{{number}}", "prMerged": "Pull request fusionada", + "prMergeFailed": "Error al fusionar la pull request", + "projectRootNotAvailable": "Ruta raíz del proyecto no disponible", "prRefreshFailed": "Error al actualizar la PR", "prStatusRefreshed": "Estado de la PR actualizado", "prUnlinkConfirm": "¿Desvincular la PR #{{number}} de esta tarea? La PR no se cerrará.", "prUnlinked": "PR #{{number}} desvinculada", - "previewHeading": "Vista previa", - "previewIssueMeta": "Issue #{{number}}", - "previewPullMeta": "Pull Request #{{number}}", - "projectRootNotAvailable": "Ruta raíz del proyecto no disponible", "pull": "Pull", "pullCompleted": "Pull completado", - "pullCount": "{{count}} pull request", + "pullCount_one": "", + "pullCount_other": "", "pullFailed": "Error en el pull", "pullOptions": "Opciones de pull", "pullOptionsMenu": "Menú de opciones de pull", "pullRebase": "Pull --rebase", "pullRebaseCompleted": "Pull --rebase completado", "pullRequestHeading": "Pull request", - "pullRequestsCount": "{{count}} pull requests", + "pullRequestsCount_one": "", + "pullRequestsCount_other": "", "push": "Push", "pushCompleted": "Push completado", "pushFailed": "Error en el push", "pushLabel": "Push:", "pushUrlLabel": "URL de push", - "reCheckConflicts": "Volver a verificar conflictos", "recentCommitsOnRemote": "Commits recientes en {{remote}}", "recentIntegrationAdvances": "Avances recientes en la rama de integración", + "reCheckConflicts": "Volver a verificar conflictos", "refresh": "Actualizar", "refreshPrStatus": "Actualizar estado de la PR", "refreshToCheckMerge": "Actualizar estado de la PR para comprobar si está lista para fusionarse", @@ -2176,24 +2231,28 @@ "sectionStashes": "Stashes", "sectionStatus": "Estado", "sectionWorktrees": "Worktrees", + "selectedRemote": "remoto seleccionado", "selectFileToViewDiff": "Selecciona un archivo para ver su diff", "selectIssueAriaLabel": "Seleccionar issue #{{number}}", "selectPullAriaLabel": "Seleccionar pull request #{{number}}", "selectRemoteAriaLabel": "Seleccionar remoto de Git", "selectRemotePlaceholder": "Seleccionar remoto…", "selectRemoteToViewDetails": "Selecciona un remoto para ver detalles", - "selectedRemote": "remoto seleccionado", "sidebarAriaLabel": "Secciones del gestor de Git", "stageAll": "Preparar todo", "stageAllAndCommit": "Preparar todo y confirmar", "stageAllAndCommitTitle": "Preparar todo y confirmar", - "stageCount": "Preparar ({{count}})", + "stageCount_one": "", + "stageCount_other": "", + "staged": "Preparado", + "stagedChanges_one": "", + "stagedChanges_other": "", + "stagedCount_one": "", + "stagedCount_other": "", + "stagedFiles_one": "", + "stagedFiles_other": "", "stageFile": "Preparar archivo", "stageSelected": "Preparar selección", - "staged": "Preparado", - "stagedChanges": "Cambios preparados ({{count}})", - "stagedCount": "{{count}} preparado(s)", - "stagedFiles": "{{count}} archivo(s) preparado(s)", "staleIndexWarning": "Se detectó un índice obsoleto. HEAD ha avanzado (normalmente porque el merger de Fusion actualizó la referencia de la rama de integración) pero el índice todavía refleja la punta anterior — `git status` reportará los nuevos commits invertidos como \"cambios preparados\". Habilita mergeAdvanceAutoSync en Configuración para que el merger reconcilie automáticamente, o ejecuta git reset --hard HEAD para avanzar manualmente.", "stash": "Guardar en stash", "stashApplied": "Stash aplicado", @@ -2220,17 +2279,17 @@ "statusLabelWorkingTree": "Árbol de trabajo", "switchedToBranch": "Cambiado a {{name}}", "sync": "Sincronizar", + "synced": "Sincronizado", + "syncedWithOrigin": "Sincronizado con origin (pull --rebase + push)", + "syncedWorktreeToIntegrationTip": "Árbol de trabajo sincronizado con la punta de integración local", "syncFailed": "Error en la sincronización", + "syncing": "Sincronizando…", "syncLocalTip": "Sincronizar punta local", "syncLocalTipTitle": "Sincronizar árbol de trabajo con la punta de integración local (igual que el Pull del banner)", "syncOriginTitle": "Pull --rebase desde origin, luego enviar la rama actual", "syncWithOriginFailed": "Error al sincronizar con origin", "syncWorkingTree": "Sincronizar árbol de trabajo", "syncWorkingTreeTitle": "Incorporar la rama de integración al árbol de trabajo (guarda y restaura automáticamente las ediciones sin confirmar)", - "synced": "Sincronizado", - "syncedWithOrigin": "Sincronizado con origin (pull --rebase + push)", - "syncedWorktreeToIntegrationTip": "Árbol de trabajo sincronizado con la punta de integración local", - "syncing": "Sincronizando…", "tabIssues": "Issues", "tabPullRequests": "Pull Requests", "tip": "punta", @@ -2239,14 +2298,18 @@ "unlinkButton": "Desvincular", "unresolvedMergeConflicts": "Conflictos de fusión sin resolver", "unstageAll": "Desindexar todo", - "unstageCount": "Desindexar ({{count}})", + "unstageCount_one": "", + "unstageCount_other": "", + "unstaged": "Sin preparar", + "unstagedChanges_one": "", + "unstagedChanges_other": "", + "unstagedFiles_one": "", + "unstagedFiles_other": "", "unstageFile": "Desindexar archivo", "unstageSelected": "Desindexar selección", - "unstaged": "Sin preparar", - "unstagedChanges": "Cambios sin preparar ({{count}})", - "unstagedFiles": "{{count}} archivo(s) desindexado(s)", "untracked": "Sin seguimiento", - "untrackedCount": "{{count}} sin seguimiento", + "untrackedCount_one": "", + "untrackedCount_other": "", "upToDate": "Al día", "view": "Ver", "viewOnGithub": "Ver en GitHub", @@ -2256,18 +2319,21 @@ "workingTreeModified": "Modificado", "worktreeBadgeBare": "bare", "worktreeBadgeMain": "principal", - "worktreesInUse": "{{count}} en uso", - "worktreesTotal": "{{count}} en total" + "worktreesInUse_one": "", + "worktreesInUse_other": "", + "worktreesTotal_one": "", + "worktreesTotal_other": "" }, "goals": { - "activeCount": "{{count}} objetivos activos", + "activeCount_one": "", + "activeCount_other": "", "addGoal": "Añadir objetivo", "archive": "Archivar", "capError": "No se pueden activar más de 5 objetivos. Resuelve un objetivo activo antes de activar otro.", "capWarning": "Se aproxima al límite de 5 objetivos activos. Mantén los objetivos activos enfocados.", "createError": "No se puede crear el objetivo ahora. Por favor, intenta de nuevo.", - "draftWithAi": "Redactar con IA", "drafting": "Redactando…", + "draftWithAi": "Redactar con IA", "emptyState": "Sin objetivos aún. Añade uno para comenzar a rastrear resultados estratégicos.", "labelDescription": "Descripción", "labelTitle": "Título", @@ -2281,6 +2347,7 @@ "updateError": "No se puede actualizar el estado del objetivo ahora. Por favor, intenta de nuevo." }, "groupTask": { + "abandonGroup": "", "ariaLabel": "Detalles del grupo de ramas", "autoMergeEnabled": "Fusión automática habilitada", "completionText": "{{landed}} de {{total}} miembros terminados", @@ -2290,12 +2357,16 @@ "mergeIntoMain": "Combinar grupo en principal", "openPR": "Abrir PR", "openTask": "Abrir tarea", + "prClosed": "", + "prMerged": "", "sharedBranch": "Rama compartida", "status": "Estado", "title": "Grupo de ramas {{id}}", "unavailable": "Grupo de ramas no disponible" }, "header": { + "activePlanningSessions_one": "", + "activePlanningSessions_other": "", "addFirstScript": "Agregar tu primer script", "additionalHeaderActions": "Acciones adicionales de encabezado", "agentsView": "Vista agentes", @@ -2321,7 +2392,8 @@ "localNode": "Local", "mailbox": "Buzón", "mailboxView": "Vista buzón", - "mailboxWithCount": "Buzón ({{count}})", + "mailboxWithCount_one": "", + "mailboxWithCount_other": "", "manageProjects": "Administrar proyectos", "manageScripts": "Administrar scripts…", "memoryView": "Memoria", @@ -2330,10 +2402,10 @@ "moreHeaderActions": "Más acciones de encabezado", "moreViews": "Más vistas", "noBaseBranch": "Sin rama base", + "nodes": "Nodos", "noScriptsAddOne": "Sin scripts — agregar uno…", "noScriptsConfigured": "Sin scripts configurados", "noWorkingBranch": "Sin rama de trabajo", - "nodes": "Nodos", "openSearch": "Abrir búsqueda", "openTerminal": "Abrir terminal", "pauseTriage": "Pausar clasificación", @@ -2343,7 +2415,8 @@ "reliabilityView": "Fiabilidad", "researchView": "Investigación", "resumePlanningSession": "Reanudar sesión de planificación", - "resumePlanningSessionCount": "Reanudar sesión de planificación ({{count}})", + "resumePlanningSessionCount_one": "", + "resumePlanningSessionCount_other": "", "resumeScheduling": "Reanudar programación", "scripts": "Scripts", "scriptsSubmenu": "Submenú de scripts", @@ -2364,7 +2437,8 @@ "terminal": "Terminal", "todosView": "Tareas pendientes", "unreadChatResponse": "Respuesta de chat no leída", - "unreadMessages": "{{count}} mensajes sin leer", + "unreadMessages_one": "", + "unreadMessages_other": "", "viewActivityLog": "Ver registro de actividad", "viewProjects": "Ver proyectos", "viewUsage": "Ver uso", @@ -2373,12 +2447,6 @@ }, "health": { "activeTasks": "Tareas activas", - "anomaly": { - "duplicateActiveId": "ID de tarea activa duplicado", - "idInBothStorages": "ID de tarea presente en almacenamiento activo y archivado", - "sequenceOverlap": "La siguiente secuencia del asignador se superpone con un ID de tarea existente", - "unknownPrefix": "La fila de tarea usa un prefijo fuera del estado del asignador" - }, "anomalyBody": "Fusion encontró un estado del asignador que puede causar que los ID de tarea se reutilicen u sobrescriban registros de tarea activos.", "anomalyDetected": "Se detectó anomalía de integridad de ID de tarea", "completed": "Completado", @@ -2436,26 +2504,23 @@ "collapse": "Contraer", "collapseDescription": "Contraer descripción", "collapseTaskOptions": "Contraer opciones de tarea avanzadas", - "connecting": "Conectando", "creating": "Creando...", "custom": "Personalizado", "deps": "Dependencias", "editingDescription": "Descripción de edición", "enableBrowserVerification": "Habilitar paso de flujo de verificación del navegador", "enterDescriptionFirst": "Ingrese una descripción primero", - "error": "Error", "expand": "Expandir", "expandDescription": "Expandir descripción", "expandTaskOptions": "Expandir opciones de tarea avanzadas", "hintEnterEsc": "Presione Intro para crear · Esc para cancelar", "loadingAgents": "Cargando agentes...", - "model": "modelo", + "model_one": "", + "model_other": "", "models": "Modelos", "noAgentsAvailable": "Sin agentes disponibles", - "noExistingTasks": "Sin tareas existentes", "node": "Nodo", - "offline": "Desconectado", - "online": "En línea", + "noExistingTasks": "Sin tareas existentes", "openPlanningMode": "Abrir modo de planificación con descripción actual", "plan": "Planificar", "preset": "Predefinido", @@ -2475,39 +2540,22 @@ "allInsights": "Toda la información", "alreadyRunning": "La generación de información ya se está ejecutando. Mostrando la ejecución activa.", "alreadyRunningShort": "La generación de información ya se está ejecutando", - "archiveLabel": "Archivar esta información", - "archiveTitle": "Archivar esta información", "archived": "\"{{title}}\" archivado", "archivedMsg": "Información archivada: {{title}}", + "archiveLabel": "Archivar esta información", + "archiveTitle": "Archivar esta información", "archiving": "Archivando \"{{title}}\"...", "backlogHealth": "Salud del trabajo pendiente", - "category": { - "architecture": "Arquitectura", - "competitive_analysis": "Análisis competitivo", - "dependency": "Dependencias", - "documentation": "Documentación", - "features": "Características", - "other": "Otro", - "performance": "Rendimiento", - "quality": "Calidad", - "reliability": "Fiabilidad", - "research": "Investigación", - "security": "Seguridad", - "testability": "Capacidad de prueba", - "trends": "Tendencias", - "ux": "Experiencia de usuario", - "workflow": "Flujo de trabajo" - }, "configureModel": "Configurar el modelo de generación de información", "configureModelTitle": "Configurar modelo", "createTaskLabel": "Crear tarea desde esta información", "createTaskTitle": "Crear tarea desde esta información", "creatingTask": "Creando tarea desde \"{{title}}\"...", - "dismissLabel": "Rechazar esta información", - "dismissTitle": "Rechazar esta información", "dismissed": "\"{{title}}\" rechazado", "dismissedMsg": "Información rechazada: {{title}}", "dismissing": "Rechazando \"{{title}}\"...", + "dismissLabel": "Rechazar esta información", + "dismissTitle": "Rechazar esta información", "failedToArchive": "Error al archivar la información", "failedToCreateTask": "Error al crear la tarea", "failedToDismiss": "Error al rechazar la información", @@ -2516,6 +2564,7 @@ "failedToUnarchive": "Error al desarchivar la información", "generateDescription": "Genere información para obtener recomendaciones impulsadas por IA para su proyecto.", "generateFirst": "Generar primera información", + "generateInsights": "", "generateInsightsBtn": "Generar información", "generating": "Generando...", "generatingInsights": "Generando información...", @@ -2531,16 +2580,17 @@ "runCompleted": "{{created}} creado(s), {{updated}} actualizado(s)", "showAllInsights": "Mostrar toda la información", "showArchived": "Mostrar información archivada", - "showArchivedLabel": "Mostrar archivados ({{count}})", + "showArchivedLabel_one": "", + "showArchivedLabel_other": "", "showBacklogHealth": "Mostrar solo información de salud del trabajo pendiente", "taskCreated": "Tarea creada desde \"{{title}}\"", "taskCreatedMsg": "Tarea creada: {{title}}", "taskCreationUnavailable": "La creación de tareas no está disponible en esta vista", "title": "Información", - "unarchiveLabel": "Desarchivar esta información", - "unarchiveTitle": "Desarchivar esta información", "unarchived": "\"{{title}}\" desarchivado", "unarchivedMsg": "Información desarchivada: {{title}}", + "unarchiveLabel": "Desarchivar esta información", + "unarchiveTitle": "Desarchivar esta información", "unarchiving": "Desarchivando \"{{title}}\"...", "usePlanningDefault": "Usar el predeterminado de planificación" }, @@ -2570,8 +2620,8 @@ "preparingQuestion": "Preparando la siguiente pregunta...", "progressText": "Pregunta {{progress}} de ~6", "reconnecting": "Reconectando…", - "refineScope": "Refinar el alcance {{label}} con IA", "refinedScope": "Alcance refinado", + "refineScope": "Refinar el alcance {{label}} con IA", "sendToBackground": "Enviar al fondo", "sessionActiveAnotherTab": "La sesión está activa en otra pestaña.", "showThinking": "Mostrar pensamiento", @@ -2586,6 +2636,10 @@ "verificationCriteria": "Criterios de verificación", "yes": "Sí" }, + "lane": { + "collapse": "", + "expand": "" + }, "listView": { "apply": "Aplicar", "applying": "Aplicando…", @@ -2593,16 +2647,18 @@ "archiveSelectedTitle": "Archivar las tareas seleccionadas que están completadas", "archiveUnavailable": "La acción de archivar no está disponible", "archiveViaButton": "Las tareas solo se pueden archivar mediante el botón de archivar", - "bulkArchiveDone": "Archivar {{count}} completada(s)", - "bulkArchiveMessage": "¿Archivar {{count}} tarea(s) seleccionada(s)?", + "bulkArchiveDone_one": "", + "bulkArchiveDone_other": "", + "bulkArchiveMessage_one": "", + "bulkArchiveMessage_other": "", "bulkArchiveNoTasks": "No hay tareas seleccionadas que se puedan archivar (solo tareas completadas)", "bulkArchiveSummary": "{{archived}} archivada(s) · {{skipped}} omitida(s) · {{failed}} fallida(s)", "bulkArchiveTitle": "Archivar tareas seleccionadas", "bulkDeleteAll": "Eliminar todo", "bulkDeleteArchiveSummary": "Archivado {{archived}}, eliminado {{deleted}}, fallido {{failed}}", - "bulkDeleteMessage": "¿Eliminar {{count}} tarea(s) seleccionada(s)?", + "bulkDeleteMessage_one": "", + "bulkDeleteMessage_other": "", "bulkDeleteNoTasks": "No hay tareas seleccionadas que se puedan eliminar (las tareas archivadas están excluidas)", - "bulkDeleteSummary": "{{deleted}} tarea(s) eliminada(s) · {{skipped}} archivada(s) omitida(s) · {{failed}} fallida(s)", "bulkDeleteSummary_one": "Eliminada {{count}} tarea · {{skipped}} archivadas omitidas · {{failed}} fallidas", "bulkDeleteSummary_other": "Eliminadas {{count}} tareas · {{skipped}} archivadas omitidas · {{failed}} fallidas", "bulkDeleteTitle": "Eliminar tareas seleccionadas", @@ -2616,7 +2672,8 @@ "bulkUnpauseSummary": "{{unpaused}} reanudada(s) · {{skipped}} omitida(s) · {{failed}} fallida(s)", "bulkUpdateFailed": "Error al actualizar los modelos", "bulkUpdateNoTasks": "No hay tareas válidas para actualizar (las tareas archivadas no se pueden modificar)", - "bulkUpdateSuccess": "{{count}} tarea(s) actualizada(s)", + "bulkUpdateSuccess_one": "", + "bulkUpdateSuccess_other": "", "cancelMove": "Cancelar movimiento", "clear": "Limpiar", "clearColumnFilter": "Limpiar filtro de columna", @@ -2636,7 +2693,8 @@ "filterChip": "Filtro: {{column}}", "forceDelete": "Forzar eliminación", "forceDeleteTitle": "Forzar eliminación de tarea", - "hidden": "{{count}} oculta(s)", + "hidden_one": "", + "hidden_other": "", "hideDone": "Ocultar completadas", "hideDoneTitle": "Ocultar tareas completadas", "keepProgress": "Conservar progreso", @@ -2646,18 +2704,18 @@ "listControlsLabel": "Controles de lista", "newTask": "+ Nueva tarea", "noChange": "Sin cambios", - "noTasks": "Sin tareas", - "noTasksMatch": "Ninguna tarea coincide con tu filtro", - "noTasksYet": "Aún no hay tareas", "nodeOverrideLabel": "Nodo de sustitución", "nodeStatusConnecting": "Conectando", "nodeStatusError": "Error", "nodeStatusOffline": "Sin conexión", "nodeStatusOnline": "En línea", + "noTasks": "Sin tareas", + "noTasksMatch": "Ninguna tarea coincide con tu filtro", + "noTasksYet": "Aún no hay tareas", + "pausedByAgent": "pausada por el agente", "pauseSelected": "Pausar seleccionadas", "pauseSelectedTitle": "Pausar todas las tareas seleccionadas que no estén ya pausadas", "pauseUnavailable": "La acción de pausar no está disponible", - "pausedByAgent": "pausada por el agente", "preserveProgressMessage": "Esta tarea tiene pasos completados. ¿Conservar el progreso antes de mover?", "preserveProgressTitle": "¿Conservar el progreso?", "resetProgress": "Restablecer progreso", @@ -2666,9 +2724,10 @@ "resizeSidebar": "Redimensionar el panel lateral de la lista de tareas", "reviewerModel": "Modelo revisor", "selectAll": "Seleccionar todas las tareas visibles", + "selectedCount_one": "", + "selectedCount_other": "", "selectTask": "Seleccionar {{taskId}}", "selectTaskPrompt": "Selecciona una tarea para ver los detalles", - "selectedCount": "{{count}} seleccionada(s)", "showAll": "Mostrar todo", "showAllTitle": "Mostrar todas las tareas", "showDone": "Mostrar completadas", @@ -2677,8 +2736,10 @@ "staleOnlyTitle": "Mostrar solo tareas obsoletas", "stalePausedReview": "Revisión pausada obsoleta", "stalePausedReviewTitle": "Mostrar solo tareas de revisión pausadas y obsoletas", - "stats": "{{count}} de {{total}} tareas", - "statsInColumn": "{{count}} de {{total}} tareas en {{column}}", + "stats_one": "", + "stats_other": "", + "statsInColumn_one": "", + "statsInColumn_other": "", "statusMergingFix": "Fusionando correcciones…", "stuck": "Atascada", "taskCreationUnavailable": "Creación de tarea no disponible", @@ -2690,8 +2751,6 @@ }, "mailbox": { "agent": "Agente", - "agentById": "Agente: {{id}}", - "agentByName": "Agente: {{name}}", "agents": "Agentes", "agentsTab": "Agentes", "ago": "hace", @@ -2702,8 +2761,8 @@ "approvalDeny": "Denegar", "approvalRequested": "Solicitado", "approvalRequester": "Solicitante", - "approvalTask": "Tarea", "approvals": "Aprobaciones", + "approvalTask": "Tarea", "back": "Atrás", "backButton": "← Atrás", "closeAriaLabel": "Cerrar", @@ -2732,8 +2791,9 @@ "markAllRead": "Marcar todo como leído", "markAllReadButton": "Marcar todo como leído", "markAllReadTitle": "Marcar todo como leído", + "markedAsRead_one": "", + "markedAsRead_other": "", "markReadFailed": "No se pudieron marcar los mensajes como leídos", - "markedAsRead": "Marcadas {{count}} mensajes como leído", "messageDeleted": "Mensaje eliminado", "messageSent": "Mensaje enviado", "noAgentMessages": "Sin mensajes de agente a agente", @@ -2753,15 +2813,18 @@ "refreshTitle": "Actualizar", "reply": "Responder", "replyButton": "Responder", - "replyLoadFailed": "Error al cargar el mensaje de respuesta. Haz clic para reintentar.", "replyingTo": "Respondiendo a {{preview}}", "replyingToMessage": "Respondiendo al mensaje", + "replyLoadFailed": "Error al cargar el mensaje de respuesta. Haz clic para reintentar.", "selectMessageToRead": "Seleccione un mensaje para leer", "system": "Sistema", - "timeDaysAgo": "Hace {{count}}d", - "timeHoursAgo": "Hace {{count}}h", + "timeDaysAgo_one": "", + "timeDaysAgo_other": "", + "timeHoursAgo_one": "", + "timeHoursAgo_other": "", "timeJustNow": "Hace un momento", - "timeMinsAgo": "Hace {{count}}m", + "timeMinsAgo_one": "", + "timeMinsAgo_other": "", "title": "Buzón", "to": "Para", "toLabel": "Para:", @@ -2771,7 +2834,6 @@ "typeSystem": "Sistema", "typeUserToAgent": "Usted → Agente", "user": "Usuario", - "userLabel": "Usuario: {{id}}", "you": "Tú" }, "memory": { @@ -2789,32 +2851,33 @@ "capReadable": "Legible", "capWritable": "Modificable", "categories": "Categorías", - "charCount": "{{count}} caracteres", + "charCount_one": "", + "charCount_other": "", "compactFailed": "Error al compactar la memoria", - "compactSelectedFile": "Compactar archivo seleccionado", "compacting": "Compactando…", "compactionThresholdHint": "La memoria se compactará cuando supere este número de caracteres", "compactionThresholdLabel": "Umbral de compactación (caracteres)", + "compactSelectedFile": "Compactar archivo seleccionado", "currentBackendTitle": "Backend actual", "description": "Memoria de trabajo, perspectivas a largo plazo y estado del motor", "disabledMessage": "La memoria está actualmente desactivada. Activa las herramientas de memoria en Ajustes para editar estas automatizaciones.", + "dreaming": "Procesando sueños…", "dreamNow": "Procesar ahora", "dreamNowHint": "Activa manualmente el procesamiento de sueños ahora.", "dreamProcessingComplete": "Procesamiento de sueños completado", "dreamProcessingFailed": "Error al ejecutar el procesamiento de sueños", - "dreaming": "Procesando sueños…", "dreamsEnabledHint": "Convierte las notas diarias en DREAMS.md y promueve las lecciones reutilizables en MEMORY.md.", "dreamsEnabledLabel": "Procesar sueños desde la memoria diaria", "dreamsScheduleHint": "Expresión cron para el procesamiento de sueños.", "dreamsScheduleLabel": "Programación de sueños", - "editRaw": "Editar en bruto", "editorDefaultDescription": "Edita el archivo de memoria seleccionado.", "editorLabel": "Editor de memoria", - "extractInsightsFailed": "Error al extraer perspectivas", - "extractNow": "Extraer ahora", + "editRaw": "Editar en bruto", "extracting": "Extrayendo…", + "extractInsightsFailed": "Error al extraer perspectivas", "extractionFailed": "Error", "extractionSuccess": "Éxito", + "extractNow": "Extraer ahora", "fileCompacted": "Archivo de memoria compactado", "fileLabel": "Archivo de memoria", "fileSummary": "{{size}} bytes · actualizado {{updatedAt}}", @@ -2824,13 +2887,15 @@ "healthIssues": "Se encontraron problemas", "healthStatusTitle": "Estado de salud", "healthWarning": "Advertencia", - "insightCount": "{{count}} perspectivas", - "insightsExtracted": "{{count}} perspectivas extraídas", + "insightCount_one": "", + "insightCount_other": "", + "insightsExtracted_one": "", + "insightsExtracted_other": "", "insightsMemoryLabel": "Memoria de perspectivas", "insightsSaved": "Perspectivas guardadas", + "installing": "Instalando…", "installQmd": "Instalar qmd", "installQmdFailed": "Error al instalar qmd", - "installing": "Instalando…", "lastExtractionLabel": "Última extracción", "lastUpdated": "Última actualización", "layerDaily": "Diario", @@ -2854,9 +2919,9 @@ "qmdAvailableOnPath": "qmd está disponible en el PATH.", "qmdChecking": "Verificando", "qmdCheckingAvailability": "Comprobando disponibilidad de qmd…", + "qmdInstalled": "Instalado", "qmdInstallSuccess": "qmd instalado correctamente", "qmdInstallUnavailable": "La instalación de qmd finalizó, pero qmd sigue sin estar disponible", - "qmdInstalled": "Instalado", "qmdIntegrationTitle": "Integración QMD", "qmdNotInstalled": "qmd no está instalado. La búsqueda usará archivos locales. Instala la recuperación indexada:", "qmdPathUsed": "se usó la ruta qmd", @@ -2875,7 +2940,8 @@ "saveSettingsFailed": "Error al guardar los ajustes de memoria", "saving": "Guardando…", "searchPlaceholder": "Buscar en memoria con qmd", - "sectionCount": "{{count}} secciones", + "sectionCount_one": "", + "sectionCount_other": "", "settingsNote": "Nota: cambia el tipo de backend en", "settingsNoteLink": "Ajustes → Memoria", "settingsNoteToast": "Abre Ajustes → Memoria para cambiar el tipo de backend", @@ -2884,12 +2950,13 @@ "tabEngines": "Motores", "tabInsights": "Perspectivas", "tabWorking": "Memoria de trabajo", + "testing": "Probando…", "testMemorySearchTitle": "Probar búsqueda en memoria", - "testResultCount": "{{count}} resultado para «{{query}}»", + "testResultCount_one": "", + "testResultCount_other": "", "testResultStatus": "qmd {{qmdStatus}} · {{fallbackStatus}}", "testRetrieval": "Probar recuperación", "testSearchHint": "Ejecuta el mismo camino memory_search respaldado por qmd que usan los agentes.", - "testing": "Probando…", "title": "Memoria", "totalInsights": "Total de perspectivas", "workingMemoryLabel": "Memoria de trabajo" @@ -2913,16 +2980,16 @@ "pr": "PR", "pulling": "Obteniendo…", "pushForceWithLease": "Enviar (force-with-lease)", - "pushHeading": "Enviar {{branch}} a origin — adelantado {{count}} commit{{plural}}.", + "pushHeading_one": "", + "pushHeading_other": "", + "pushing": "Enviando…", "pushSuccess": "Enviado a origin/{{branch}} @ {{sha}}.", "pushToOrigin": "Enviar a origin", - "pushing": "Enviando…", "recordedNoConfirm": "Registrado sin confirmación de combinación local", "shortstatTitle": "Estadísticas cortas finales del commit; para ver la diferencia completa de todos los commits de tareas, consulta la pestaña Cambios.", "smartPull": "Pull inteligente", "status": "Estado", - "title": "Detalles de la combinación", - "unknown": "Desconocido" + "title": "Detalles de la combinación" }, "mesh": { "ariaLabel": "Visualización de topología de malla de nodos", @@ -2947,23 +3014,24 @@ "addAssertion": "Añadir aserción", "addContext": "Añade contexto o dirección adicional...", "addFeature": "Agregar característica", + "additionalComments": "Comentarios adicionales (opcional)", "addMilestone": "Añadir hito", "addSlice": "Añadir segmento", - "additionalComments": "Comentarios adicionales (opcional)", "aiThinking": "La IA está pensando...", "aiValidatedAtRuntime": "Validado por IA en tiempo de ejecución", "aiValidatedMissionGate": "Puerta de misión validada por IA", "allFeaturesLinked": "Todas las funcionalidades ya están vinculadas", "approvePlan": "Aprobar plan", - "assertionCreateFailed": "Error al crear la aserción", "assertionCreated": "Aserción creada", + "assertionCreateFailed": "Error al crear la aserción", "assertionFieldsRequired": "El título y el texto de la aserción son obligatorios", "assertionTextEditPlaceholder": "Texto de la aserción", "assertionTextPlaceholder": "Texto de la aserción (lo que debe ser verdad al completar)", "assertionTitlePlaceholder": "Título de la aserción", - "assertionUpdateFailed": "Error al actualizar la aserción", "assertionUpdated": "Aserción actualizada", - "attemptRetries": "Intento {{attempt}} · {{count}} {{label}} restante(s)", + "assertionUpdateFailed": "Error al actualizar la aserción", + "attemptRetries_one": "", + "attemptRetries_other": "", "autopilotActivatingSlice": "Activando segmento", "autopilotCompleting": "Completando", "autopilotDescription": "Cuando está activado, Fusion activa automáticamente el siguiente segmento y planifica sus funcionalidades a medida que avanza el trabajo.", @@ -2973,11 +3041,6 @@ "autopilotLabel": "Piloto automático", "autopilotLastActivation": "Última activación {{time}}", "autopilotOff": "Desactivado", - "autopilotStateActivating": "Activando segmento", - "autopilotStateCompleting": "Completando", - "autopilotStateInactive": "Desactivado", - "autopilotStateUnknown": "Desconocido", - "autopilotStateWatching": "Observando", "autopilotUpdateFailed": "Error al actualizar el piloto automático", "autopilotWatching": "Piloto automático en vigilancia", "autopilotWatchingSince": "Observando desde {{time}}", @@ -3009,20 +3072,20 @@ "confirmSlicePlaceholder": "Cómo confirmar que esta rebanada está hecha...", "contractAssertions": "Aserciones de contrato (validadas por IA)", "createButton": "Crear", - "createTask": "Crear tarea", "created": "Misión creada", "createdFromInterview": "Misión creada desde la entrevista IA", + "createTask": "Crear tarea", "creatingMission": "Creando misión...", "defaultInterviewTitle": "Entrevista de misión", "deleteAssertion": "Eliminar la aserción", "deleteButton": "Eliminar", "deleteConfirm": "¿Eliminar este/esta {{type}}? Esta acción no se puede deshacer.", + "deleted": "Misión eliminada", "deleteFailed": "Error al eliminar la misión", "deleteFeature": "Eliminar la funcionalidad", "deleteMilestone": "Eliminar el hito", "deleteMission": "Eliminar la misión", "deleteSlice": "Eliminar el segmento", - "deleted": "Misión eliminada", "describeGoal": "Describe lo que quieres construir. La IA te entrevistará para entender el alcance, las restricciones y los requisitos, luego producirá un plan estructurado con hitos, rebanadas y características.", "descriptionLabel": "Descripción de la misión", "descriptionOptional": "Descripción (opcional)", @@ -3047,23 +3110,24 @@ "failedLoadModels": "Error al cargar modelos", "featureCreated": "Funcionalidad creada", "featureCriteriaAwaitingSync": "Criterios de funcionalidades pendientes de sincronización de aserción", - "featureDeleteFailed": "Error al eliminar la funcionalidad", "featureDeleted": "Funcionalidad eliminada", - "featureLinkFailed": "Error al vincular la funcionalidad", - "featureLinkTaskFailed": "Error al vincular la funcionalidad a la tarea", + "featureDeleteFailed": "Error al eliminar la funcionalidad", "featureLinkedToAssertion": "Funcionalidad vinculada a la aserción", "featureLinkedToTask": "Funcionalidad vinculada a la tarea", + "featureLinkFailed": "Error al vincular la funcionalidad", + "featureLinkTaskFailed": "Error al vincular la funcionalidad a la tarea", "featureSaveFailed": "Error al guardar la funcionalidad", + "featuresCount_one": "", + "featuresCount_other": "", "featureTitlePlaceholder": "Título de la funcionalidad", "featureTitleRequired": "El título de la funcionalidad es obligatorio", - "featureTriageFailed": "Error al clasificar la funcionalidad", "featureTriaged": "Funcionalidad clasificada — tarea creada", - "featureUnlinkFailed": "Error al desvincular la funcionalidad", - "featureUnlinkFromAssertionFailed": "Error al desvincular la funcionalidad", + "featureTriageFailed": "Error al clasificar la funcionalidad", "featureUnlinkedFromAssertion": "Funcionalidad desvinculada de la aserción", "featureUnlinkedFromTask": "Funcionalidad desvinculada de la tarea", + "featureUnlinkFailed": "Error al desvincular la funcionalidad", + "featureUnlinkFromAssertionFailed": "Error al desvincular la funcionalidad", "featureUpdated": "Funcionalidad actualizada", - "featuresCount": "{{count}} funcionalidades", "filterAll": "Todos los eventos", "filterAutopilot": "Eventos de piloto automático", "filterErrors": "Errores y advertencias", @@ -3074,9 +3138,6 @@ "generatedFixFeatures": "Funcionalidades de corrección generadas:", "generatedFixFeaturesTitle": "Funcionalidades de corrección generadas", "generatedFromFeature": "Generado desde la funcionalidad: {{id}}", - "helperTextActive": "Detener pausa las tareas vinculadas y marca la misión como bloqueada.", - "helperTextBlocked": "Reanudar reactiva la misión y continúa la ejecución.", - "helperTextPlanning": "El inicio activa el primer segmento para que el trabajo pueda comenzar.", "hideDetails": "Ocultar detalles", "hideMetadata": "Ocultar metadatos", "hideThinking": "Ocultar pensamiento", @@ -3090,42 +3151,39 @@ "interviewErrored": "La entrevista encontró un error. Reintenta desde este elemento.", "interviewGenerating": "Generando la jerarquía de la misión desde el contexto de la entrevista.", "interviewInProgress": "Entrevista en progreso", - "interviewStatusAwaitingInput": "Esperando entrada", - "interviewStatusComplete": "Plan listo", - "interviewStatusError": "Necesita reintento", - "interviewStatusGenerating": "Generando el plan", - "interviewStatusNeedsRetry": "Necesita reintento", - "interviewStatusPlanReady": "Plan listo", "interviewWaiting": "La entrevista está esperando tu próxima respuesta.", "lastValidatorStatus": "Último {{status}}", "linkAFeature": "Vincular una funcionalidad", "linkButton": "Vincular", - "linkFeatureButton": "Vincular funcionalidad", - "linkFeatureToTask": "Vincular funcionalidad a la tarea:", - "linkToTask": "Vincular a la tarea", - "linkedCount": "{{count}} vinculados", - "linkedFeaturesCount": "{{count}} funcionalidades vinculadas", + "linkedCount_one": "", + "linkedCount_other": "", + "linkedFeaturesCount_one": "", + "linkedFeaturesCount_other": "", "linkedFeaturesLabel": "Funcionalidades vinculadas", "linkedGoals": "Objetivos vinculados", "linkedGoalsTitle": "Objetivos vinculados", + "linkFeatureButton": "Vincular funcionalidad", + "linkFeatureToTask": "Vincular funcionalidad a la tarea:", + "linkToTask": "Vincular a la tarea", "loadActivityFailed": "Error al cargar la actividad de la misión", "loadDetailFailed": "Error al cargar los detalles de la misión", "loadFailed": "Error al cargar las misiones", - "loadMore": "Cargar más", "loadingActivity": "Cargando actividad de la misión…", "loadingMissionDetails": "Cargando detalles de la misión…", "loadingMissions": "Cargando misiones…", "loadingModels": "Cargando modelos…", + "loadMore": "Cargar más", "loopState": "Estado de bucle: {{state}}", "milestoneCreated": "Hito creado", - "milestoneDeleteFailed": "Error al eliminar el hito", "milestoneDeleted": "Hito eliminado", + "milestoneDeleteFailed": "Error al eliminar el hito", "milestoneDescriptionPlaceholder": "Descripción del hito...", "milestoneSaveFailed": "Error al guardar el hito", + "milestonesCount_one": "", + "milestonesCount_other": "", "milestoneTitlePlaceholder": "Título del hito", "milestoneTitleRequired": "El título del hito es obligatorio", "milestoneUpdated": "Hito actualizado", - "milestonesCount": "{{count}} hitos", "missionHealthAriaLabel": "Estado de salud de la misión: {{state}}", "missionInterviewInProgressDesc": "La entrevista de la misión aún está en progreso. Abre esta misión para continuar la planificación.", "missionList": "Lista de misiones", @@ -3143,44 +3201,45 @@ "noMilestonesYet": "Aún no hay hitos. Añade uno para empezar.", "noMissionsYetBody": "Las misiones son grandes iniciativas que agrupan hitos, segmentos y funcionalidades en un solo plan. Planifica una misión para desglosar un objetivo de extremo a extremo y deja que los agentes lo ejecuten en modo piloto automático.", "noMissionsYetTitle": "Aún no hay misiones", + "none": "Ninguno", "noSlicesYet": "Aún no hay segmentos", "noValidationRunsYet": "Aún no hay ejecuciones de validación.", - "none": "Ninguno", "openMissionAriaLabel": "Abrir la misión {{title}}", "orSelect": "O seleccionar:", "planMilestone": "Planificar el hito", "planNewMission": "Planificar nueva misión", + "planningModel": "Modelo de planificación", "planReady": "Plan de misión listo", "planSlice": "Planificar el segmento", "planStateNeedsUpdate": "Necesita actualización", "planStateNotPlanned": "Sin planificar", "planStatePlanned": "Planificado", "planTitle": "Planificar una misión con IA", - "planningModel": "Modelo de planificación", "prepareQuestion": "Preparando la siguiente pregunta...", - "progressText": "Pregunta {{count}} de ~6", + "progressText_one": "", + "progressText_other": "", "reconnecting": "Reconectando…", - "relativeTimeDays": "hace {{count}} d", - "relativeTimeHours": "hace {{count}} h", + "relativeTimeDays_one": "", + "relativeTimeDays_other": "", + "relativeTimeHours_one": "", + "relativeTimeHours_other": "", "relativeTimeJustNow": "ahora mismo", - "relativeTimeMinutes": "hace {{count}} min", + "relativeTimeMinutes_one": "", + "relativeTimeMinutes_other": "", "removeFeature": "Eliminar característica", "removeMilestone": "Eliminar hito", "removeSlice": "Eliminar rebanada", "resizeSidebar": "Redimensionar el panel lateral de misiones", + "resumed": "Misión reanudada", "resumeFailed": "Error al reanudar la misión", "resumeInterviewAriaLabel": "Reanudar la entrevista {{title}}", "resumeMission": "Reanudar la misión", - "resumed": "Misión reanudada", "retries": "reintentos", "retry": "reintento", "retryBudgetTitle": "Intentos de implementación y presupuesto de reintentos restante", "retrying": "Reintentando...", "roadmapLabel": "Hoja de ruta", "run": "Ejecución:", - "runHelperActive": "Detener pausa las tareas vinculadas y marca la misión como bloqueada.", - "runHelperBlocked": "Reanudar vuelve a activar la misión y continúa la ejecución.", - "runHelperPlanning": "Al iniciar se activa el primer segmento para que el trabajo pueda comenzar.", "runSettings": "Configuración de ejecución de la misión", "runSettingsTitle": "Configuración de ejecución de la misión", "saveButton": "Guardar", @@ -3194,25 +3253,27 @@ "showMetadata": "Mostrar metadatos", "showThinking": "Mostrar pensamiento", "showValidationRounds": "Mostrar rondas de validación", - "sliceActivateFailed": "Error al activar el segmento", "sliceActivated": "Segmento activado", + "sliceActivateFailed": "Error al activar el segmento", "sliceCreated": "Segmento creado", - "sliceDeleteFailed": "Error al eliminar el segmento", "sliceDeleted": "Segmento eliminado", + "sliceDeleteFailed": "Error al eliminar el segmento", "sliceSaveFailed": "Error al guardar el segmento", + "slicesCount_one": "", + "slicesCount_other": "", "sliceTitlePlaceholder": "Título del segmento", "sliceTitleRequired": "El título del segmento es obligatorio", + "sliceTriaged_one": "", + "sliceTriaged_other": "", "sliceTriageFailed": "Error al clasificar las funcionalidades del segmento", - "sliceTriaged": "{{count}} funcionalidades clasificadas", "sliceUpdated": "Segmento actualizado", "sliceVerification": "Verificación de rebanada", - "slicesCount": "{{count}} segmentos", "source": "Origen:", + "started": "Misión iniciada — primer segmento activado", "startFailed": "Error al iniciar la misión", "startInterview": "Comenzar entrevista", "startMission": "Iniciar la misión", "startOver": "Empezar de nuevo", - "started": "Misión iniciada — primer segmento activado", "statusActive": "Activo", "statusArchived": "Archivado", "statusBlocked": "Bloqueado", @@ -3227,9 +3288,11 @@ "statusTriaged": "Clasificado", "stopFailed": "Error al detener la misión", "stopMission": "Detener la misión", - "stopped": "Misión detenida ({{count}} tareas pausadas)", + "stopped_one": "", + "stopped_other": "", "summaryStats": "{{milestones}} hitos, {{features}} características. Revisa y edita antes de aprobar.", - "tabActivity": "Actividad ({{count}})", + "tabActivity_one": "", + "tabActivity_other": "", "tabStructure": "Estructura", "takeControl": "Tomar control", "takingControl": "Tomando control...", @@ -3237,7 +3300,8 @@ "targetBranchPlaceholder": "p. ej. main", "taskIdPlaceholder": "ID de tarea (p. ej., FN-001)", "taskIdRequired": "El ID de tarea es obligatorio", - "tasksFailed": "{{count}} fallidos", + "tasksFailed_one": "", + "tasksFailed_other": "", "title": "Misiones", "titleLabel": "Título de la misión", "titleRequired": "El título de la misión es obligatorio", @@ -3247,21 +3311,23 @@ "triageCreateTask": "Clasificar — crear tarea", "tryExample": "Intenta un ejemplo:", "typeAnswer": "Escribe tu respuesta aquí...", + "unlinkedBadge": "Sin vincular", "unlinkFeature": "Desvincular funcionalidad", "unlinkTask": "Desvincular tarea", - "unlinkedBadge": "Sin vincular", "untitled": "Sin título", "updateButton": "Actualizar", "updated": "Misión actualizada", "validateFeature": "Validar la funcionalidad", - "validationRoundsCount": "{{count}} rondas", - "validationRoundsLabel": "Rondas de validación ({{count}})", + "validationRoundsCount_one": "", + "validationRoundsCount_other": "", + "validationRoundsLabel_one": "", + "validationRoundsLabel_other": "", "validationRuns": "Ejecuciones de validación", "validationState": "Estado de validación", "validationStateNotStarted": "No iniciado", "validationTelemetry": "Telemetría de validación", - "validationTriggerFailed": "Error al iniciar la validación", "validationTriggered": "Validación iniciada", + "validationTriggerFailed": "Error al iniciar la validación", "verification": "Verificación:", "verificationCriteria": "Criterios de verificación", "viewMissionFailures": "Ver fallos de la misión", @@ -3276,26 +3342,13 @@ "noChange": "Sin cambios", "selectPlaceholder": "Seleccionar un modelo…" }, - "modelSelection": { - "choose": "Elige modelos para esta tarea. Si no se selecciona, se usarán los modelos predeterminados.", - "custom": "Personalizado", - "executorModel": "Modelo ejecutor", - "executorPlaceholder": "Seleccionar modelo ejecutor…", - "loading": "Cargando modelos…", - "noModels": "No hay modelos disponibles. Configura la autenticación en Configuración para habilitar la selección de modelos.", - "preset": "Predefinido", - "reviewerModel": "Modelo revisor", - "reviewerPlaceholder": "Seleccionar modelo revisor…", - "title": "Seleccionar modelos", - "useDefault": "Usar predeterminado", - "usingDefault": "Usando predeterminado" - }, "models": { "addProviderToFavoritesAriaLabel": "Añadir {{provider}} a favoritos", "addToFavorites": "Añadir a favoritos", "addToFavoritesAriaLabel": "Añadir {{name}} a favoritos", "clearFilter": "Limpiar filtro", - "count": "{{count}} modelo", + "count_one": "", + "count_other": "", "descriptions": { "executor": "El modelo de IA utilizado para implementar esta tarea.", "override": "Anule los modelos de IA utilizados para esta tarea. Si no se especifica, se utilizan los valores predeterminados del proyecto o globales.", @@ -3320,8 +3373,6 @@ "thinkingLevel": "Nivel de reflexión" }, "messages": { - "modelSetTo": "Modelo {{label}} establecido en {{provider}}/{{modelId}}", - "modelSetToDefault": "Modelo {{label}} establecido por defecto", "thinkingLevelSet": "Nivel de reflexión establecido en {{level}}", "thinkingLevelSetDefault": "Nivel de reflexión establecido en predeterminado ({{level}})", "upToDate": "La configuración del modelo está actualizada.", @@ -3348,16 +3399,25 @@ "loading": "Cargando modelos disponibles…", "usingDefault": "Usando predeterminado" }, - "targetLabels": { - "executor": "Ejecutor", - "planning": "Planificación", - "validator": "Revisor" - }, "titles": { "configuration": "Configuración del modelo" }, "useDefault": "Usar predeterminado" }, + "modelSelection": { + "choose": "Elige modelos para esta tarea. Si no se selecciona, se usarán los modelos predeterminados.", + "custom": "Personalizado", + "executorModel": "Modelo ejecutor", + "executorPlaceholder": "Seleccionar modelo ejecutor…", + "loading": "Cargando modelos…", + "noModels": "No hay modelos disponibles. Configura la autenticación en Configuración para habilitar la selección de modelos.", + "preset": "Predefinido", + "reviewerModel": "Modelo revisor", + "reviewerPlaceholder": "Seleccionar modelo revisor…", + "title": "Seleccionar modelos", + "useDefault": "Usar predeterminado", + "usingDefault": "Usando predeterminado" + }, "nav": { "activityLog": "Registro de actividad", "agents": "Agentes", @@ -3380,8 +3440,8 @@ "missions": "Misiones", "more": "Más", "moreSheetTitle": "Navegar", - "noScriptsAddOne": "Sin scripts — agregar uno…", "nodes": "Nodos", + "noScriptsAddOne": "Sin scripts — agregar uno…", "planning": "Planificación", "primaryNavAriaLabel": "Navegación principal", "projects": "Proyectos", @@ -3417,28 +3477,12 @@ "noAvailableTasks": "No hay tareas disponibles", "searchTasks": "Buscar tareas…", "selectAgent": "Seleccionar agente", - "selectedCount": "{{count}} seleccionado", + "selectedCount_one": "", + "selectedCount_other": "", "taskCreated": "{{taskId}} creado", "title": "Nueva tarea", "unsavedChanges": "Tiene cambios sin guardar. ¿Descartar?" }, - "nodeStatus": { - "connecting": "Conectando", - "error": "Error", - "local": "Local", - "offline": "Sin conexión", - "online": "En línea", - "unknown": "Desconocido" - }, - "nodeSync": { - "error": { - "authSyncFailed": "Error en la sincronización de autenticación", - "failedToFetchStatus": "No se pudo obtener el estado de sincronización", - "pullFailed": "Error al obtener la configuración", - "pushFailed": "Error al enviar la configuración", - "someRequestsFailed": "Algunas solicitudes de estado de sincronización fallaron" - } - }, "nodes": { "actions": { "connect": "Conectar", @@ -3481,22 +3525,16 @@ "addDockerNode": "Añadir nodo Docker", "addDockerNodeTitle": "Añadir nodo Docker administrado", "addFirstNode": "Añadir primer nodo", + "adding": "Agregando...", "addMountButton": "Agregar montaje", "addNode": "Añadir nodo", "addVariableButton": "Agregar variable", - "adding": "Agregando...", "apiKey": "Clave API", "apiKeyMode": "Modo de clave API", "apiKeyNotConfigured": "No configurada", "apiKeyPlaceholder": "Dejar en blanco para no cambiar", "attachProjects": "Adjuntar proyectos existentes", "attachProjectsHint": "Seleccione proyectos existentes para ejecutar en este nodo y proporcione la ruta absoluta específica del nodo para cada uno.", - "auth": { - "differ": "Las credenciales difieren", - "differProviders": "Las credenciales difieren: {{providers}}", - "match": "Las credenciales coinciden", - "notSynced": "Autenticación no sincronizada" - }, "authSync": { "differ": "credenciales difieren", "label": "Sincronización de autenticación: {{status}}", @@ -3517,9 +3555,10 @@ "containerLogs": "Registros del contenedor", "description": "Registre un nodo Fusion existente proporcionando sus detalles de conexión y configuración de concurrencia.", "discoverBeforeAdding": "Descubra proyectos remotos antes de agregar este nodo.", - "discoverRemoteProjects": "Descubrir proyectos remotos", - "discoveredCount": "{{count}} proyecto remoto descubierto{{plural}}.", + "discoveredCount_one": "", + "discoveredCount_other": "", "discovering": "Descubriendo...", + "discoverRemoteProjects": "Descubrir proyectos remotos", "discoveryFailed": "No se pudieron descubrir proyectos remotos", "dismissError": "Descartar error", "docker": "Docker", @@ -3553,8 +3592,8 @@ "dockerPidsLimit": "Límite de PIDs", "dockerPort": "Puerto", "dockerResourceDefault": "Predeterminado", - "dockerResourceSizing": "Dimensionamiento de recursos", "dockerResources": "Recursos", + "dockerResourceSizing": "Dimensionamiento de recursos", "dockerRetainOnDelete": "Conservar al eliminar", "dockerStatusUnknown": "Desconocido", "dockerTlsCaCert": "Ruta del certificado CA TLS", @@ -3567,11 +3606,11 @@ "editButton": "Editar", "errorFetching": "Error al obtener nodos", "errorPersistMappings": "Error al persistir las asignaciones de proyecto", - "errorUnregisterAfterMappingFailure": "Error al anular el registro del nodo tras el error de asignación", "errors": { "connectFailed": "Error de conexión", "connectToNode": "No se pudo conectar al nodo" }, + "errorUnregisterAfterMappingFailure": "Error al anular el registro del nodo tras el error de asignación", "failedCreateDocker": "No se pudo crear el nodo Docker", "failedRefresh": "No se pudieron actualizar los nodos", "failedRemove": "No se pudo eliminar el nodo", @@ -3582,10 +3621,6 @@ "fieldCreated": "Creado", "fieldMaxConcurrent": "Concurrencia máx.", "fieldName": "Nombre", - "fieldStatus": "Estado", - "fieldType": "Tipo", - "fieldUpdated": "Actualizado", - "fieldUrl": "URL", "fields": { "authKey": "Clave de autenticación", "host": "Host / Dirección IP", @@ -3594,6 +3629,10 @@ "port": "Puerto", "url": "URL" }, + "fieldStatus": "Estado", + "fieldType": "Tipo", + "fieldUpdated": "Actualizado", + "fieldUrl": "URL", "heading": "Nodos", "healthCheckButton": "Comprobación de salud", "healthCheckComplete": "Verificación de salud del nodo completada", @@ -3623,6 +3662,7 @@ "namePlaceholder": "Máquina de construcción", "nameRequired": "El nombre es obligatorio", "no": "No", + "nodeLabel": "{{name}} ({{type}}) — {{status}}", "noLogsAvailable": "No hay registros disponibles", "noMatch": "Sin coincidencia exacta del nombre remoto. Ingrese esta ruta manualmente.", "noProjects": "No hay proyectos actualmente registrados.", @@ -3630,7 +3670,6 @@ "noProjectsDiscovered": "No se descubrieron proyectos en el nodo remoto.", "noProjectsRunning": "No hay proyectos en ejecución en este nodo.", "noRegistered": "No hay nodos registrados aún.", - "nodeLabel": "{{name}} ({{type}}) — {{status}}", "offline": "Sin conexión", "online": "En línea", "pathDiscovered": "Ruta de autoridad remota descubierta: {{path}}", @@ -3642,22 +3681,23 @@ "optional": "Opcional" }, "provideManually": "Proporcionar clave manualmente", + "pulling": "Descargando…", "pullSettings": "Descargar ajustes", "pullSettingsFailed": "Error al descargar ajustes", "pullSettingsSuccess": "Ajustes descargados correctamente", - "pulling": "Descargando…", + "pushing": "Enviando…", "pushSettings": "Enviar ajustes", "pushSettingsFailed": "Error al enviar ajustes", "pushSettingsSuccess": "Ajustes enviados correctamente", - "pushing": "Enviando…", "reachableUrl": "URL accesible / Nombre de host", "readOnly": "Solo lectura", "refresh": "Actualizar", - "refreshStatus": "Actualizar estado", "refreshing": "Actualizando…", - "registerFailed": "No se pudo registrar el nodo", + "refreshStatus": "Actualizar estado", "registered": "Nodo «{{name}}» registrado", - "registeredCount": "{{count}} registrado(s)", + "registeredCount_one": "", + "registeredCount_other": "", + "registerFailed": "No se pudo registrar el nodo", "remote": "Remoto", "removeButton": "Eliminar", "removed": "Nodo eliminado", @@ -3674,18 +3714,6 @@ "sectionSettingsSync": "Sincronización de ajustes", "sectionSyncHistory": "Historial de sincronización", "startButton": "Iniciar", - "status": { - "connecting": "Conectando", - "creating": "Creando", - "deleting": "Eliminando", - "error": "Error", - "exited": "Finalizado", - "offline": "Desconectado", - "online": "En línea", - "recreating": "Recreando", - "running": "En ejecución", - "stopped": "Detenido" - }, "statusConnecting": "Conectando", "statusError": "Error", "statusOffline": "Sin conexión", @@ -3699,10 +3727,10 @@ "syncAuthFailed": "Error al sincronizar autenticación", "syncAuthSuccess": "Credenciales de autenticación sincronizadas correctamente", "syncDifferences": "Diferencias:", - "syncLastSync": "Última sincronización:", - "syncNeverSynced": "Nunca sincronizado", "synced": "Sincronizado", "syncing": "Sincronizando…", + "syncLastSync": "Última sincronización:", + "syncNeverSynced": "Nunca sincronizado", "total": "Total", "type": { "local": "Local", @@ -3722,6 +3750,18 @@ "viewLogsButton": "Ver registros", "yes": "Sí" }, + "nodeStatus": { + "local": "Local" + }, + "nodeSync": { + "error": { + "authSyncFailed": "Error en la sincronización de autenticación", + "failedToFetchStatus": "No se pudo obtener el estado de sincronización", + "pullFailed": "Error al obtener la configuración", + "pushFailed": "Error al enviar la configuración", + "someRequestsFailed": "Algunas solicitudes de estado de sincronización fallaron" + } + }, "onboarding": { "authToken": "Token de autenticación (opcional)", "continue": "Continuar", @@ -3736,8 +3776,8 @@ "remoteServer": "Servidor remoto", "resumeOnboarding": "Reanudar onboarding", "saving": "Guardando…", - "scanQr": "Escanear código QR", "scanning": "Escaneando…", + "scanQr": "Escanear código QR", "serverUrl": "URL del servidor", "serverUrlPlaceholder": "https://tu-host-fusion", "stepContinue": "paso. Continúa donde lo dejaste para completar la configuración de tu panel.", @@ -3797,10 +3837,10 @@ "companyHelp": "Selecciona una empresa de Paperclip.", "companyIdRequired": "Se requiere el ID de la empresa para crear una clave API de Paperclip.", "companyLabel": "Empresa", - "connectToPopulate": "Conectar para completar", "connected": "Conectado.", "connectedAsAgent": "Conectado como {{agentName}}{{companyInfo}}.", "connectionModeAriaLabel": "Modo de conexión de Paperclip", + "connectToPopulate": "Conectar para completar", "description": "Conduce un agente de Paperclip (empleado) en una empresa de Paperclip. Cada indicación envía una solicitud en forma de tarea; Paperclip aplica la gobernanza, presupuestos y aprobaciones. Espera una latencia de segundos a minutos por turno.", "docsLink": "Documentos de Paperclip", "githubLink": "GitHub", @@ -3808,16 +3848,6 @@ "goalIdLabel": "ID de objetivo (opcional)", "mintButton": "Crear clave de API a través de paperclipai", "mintFailed": "Error al crear: {{reason}}. Ejecuta primero `paperclipai onboard` si tu CLI no está autenticada.", - "mode": { - "issue-per-prompt": "Problema por indicación", - "rolling-issue": "Problema continuo (predeterminado)", - "wakeup-only": "Solo activación (avanzado)" - }, - "modeHelp": { - "issue-per-prompt": "Cada mensaje crea un nuevo ticket de Paperclip de nivel superior. Máxima claridad; tiende a saturar el tablero.", - "rolling-issue": "Un ticket Paperclip por sesión de Fusion; los mensajes posteriores se agregan como comentarios. La experiencia más parecida a un chat.", - "wakeup-only": "Sin efectos secundarios en tickets; el mensaje se entrega solo a través del payload de activación. Requiere que la plantilla de mensajes del agente sepa manejar un despertar impulsado por payload." - }, "modeLabel": "Modo de conversación", "name": "Paperclip", "noAgentsDiscovered": "Sin agentes descubiertos", @@ -3860,13 +3890,13 @@ "filterSkills": "Habilidades:", "filterThemes": "Temas:", "installFailed": "No se pudo instalar el paquete: {{error}}", - "installSuccess": "Paquete instalado exitosamente", "installing": "Instalando…", + "installSuccess": "Paquete instalado exitosamente", "loadExtensionsFailed": "No se pudieron cargar las extensiones: {{error}}", - "loadSettingsFailed": "No se pudieron cargar los parámetros de Pi: {{error}}", "loading": "Cargando parámetros de Pi…", "loadingExtensions": "Cargando extensiones…", "loadingFailed": "No se pudieron cargar los parámetros de Pi.", + "loadSettingsFailed": "No se pudieron cargar los parámetros de Pi: {{error}}", "noExtensions": "No se descubrieron extensiones.", "noPackages": "Ningún paquete configurado.", "noPackagesHelp": "Añada una fuente de paquete arriba para comenzar.", @@ -3876,8 +3906,8 @@ "refreshExtensions": "Actualizar extensiones", "reinstallButton": "Reinstalar habilidad de Fusion", "reinstallFailed": "No se pudo reinstalar la habilidad de Fusion: {{error}}", - "reinstallSuccess": "Habilidad de Fusion reinstalada exitosamente", "reinstalling": "Reinstalando Fusion…", + "reinstallSuccess": "Habilidad de Fusion reinstalada exitosamente", "removeFailed": "No se pudo eliminar el paquete: {{error}}", "removePackage": "Eliminar paquete", "removePackageLabel": "Eliminar paquete {{label}}", @@ -3893,9 +3923,9 @@ "updateSettingsFailed": "No se pudieron actualizar los parámetros: {{error}}" }, "planning": { - "addSubtask": "Agregar subtarea", "additionalComments": "Comentarios adicionales (opcional)", "additionalCommentsPlaceholder": "Agrega contexto o dirección adicional...", + "addSubtask": "Agregar subtarea", "advancedSettings": "Configuración avanzada de planificación", "aiThinking": "La IA está pensando...", "archiveSession": "Archivar sesión", @@ -3910,10 +3940,10 @@ "branchNameRequired": "Se requiere un nombre de rama para esta estrategia.", "branchProjectDefault": "Usar rama del proyecto/predeterminada", "branchStrategy": "Estrategia de rama", - "breakIntoTasks": "Dividir en tareas", - "breakIntoTasksTitle": "Dividir el plan en múltiples tareas con dependencias", "breakdownSubheading": "Revisa y edita las subtareas generadas a partir de tu plan. Ajusta títulos, descripciones, tamaños, prioridades y dependencias antes de crear.", "breakingDown": "Desglosando...", + "breakIntoTasks": "Dividir en tareas", + "breakIntoTasksTitle": "Dividir el plan en múltiples tareas con dependencias", "collapse": "Contraer", "continue": "Continuar", "createSingleTask": "Crear tarea única", @@ -3977,11 +4007,15 @@ "questionsLabel": "Preguntas", "reconnecting": "Reconectando…", "refineFurther": "Refinar más", - "relativeTimeDays": "hace {{count}}d", - "relativeTimeHours": "hace {{count}}h", + "relativeTimeDays_one": "", + "relativeTimeDays_other": "", + "relativeTimeHours_one": "", + "relativeTimeHours_other": "", "relativeTimeJustNow": "ahora mismo", - "relativeTimeMinutes": "hace {{count}}m", - "relativeTimeWeeks": "hace {{count}}sem", + "relativeTimeMinutes_one": "", + "relativeTimeMinutes_other": "", + "relativeTimeWeeks_one": "", + "relativeTimeWeeks_other": "", "remove": "Eliminar", "retryFailed": "El reintento falló. Por favor, inténtalo de nuevo.", "retrying": "Reintentando...", @@ -4024,30 +4058,13 @@ }, "plugins": { "addItem": "Agregar elemento", - "agentBrowser": { - "groupBrowser": "Navegador", - "groupGeneral": "General", - "groupPromptContributions": "Contribuciones de prompt", - "groupSkills": "Habilidades", - "labelAllowedDomains": "Dominios permitidos", - "labelCommandTimeoutMs": "Tiempo de espera de comando (ms)", - "labelEnabled": "Habilitar navegador de agente", - "labelHeadlessMode": "Modo sin cabeza", - "labelInstallChannel": "Canal de instalación", - "labelPromptExecutorSystem": "Prompt de sistema del ejecutor", - "labelPromptExecutorTask": "Prompt de tarea del ejecutor", - "labelPromptHeartbeat": "Prompt de latido", - "labelPromptReviewer": "Prompt del revisor", - "labelPromptTriage": "Prompt de triaje", - "labelSkillExposure": "Exposición de habilidades" - }, "aiScanDisabled": "Análisis IA al cargar deshabilitado", "aiScanEnabled": "Análisis IA al cargar habilitado", "aiScanHint": "Activar esto solo actualiza la configuración. Usa Reescanear y recargar para ejecutarlo ahora.", "author": "Autor:", "backToList": "Volver a la lista de plugins", - "builtinInstallFailed": "Error al instalar {{name}}: {{error}}", "builtinInstalledGlobally": "{{name}} instalado globalmente", + "builtinInstallFailed": "Error al instalar {{name}}: {{error}}", "builtinMetadataOnly": "Solo metadatos integrados", "builtinNoPackage": "{{name}} está integrado y aún no tiene un paquete instalable", "builtinPluginRecommendations": "Recomendaciones de plugins integrados", @@ -4057,34 +4074,35 @@ "checkingSetup": "Verificando configuración…", "componentUnavailable": "Componente del complemento no disponible", "couldNotResolve": "El panel no pudo resolver esta superficie de complemento del registro de host estático.", + "disabledForProject": "{{name}} deshabilitado para este proyecto", "disableInProject": "Deshabilitar en el proyecto", "disablePlugin": "Deshabilitar {{name}}", "disablePluginFailed": "Error al deshabilitar el plugin: {{error}}", - "disabledForProject": "{{name}} deshabilitado para este proyecto", "droidOnboardingTip": "Consejo: Habilita Droid CLI para reutilizar tu suscripción de Factory AI sin agregar una clave API.", "droidRecommendDesc": "Usa tu sesión local de Droid CLI como proveedor de IA en Fusion.", "droidRecommendTitle": "Habilitar Droid CLI", "enableAiScanBeforeLoad": "Habilitar análisis IA antes de cargar/recargar", "enableAiSecurityScan": "Habilitar análisis de seguridad IA al cargar", + "enabledForProject": "{{name}} habilitado para este proyecto", "enableFailed": "Error al habilitar {{name}}: {{error}}", "enableInProject": "Habilitar en el proyecto", "enablePlugin": "Habilitar {{name}}", "enablePluginFailed": "Error al habilitar el plugin: {{error}}", - "enabledForProject": "{{name}} habilitado para este proyecto", "experimental": "Experimental", - "findings": "Hallazgos ({{count}})", + "findings_one": "", + "findings_other": "", "homepage": "Página de inicio:", "install": "Instalar", + "installedGlobally": "Plugin instalado globalmente", + "installedPlugins": "Plugins instalados", "installFailed": "Error al instalar el plugin: {{error}}", "installHint": "Navega a la raíz del paquete del plugin (contiene manifest.json) o un directorio dist compilado.", + "installing": "Instalando…", "installNamed": "Instalar {{name}}", "installPathPlaceholder": "Ruta absoluta al directorio del plugin o carpeta dist", "installPathRequired": "Por favor ingresa una ruta de plugin", "installPluginGlobally": "Instalar plugin globalmente", "installSetup": "Instalar configuración", - "installedGlobally": "Plugin instalado globalmente", - "installedPlugins": "Plugins instalados", - "installing": "Instalando…", "loadFailed": "Error al cargar los plugins: {{error}}", "loading": "Cargando…", "loadingPlugins": "Cargando plugins…", @@ -4098,8 +4116,8 @@ "refresh": "Actualizar", "refreshPluginList": "Actualizar lista de plugins", "reload": "Recargar", - "reloadFailed": "Error al recargar el plugin: {{error}}", "reloaded": "{{name}} recargado", + "reloadFailed": "Error al recargar el plugin: {{error}}", "reloading": "Recargando…", "removeItem": "Eliminar elemento", "rescanAndReload": "Reescanear y recargar", @@ -4109,11 +4127,11 @@ "saveSettingsFailed": "Error al guardar la configuración: {{error}}", "securityScan": "Análisis de seguridad", "selectOption": "Seleccionar…", - "settingUp": "Configurando…", "settings": "Configuración", "settingsSaved": "Configuración guardada", - "setupInstallFailed": "Error al instalar la configuración de {{name}}: {{error}}", + "settingUp": "Configurando…", "setupInstalled": "Configuración de {{name}} instalada", + "setupInstallFailed": "Error al instalar la configuración de {{name}}: {{error}}", "setupReady": "Configuración lista", "setupRequired": "Configuración requerida", "startPluginToCheckSetup": "Inicia el plugin para verificar la configuración", @@ -4121,11 +4139,11 @@ "statusInstalled": "Instalado", "statusNotInstalled": "No instalado", "uninstallConfirm": "¿Estás seguro de que quieres desinstalar \"{{name}}\" globalmente (todos los proyectos)?", + "uninstalledGlobally": "{{name}} desinstalado globalmente", "uninstallFailed": "Error al desinstalar el plugin: {{error}}", "uninstallGlobally": "Desinstalar globalmente", "uninstallGloballyTitle": "Desinstalar globalmente", "uninstallTitle": "Desinstalar plugin globalmente", - "uninstalledGlobally": "{{name}} desinstalado globalmente", "unknownError": "error desconocido", "updateFailed": "Error al actualizar el plugin: {{error}}", "version": "Versión:" @@ -4151,7 +4169,6 @@ "createPr": "Crear PR", "createTitle": "Crear solicitud de extracción", "dismissError": "Descartar error de PR", - "loadingMetadata": "Cargando metadatos de RP…", "noConflicts": "Sin conflictos de fusión detectados.", "preflightChecks": "Comprobaciones previas al vuelo", "previewTitle": "Vista previa de diferencias y commits", @@ -4176,15 +4193,19 @@ "confirm": "Confirmar", "confirmRemove": "Confirmar eliminación", "confirmRemoveProject": "Confirmar eliminación del proyecto", - "daysAgo": "Hace {{count}}d", - "hoursAgo": "Hace {{count}}h", + "daysAgo_one": "", + "daysAgo_other": "", + "hoursAgo_one": "", + "hoursAgo_other": "", "justNow": "Hace poco", "lastActivity": "Última actividad:", - "minutesAgo": "Hace {{count}}m", - "moreItems": "+{{count}} más", + "minutesAgo_one": "", + "minutesAgo_other": "", + "moreItems_one": "", + "moreItems_other": "", "never": "Nunca", - "noHealthData": "No hay datos de salud disponibles", "nodeAvailability": "Disponibilidad de nodo del proyecto", + "noHealthData": "No hay datos de salud disponibles", "open": "Abrir", "openProject": "Abrir proyecto", "pause": "Pausar", @@ -4199,20 +4220,13 @@ "emptyHint": "Intente con una ruta base diferente o agregue un proyecto manualmente", "noDbWarning": "No se encontró la base de datos fn - se inicializará", "registerAll": "Registrar todo", - "registerSelected": "Registrar seleccionado ({{count}})", "registering": "Registrando...", - "selectAll": "Seleccionar todo ({{count}})", - "selectedCount": "{{count}} seleccionado(s)" - }, - "projectSelector": { - "allProjects": "Todos los proyectos", - "ariaLabel": "Seleccionar proyecto", - "clearSearch": "Limpiar búsqueda", - "noResults": "No hay proyectos que coincidan con tu búsqueda", - "recent": "Reciente", - "searchPlaceholder": "Buscar proyectos...", - "selectProject": "Seleccionar proyecto", - "viewAll": "Ver todos los proyectos" + "registerSelected_one": "", + "registerSelected_other": "", + "selectAll_one": "", + "selectAll_other": "", + "selectedCount_one": "", + "selectedCount_other": "" }, "projects": { "actions": { @@ -4237,9 +4251,9 @@ "filterByNode": "Filtrar por nodo", "filterErrored": "Errores", "filterPaused": "En pausa", + "nodesLabel": "Nodos", "noMatch": "Ningún proyecto coincide con el filtro actual", "noProjectsFound": "No se encontraron proyectos", - "nodesLabel": "Nodos", "setup": { "success": "El proyecto {{name}} se registró correctamente" }, @@ -4254,13 +4268,24 @@ "title": "Proyectos", "totalLabel": "Total" }, + "projectSelector": { + "allProjects": "Todos los proyectos", + "ariaLabel": "Seleccionar proyecto", + "clearSearch": "Limpiar búsqueda", + "noResults": "No hay proyectos que coincidan con tu búsqueda", + "recent": "Reciente", + "searchPlaceholder": "Buscar proyectos...", + "selectProject": "Seleccionar proyecto", + "viewAll": "Ver todos los proyectos" + }, "providers": { "actions": { "addModel": "+ Agregar modelo", + "detecting": "Detectando…", "detectModels": "Detectar modelos", "detectModelsTitle": "Llamar al endpoint /models del proveedor para descubrir los modelos disponibles", - "detecting": "Detectando…", - "removeModel": "Eliminar modelo", + "removeModel_one": "", + "removeModel_other": "", "save": "Guardar proveedor", "saving": "Guardando..." }, @@ -4280,9 +4305,9 @@ "noModels": "No se encontraron modelos. El proveedor puede requerir una clave API.", "urlRequired": "Se requiere una URL base para detectar modelos." }, + "detecting": "Detectando…", "detectModels": "Detectar modelos", "detectTitle": "Detección automática de modelos desde el punto final /models del proveedor", - "detecting": "Detectando…", "editLabel": "Editar {{name}}", "failedDelete": "Error al eliminar el proveedor.", "failedDetect": "Error al detectar modelos", @@ -4297,6 +4322,7 @@ "maxTokens": "Tokens máximos", "modelId": "ID del modelo", "modelName": "Nombre para mostrar", + "modelNameLabel": "", "models": "Modelos", "name": "Nombre para mostrar", "reasoning": "Razonamiento" @@ -4360,8 +4386,10 @@ "p95": "P95", "p95Raw": "P95 sin procesar: {{value}} ms", "reason": "Razón: {{reason}}", - "sampleCount": "Número de muestras: {{count}}", - "samples": "Muestras: {{count}}" + "sampleCount_one": "", + "sampleCount_other": "", + "samples_one": "", + "samples_other": "" }, "failureRate": "Tasa de fracaso: {{rate}}", "heading": "Confiabilidad", @@ -4370,12 +4398,14 @@ "insufficientData": "Datos insuficientes — {{reason}}", "mergeAttempts": { "heading": "Intentos de fusión", - "histogramTotal": "Total del histograma: {{count}}", + "histogramTotal_one": "", + "histogramTotal_other": "", "max": "Máx", "mean": "Media", "moreStats": "Más estadísticas", "reason": "Razón: {{reason}}", - "tasksCounted": "Tareas contadas: {{count}}" + "tasksCounted_one": "", + "tasksCounted_other": "" }, "reason": "Razón: {{reason}}", "resetBaseline": "Restablecer línea de base: {{date}}", @@ -4413,11 +4443,11 @@ "enrichTaskButton": "Enriquecer tarea", "enrichTaskTitle": "Enriquecer tarea existente", "enterTaskId": "Ingrese ID de tarea", + "exportedFile": "Exportado {{filename}}", "exportFailed": "La exportación falló", "exportHtml": "Exportar HTML", "exportJson": "Exportar JSON", "exportMd": "Exportar MD", - "exportedFile": "Exportado {{filename}}", "findingLabel": "Hallazgo:", "loadingRuns": "Cargando ejecuciones…", "loadingTasks": "Cargando tareas…", @@ -4434,11 +4464,6 @@ "priorityLow": "Bajo", "priorityNormal": "Normal", "priorityUrgent": "Urgente", - "providerGitHub": "GitHub", - "providerLlmSynthesis": "Síntesis LLM", - "providerLocalDocs": "Docs locales", - "providerPageFetch": "Obtención de página", - "providerWebSearch": "Búsqueda web", "providersLabel": "Proveedores", "queryLabel": "Consulta", "runCancelled": "Ejecución cancelada", @@ -4461,7 +4486,8 @@ "viewLabel": "Vista de investigación" }, "routine": { - "andMore": "…y {{count}} más", + "andMore_one": "", + "andMore_other": "", "delete": "Eliminar", "deleteMessage": "¿Eliminar la rutina {{name}}? Esta acción no se puede deshacer.", "deleteName": "Eliminar {{name}}", @@ -4474,11 +4500,13 @@ "enableName": "Habilitar {{name}}", "resultFailed": "Fallido", "resultSuccess": "Exitoso", - "runHistory": "Historial de ejecución ({{count}})", + "runHistory_one": "", + "runHistory_other": "", "runNameNow": "Ejecutar {{name}} ahora", - "runNow": "Ejecutar ahora", "running": "Ejecutando…", - "stepCount": "{{count}} paso" + "runNow": "Ejecutar ahora", + "stepCount_one": "", + "stepCount_other": "" }, "routing": { "cannotChangeWhileActive": "No se puede cambiar la anulación del nodo mientras la tarea está activa.", @@ -4494,11 +4522,6 @@ "overrideSection": "Anulación de nodo", "overrideSetTo": "Anulación establecida en", "overrideUpdated": "Anulación de nodo actualizada", - "policyLabel": { - "block": "Bloquear ejecución", - "fallback": "Volver a local", - "notConfigured": "No configurado" - }, "selectLabel": "Seleccionar nodo de ejecución", "source": { "noRouting": "Sin enrutamiento", @@ -4532,11 +4555,13 @@ "advancedMode": "Multi-paso", "advancedModeHelp": "Ejecutar varios pasos secuencialmente (comandos e indicaciones de IA)", "aiPromptType": "Prompt de IA", - "andMore": "…y {{count}} más", + "andMore_one": "", + "andMore_other": "", "apiEndpointHint": "Ruta del endpoint de API que activa esta rutina", "apiEndpointLabel": "Endpoint de API", "apiEndpointPlaceholder": "/api/routine/my-routine", - "automationCount": "{{count}} automatización{{plural}}", + "automationCount_one": "", + "automationCount_other": "", "cancelButton": "Cancelar", "catchUpPolicyHint": "Qué hacer cuando se omite una ejecución programada", "catchUpPolicyLabel": "Política de recuperación", @@ -4591,10 +4616,10 @@ "editTitle": "Editar programación", "emptyStateDescription": "Cree una automatización con un cronograma, webhook, API o disparador manual.", "enable": "Habilitar", - "enableName": "Habilitar {{name}}", "enabledHelp": "Cuando está deshabilitado, la programación no se ejecutará automáticamente", "enabledHint": "Cuando está deshabilitada, la rutina no se ejecutará automáticamente", "enabledLabel": "Habilitado", + "enableName": "Habilitar {{name}}", "errorApiEndpointRequired": "El endpoint de API es obligatorio", "errorCommandRequired": "El comando es obligatorio", "errorCronInvalid": "Formato cron inválido: se esperan 5 campos (p. ej. '0 */6 * * *')", @@ -4607,9 +4632,9 @@ "errorStepCommandRequired": "Paso {{n}}: el comando es obligatorio", "errorStepNameRequired": "Paso {{n}}: el nombre es obligatorio", "errorStepPromptRequired": "Paso {{n}}: el prompt es obligatorio", - "errorStepTaskDescRequired": "Paso {{n}}: la descripción de la tarea es obligatoria", "errorStepsEditing": "Guarda o cancela todas las ediciones de pasos antes de guardar la rutina", "errorStepsRequired": "Se requiere al menos un paso", + "errorStepTaskDescRequired": "Paso {{n}}: la descripción de la tarea es obligatoria", "errorTaskDescriptionRequired": "La descripción de la tarea es obligatoria", "errorTimeoutMin": "El tiempo de espera debe ser de al menos 1 segundo (1000 ms)", "errorWebhookPathRequired": "La ruta del webhook es obligatoria", @@ -4626,13 +4651,13 @@ "frequencyLabel": "Frecuencia", "global": "Global", "globalScope": "Automatizaciones globales (a nivel de usuario)", - "globalScopeTitle": "Alcance global", "globalScoped": "Este horario se creará en alcance global.", + "globalScopeTitle": "Alcance global", "loadRoutinesError": "Error al cargar rutinas", "manualTriggerInfo": "Esta rutina se activará manualmente desde el panel o la API.", "modeAriaLabel": "Modo de ejecución", - "modeLabel": "Modo de ejecución", "model": "Modelo", + "modeLabel": "Modo de ejecución", "modelConsistency": "El proveedor del modelo y la ID del modelo deben estar configurados o ambos deben estar vacíos", "modelDropdownLabel": "Modelo", "modelHelp": "Modelo de IA para este paso. Usa el predeterminado si no se selecciona.", @@ -4656,9 +4681,9 @@ "project": "Proyecto", "projectRequired": "Las entradas específicas del proyecto requieren un proyecto activo.", "projectScope": "Automatizaciones con alcance de proyecto", + "projectScoped": "Este horario se limitará al proyecto actual.", "projectScopeDisabled": "Seleccione un proyecto para habilitar el alcance del proyecto", "projectScopeTitle": "Alcance del proyecto", - "projectScoped": "Este horario se limitará al proyecto actual.", "prompt": "Prompt", "promptHelp": "Indicación de IA a ejecutar. Proporcione instrucciones claras para la tarea.", "promptHint": "Prompt de IA a ejecutar.", @@ -4675,10 +4700,11 @@ "routineSuccess": "\"{{name}}\" completado exitosamente", "routineUpdated": "Rutina actualizada", "runError": "Error al ejecutar rutina", - "runHistory": "Historial de ejecución ({{count}})", + "runHistory_one": "", + "runHistory_other": "", "runNameNow": "Ejecutar {{name}} ahora", - "runNow": "Ejecutar ahora", "running": "Ejecutando…", + "runNow": "Ejecutar ahora", "saveChanges": "Guardar cambios", "saveStep": "Guardar paso", "saving": "Guardando…", @@ -4695,15 +4721,16 @@ "simpleMode": "Simple", "simpleModeHelp": "Ejecutar un comando de shell único o indicación de IA", "stepCommandRequired": "Paso {{index}}: Se requiere comando", - "stepCount": "{{count}} paso", + "stepCount_one": "", + "stepCount_other": "", "stepName": "Nombre del paso", "stepNamePlaceholder": "p. ej. Ejecutar pruebas", "stepNameRequired": "Se requiere el nombre del paso", "stepPromptRequired": "Paso {{index}}: Se requiere el prompt", - "stepType": "Tipo de paso", "steps": "Pasos", "stepsEditing": "Por favor guarde o cancele todas las ediciones de pasos antes de guardar la programación", "stepsRequired": "Se requiere al menos un paso", + "stepType": "Tipo de paso", "targetColumn": "Columna de destino", "targetColumnHelp": "Columna donde se creará la nueva tarea", "targetColumnLabel": "Columna de destino", @@ -4780,7 +4807,8 @@ "saving": "Guardando...", "scriptAlreadyExists": "Ya existe un script con este nombre", "scriptCommandRequired": "El comando del script es obligatorio", - "scriptCount": "{{count}} script", + "scriptCount_one": "", + "scriptCount_other": "", "scriptCreated": "Script creado", "scriptDeleted": "Script eliminado", "scriptName": "Nombre del script", @@ -4852,20 +4880,17 @@ "failed": "Fallido", "headerAwaitingAndErrorPlural": "{{awaitingCount}} sesiones de IA necesitan tu entrada, {{errorCount}} fallida(s)", "headerAwaitingAndErrorSingular": "{{awaitingCount}} sesión de IA necesita tu entrada, {{errorCount}} fallida(s)", - "headerAwaitingPlural": "{{count}} sesiones de IA necesitan tu entrada", - "headerAwaitingSingular": "{{count}} sesión de IA necesita tu entrada", - "headerErrorPlural": "{{count}} sesiones de IA fallaron", - "headerErrorSingular": "{{count}} sesión de IA falló", + "headerAwaitingPlural_one": "", + "headerAwaitingPlural_other": "", + "headerAwaitingSingular_one": "", + "headerAwaitingSingular_other": "", + "headerErrorPlural_one": "", + "headerErrorPlural_other": "", + "headerErrorSingular_one": "", + "headerErrorSingular_other": "", "regionLabel": "Sesiones de IA que necesitan entrada o han fallado", "resume": "Reanudar", - "retry": "Reintentar", - "typeLabel": { - "milestoneInterview": "Entrevista de hito", - "missionInterview": "Entrevista de misión", - "planning": "Planificación", - "sliceInterview": "Entrevista de segmento", - "subtask": "Desglose de subtareas" - } + "retry": "Reintentar" }, "settings": { "actions": { @@ -4876,7 +4901,6 @@ "language": "Idioma", "languageAuto": "Automático", "languageAutoHint": "Seguir el idioma del navegador", - "languageHint": "Elige el idioma de la interfaz de {{brand}}.", "title": "Apariencia" }, "auth": { @@ -4930,8 +4954,8 @@ "general": { "learnMore": "Más información", "settingsSaved": "Configuración guardada", - "upToDate": "Estás actualizado ✓", - "updateAvailablePrefix": "v{{version}} disponible" + "updateAvailablePrefix": "v{{version}} disponible", + "upToDate": "Estás actualizado ✓" }, "header": { "discord": "Discord" @@ -4941,8 +4965,8 @@ "exportBtn": "Exportar", "exportTitle": "Exportar configuración a archivo JSON", "importBtn": "Importar", - "importTitle": "Importar configuración", "importing": "Importando…", + "importTitle": "Importar configuración", "loadingFile": "Cargando…", "reviewPrompt": "Revisa la configuración a importar:" }, @@ -4951,17 +4975,17 @@ "keepRemote": "Mantener remoto", "loading": "Cargando…", "memory": { - "compactSelectedFile": "Compactar archivo seleccionado", "compacting": "Compactando…", + "compactSelectedFile": "Compactar archivo seleccionado", "dreamCompleted": "Procesamiento de sueños completado", "dreamNow": "Soñar ahora", - "installQmd": "Instalar qmd", "installing": "Instalando…", + "installQmd": "Instalar qmd", "memoryCompacted": "Archivo de memoria compactado", "memorySaved": "Memoria guardada", "saveMemory": "Guardar memoria", - "testRetrieval": "Probar recuperación", - "testing": "Probando…" + "testing": "Probando…", + "testRetrieval": "Probar recuperación" }, "mergeManually": "Fusionar manualmente", "mobileNav": { @@ -4976,45 +5000,14 @@ "savePreset": "Guardar ajuste predefinido" }, "nav": { - "accountHeader": "Cuenta", - "agentPermissions": "Permisos del agente", - "appearance": "Apariencia", "aria": { "global": "Configuración global", "project": "Configuración de proyecto" }, - "authentication": "Autenticación", - "backups": "Copias de seguridad", - "commands": "Comandos", - "experimental": "Funciones experimentales", - "globalGeneral": "General", - "globalHeader": "Global", - "globalModels": "Modelos", - "hermesRuntime": "Hermes", - "memory": "Memoria", - "merge": "Fusión", - "nodeRouting": "Enrutamiento de nodos", - "nodeSync": "Sincronización de nodos", - "notifications": "Notificaciones", - "openclawRuntime": "OpenClaw", - "paperclipRuntime": "Paperclip", - "plugins": "Complementos", - "projectGeneral": "General del proyecto", - "projectHeader": "Proyecto", - "projectModels": "Modelos del proyecto", - "prompts": "Indicaciones", - "remote": "Acceso remoto", - "researchGlobal": "Valores predeterminados de investigación", - "researchProject": "Investigación", - "runtimesHeader": "Entornos de ejecución", - "scheduledEvals": "Evaluaciones programadas", - "scheduling": "Programación", - "secrets": "Secretos", "tooltip": { "global": "Compartido en todos los proyectos", "project": "Específico de este proyecto" - }, - "worktrees": "Árboles de trabajo" + } }, "notifications": { "sending": "Enviando…", @@ -5028,10 +5021,10 @@ "restarting": "Reiniciando…", "shortLivedTokenGenerated": "Token de corta duración generado", "startFresh": "Iniciar nuevo", - "startTunnel": "Iniciar túnel", "starting": "Iniciando…", - "stopTunnel": "Detener túnel", + "startTunnel": "Iniciar túnel", "stopping": "Deteniendo…", + "stopTunnel": "Detener túnel", "tunnelRestarted": "Túnel remoto reiniciado", "tunnelStarted": "Túnel remoto iniciado", "tunnelStopped": "Túnel remoto detenido", @@ -5039,8 +5032,8 @@ }, "resolveAllLocal": "Resolver todo: Mantener local", "resolveAllRemote": "Resolver todo: Mantener remoto", - "resolveFailed": "No se pudieron resolver los conflictos", "resolvedSuccess": "Conflictos de configuración resueltos exitosamente", + "resolveFailed": "No se pudieron resolver los conflictos", "resolving": "Resolviendo...", "scheduling": { "selectCurrentDir": "Seleccionar directorio actual", @@ -5068,46 +5061,11 @@ "aiSetupDescription": "Fusion usa modelos de IA para planificar, escribir y revisar código por ti. Conecta un proveedor de IA abajo para comenzar: puedes usar un servicio alojado o ingresar una clave API.", "allProvidersShown": "Todos los proveedores disponibles actualmente ya están mostrados arriba.", "allSet": "¡Todo listo!", - "apiKeyFormatError": "Las claves de {{providerName}} deben seguir este formato: {{hint}} (p. ej. {{example}})", "apiKeyFormatHint": "Formato: {{hint}}", "apiKeyHint": "Clave: {{keyHint}}", - "apiKeyLabel": { - "fallback": "Clave API", - "kimiCoding": "Clave API de Kimi", - "minimax": "Clave API de MiniMax", - "ollama": "Punto de acceso de Ollama", - "openai": "Clave API de OpenAI", - "openrouter": "Clave API de OpenRouter", - "zai": "Clave API de Zhipu AI" - }, - "apiKeyPlaceholder": { - "fallback": "Ingresa la clave API", - "kimiCoding": "Ingresa tu clave API de Kimi", - "minimax": "Ingresa tu clave API de MiniMax", - "zai": "Ingresa tu clave API de Zhipu AI" - }, "apiKeyRemoved": "Clave API eliminada", - "apiKeyRequired": "La clave API es requerida", "apiKeySaved": "✓ Clave API guardada", "apiKeySavedToast": "Clave API guardada", - "apiKeySetup": { - "fallback": "Ingresa tu clave API para este proveedor.", - "kimiCoding": "Crea tu clave API en la configuración de cuenta de la plataforma Moonshot.", - "minimax": "Genera una clave API desde la consola de desarrollador de la plataforma MiniMax.", - "ollama": "Ingresa la URL de tu punto de acceso de Ollama (por ejemplo http://localhost:11434).", - "openai": "Crea una clave API desde tu panel de OpenAI en la sección Claves API.", - "openrouter": "Crea una clave API desde la página de gestión de claves de tu cuenta OpenRouter.", - "zai": "Crea una clave API en la configuración de cuenta de la plataforma abierta de Zhipu AI." - }, - "apiKeyUsage": { - "fallback": "Usado por Fusion para autenticar solicitudes a este proveedor", - "kimiCoding": "Usado para modelos Kimi/Moonshot AI en ejecución y planificación de tareas", - "minimax": "Usado para modelos MiniMax en la ejecución de tareas", - "ollama": "Se conecta a tu instancia local de Ollama", - "openai": "Usado para modelos GPT en ejecución y planificación de tareas", - "openrouter": "Enruta a múltiples proveedores de modelos de IA a través de una sola clave", - "zai": "Usado para modelos GLM en la ejecución de tareas" - }, "ariaDismissRecommendations": "Descartar recomendaciones", "ariaSetupRecommendations": "Recomendaciones de configuración", "authCodeAlreadySubmitted": "Ese código de autorización ya fue enviado. Esperando inicio de sesión…", @@ -5158,13 +5116,13 @@ "connectAiProvider": "Conectar proveedor de IA", "connectAiProviderDesc": "Conecte un proveedor de IA para habilitar agentes de IA para la planificación de tareas y la generación de código", "connectAnyway": "Conectar de todos modos", + "connectedProviders": "Proveedores conectados", "connectGitHub": "Conectar GitHub", "connectGitHubAnytime": "No te preocupes si no estás listo — conecta GitHub en cualquier momento desde Ajustes → Autenticación.", "connectGitHubButton": "Conectar GitHub", "connectGitHubDesc": "Conecte GitHub para importar problemas y rastrear solicitudes de extracción", "connectOauthOptional": "Conectar OAuth (opcional)", "connectRemoteServer": "Conectar servidor Fusion remoto", - "connectedProviders": "Proveedores conectados", "continueToLogin": "Continuar al inicio de sesión", "continueWithGhCli": "Continuar con autenticación gh CLI →", "continueWithoutGitHub": "Continuar sin GitHub →", @@ -5229,10 +5187,10 @@ "githubSkipped": "Se omitió GitHub. Puedes conectarte en cualquier momento desde Ajustes → Autenticación.", "goBackToStep": "Volver a {{label}}", "goToDashboard": "Ir al panel", - "howDoIChooseModel": "¿Cómo elijo un modelo?", - "howDoIChooseModelBody": "Los modelos varían en velocidad, capacidad y costo. Un buen valor predeterminado suele ser el modelo más reciente de tu proveedor conectado. Siempre puedes cambiarlo en Ajustes.", "howDoesLoginWork": "¿Cómo funciona el inicio de sesión?", "howDoesLoginWorkBody": "Al hacer clic en Iniciar sesión se abre el sitio del proveedor en una nueva pestaña. Una vez que autorices a Fusion, esta página detectará la conexión automáticamente. Tus credenciales nunca se almacenan en Fusion.", + "howDoIChooseModel": "¿Cómo elijo un modelo?", + "howDoIChooseModelBody": "Los modelos varían en velocidad, capacidad y costo. Un buen valor predeterminado suele ser el modelo más reciente de tu proveedor conectado. Siempre puedes cambiarlo en Ajustes.", "importFromGitHub": "Importar desde GitHub", "importFromGitHubSubtitle": "Convierte issues de GitHub en tareas que puedes rastrear aquí", "inProcess": "En proceso", @@ -5294,25 +5252,11 @@ "projectRequired": "Se requiere un proyecto antes de que las acciones de primera tarea estén disponibles.", "projectSelected": "Proyecto seleccionado — la creación de tareas y las importaciones están disponibles.", "projectSetupDescription": "Elige tu primer proyecto antes de crear o importar tareas. Puedes registrar un directorio local existente o clonar una URL de repositorio de GitHub a través del asistente de configuración.", - "providerDesc": { - "anthropic": "Modelos Claude — excelentes en razonamiento, análisis y código", - "fallback": "Proveedor de IA — conéctate para empezar a usar modelos de IA", - "gemini": "Modelos Gemini — multimodales con gran capacidad de razonamiento", - "google": "Modelos Gemini — multimodales con gran capacidad de razonamiento", - "kimi": "Kimi de Moonshot AI — capacidades de contexto largo", - "kimiCoding": "Kimi de Moonshot AI — capacidades de contexto largo", - "minimax": "Modelos MiniMax — rentables para uso de alto volumen", - "moonshot": "Kimi de Moonshot AI — capacidades de contexto largo", - "ollama": "Ejecuta modelos de código abierto localmente en tu máquina", - "openai": "Modelos GPT — versátiles para una amplia variedad de tareas", - "openaiCodex": "Modelos Codex de OpenAI — optimizados para tareas de programación", - "openrouter": "OpenRouter — enruta solicitudes a través de múltiples proveedores de IA", - "zai": "Modelos GLM de Zhipu AI — sólido soporte multilingüe" - }, "providersConnectedSummary": "✓ {{connected}} de {{total}} proveedor(es) conectado(s)", - "providersSkippedSummary": "{{count}} proveedor(es) omitido(s)", - "providersSkippedSummary_one": "{{count}} proveedor omitido", - "providersSkippedSummary_other": "{{count}} proveedores omitidos", + "providersSkippedSummary_one_one": "", + "providersSkippedSummary_one_other": "", + "providersSkippedSummary_other_one": "", + "providersSkippedSummary_other_other": "", "quickStartProviders": "Proveedores de inicio rápido", "readinessAiProviderConnected": "{{name}} conectado — los agentes de IA pueden trabajar en las tareas", "readinessAiProviderLabel": "Proveedor de IA", @@ -5331,8 +5275,8 @@ "readinessSummaryHeader": "Resumen de configuración", "recommended": "Recomendado", "recommendedNextSteps": "Pasos siguientes recomendados", - "registerProject": "Registrar proyecto", "registering": "Registrando...", + "registerProject": "Registrar proyecto", "remoteServerNote": "Tu shell nativo necesita un perfil remoto activo para completar la transferencia al panel.", "remoteServerProfileSaved": "Perfil del servidor remoto guardado", "removeKey": "Eliminar clave", @@ -5346,9 +5290,9 @@ "retry": "Reintentar", "reviewStep": "Revisar {{label}}", "runtimeNode": "Nodo en tiempo de ejecución", + "savedProfileButFailedToActivate": "Se guardó el perfil pero no se pudo activar", "saveKey": "Guardar", "saveRemoteServer": "Guardar servidor remoto", - "savedProfileButFailedToActivate": "Se guardó el perfil pero no se pudo activar", "saving": "Guardando…", "savingKey": "Guardando…", "savingRemoteServer": "Guardando…", @@ -5361,9 +5305,9 @@ "setToken": "Establecer token", "setTokenContinue": "Establecer token y continuar", "setUpAi": "Configurar IA", - "setUpProject": "Configurar proyecto", "setupComplete": "¡Configuración completada!", "setupMode": "Modo de configuración", + "setUpProject": "Configurar proyecto", "setupWizardHint": "En el asistente de configuración, elige un directorio existente o pega una URL de clon de GitHub.", "skip": "Omitir", "skipForNow": "Omitir por ahora", @@ -5449,21 +5393,22 @@ "catalogUnavailable": "El catálogo no está disponible temporalmente. Por favor, inténtelo más tarde.", "closeDetail": "Cerrar detalles de habilidad", "closeView": "Cerrar vista de habilidades", - "disableSkill": "Deshabilitar {{name}}", "disabled": "Habilidad deshabilitada", + "disableSkill": "Deshabilitar {{name}}", "discovered": "descubiertas", - "discoveredCount": "{{count}} habilidades descubiertas", + "discoveredCount_one": "", + "discoveredCount_other": "", "discoveredSection": "Habilidades descubiertas", - "enableSkill": "Habilitar {{name}}", "enabled": "Habilidad habilitada", + "enableSkill": "Habilitar {{name}}", "filesLabel": "Archivos", "install": "Instalar", "installError": "Error al instalar habilidad", "installFailed": "Error al instalar {{name}}: {{message}}", - "installSkill": "Instalar {{name}}", - "installSuccess": "{{name}} instalado", "installing": "Instalando…", "installsCount": "{{count}} instalaciones", + "installSkill": "Instalar {{name}}", + "installSuccess": "{{name}} instalado", "loadCatalogError": "Error al cargar el catálogo", "loadContentError": "Error al cargar el contenido de la habilidad", "loadDiscoveredError": "Error al cargar habilidades descubiertas", @@ -5491,8 +5436,8 @@ "feedbackPlaceholder": "p. ej., 'Agregar más detalles sobre el manejo de errores', 'Dividir esto en pasos más pequeños', 'Incluir pruebas para los puntos finales de la API'...", "keyboardHint": "Presiona Ctrl+Enter (o Cmd+Enter) para guardar", "placeholder": "Ingresa la especificación de la tarea en Markdown...", - "requestRevision": "Solicitar revisión de IA", "requesting": "Solicitando…", + "requestRevision": "Solicitar revisión de IA", "revisionHelp": "Proporciona retroalimentación a la IA para mejorar esta especificación. La tarea se moverá a la fase de planificación para replanificación.", "revisionTitle": "Pedir a la IA que revise", "saving": "Guardando…", @@ -5514,12 +5459,14 @@ "dropTitle": "¿Descartar almacenamiento huérfano?", "failedToLoadDiff": "Error al cargar el diff", "failedToLoadOrphans": "Error al cargar los huérfanos", - "fileCount": "{{count}} archivos", + "fileCount_one": "", + "fileCount_other": "", "inspectDiff": "Inspeccionar diferencia", "loadingDiff": "Cargando diferencia…", "noDiffOutput": "Sin salida de diferencia disponible.", "noOrphans": "No se encontraron guardados automáticos de fusión huérfanos.", - "orphanCount": "{{count}} huérfanos", + "orphanCount_one": "", + "orphanCount_other": "", "shaLabel": "SHA", "title": "Recuperación de almacenamiento", "unknownSource": "Fuente desconocida" @@ -5592,7 +5539,8 @@ "untitled": "Sin título" }, "syncLog": { - "entryCount": "{{count}} entrada", + "entryCount_one": "", + "entryCount_other": "", "filterAll": "Todos", "filterAllNodes": "Todos los nodos", "filterDirection": "Dirección:", @@ -5624,11 +5572,12 @@ "errorLoadVitestSettings": "Error al cargar la configuración de vitest", "errorSaveVitestSettings": "Error al guardar la configuración de vitest", "footerRefreshFailed": "Última actualización fallida: {{error}}", + "killedProcesses_one": "", + "killedProcesses_other": "", "killThresholdInputAriaLabel": "Umbral de cierre (%)", "killThresholdLabel": "Umbral de cierre (%)", "killThresholdSliderAriaLabel": "Control deslizante del umbral de cierre (%)", "killVitest": "Terminar procesos de vitest", - "killedProcesses": "{{count}} procesos terminados", "lastAutoKill": "Último cierre automático: {{time}}", "loading": "Cargando estadísticas del sistema…", "notYet": "Aún no", @@ -5672,18 +5621,14 @@ "taskChanges": { "attributionFailed": "El conjunto de archivos aterrizados puede incluir commits foráneos (atribución no disponible).", "disableWordWrap": "Deshabilitar ajuste de palabras", - "emptyWorktreeHint": "El diferencial del árbol de trabajo en vivo está vacío. Mostrando las últimas rutas de archivo capturadas durante la ejecución — parches no disponibles.", "enableWordWrap": "Habilitar ajuste de palabras", "error": "Error al cargar cambios: {{error}}", - "executionFilesHint": "Estos son archivos capturados del árbol de trabajo durante la ejecución. Pueden diferir de los archivos que realmente llegaron a la rama principal. La diferencia respaldada por el linaje no está disponible para esta tarea.", "expandDiff": "Expandir a vista de diferencias a pantalla completa", "expandDiffView": "Expandir vista de diferencias", - "fileCount": "{{count}} archivo{{plural}} modificado(s).", - "filesChangedHeading": "Archivos modificados ({{count}})", - "landedFilesHint": "Estos son archivos capturados de los metadatos del commit fusionado. La diferencia respaldada por el linaje no está disponible para esta tarea.", + "filesChangedHeading_one": "", + "filesChangedHeading_other": "", "loadError": "Error al cargar cambios de tarea", "loading": "Cargando cambios...", - "merged": "Fusionado {{date}}", "mergedAt": "Fusionado {{date}}", "nextFile": "Archivo siguiente", "noExecutionModifications": "El agente no modificó ningún archivo durante la ejecución.", @@ -5694,7 +5639,6 @@ "noWorktree": "No hay árbol de trabajo disponible para esta tarea.", "noWorktreeHint": "Los cambios se mostrarán una vez que la tarea esté en progreso.", "previousFile": "Archivo anterior", - "statusUnknown": "estado desconocido", "summaryHint": "Resumen del commit final: {{files}} archivo{{plural}} modificado(s), +{{additions}} adiciones, -{{deletions}} eliminaciones. Cuenta solo el commit de fusión/compresión registrado, no la linaje completa de la tarea.", "toggleWordWrap": "Alternar ajuste de palabras", "unavailable": "Cambios de archivo detallados no disponibles." @@ -5703,6 +5647,19 @@ "actions": { "menuBtn": "Acciones" }, + "agent": { + "assignBtn": "Asignar agente", + "assignedUpdated": "Agente asignado actualizado", + "assignFailed": "Error al asignar el agente: {{error}}", + "label": "Agente", + "loadFailed": "Error al cargar los agentes: {{error}}", + "loadingAgents": "Cargando agentes...", + "noAgents": "No hay agentes disponibles", + "unassigned": "Agente desasignado", + "unassignFailed": "Error al desasignar el agente: {{error}}", + "unassignTitle": "Desasignar agente" + }, + "agentLink": "agente {{id}}", "ageStaleness": { "active": "Activo", "age": "Antigüedad", @@ -5713,24 +5670,11 @@ "title": "Antigüedad de la tarea", "warning": "Advertencia" }, - "agent": { - "assignBtn": "Asignar agente", - "assignFailed": "Error al asignar el agente: {{error}}", - "assignedUpdated": "Agente asignado actualizado", - "label": "Agente", - "loadFailed": "Error al cargar los agentes: {{error}}", - "loadingAgents": "Cargando agentes...", - "noAgents": "No hay agentes disponibles", - "unassignFailed": "Error al desasignar el agente: {{error}}", - "unassignTitle": "Desasignar agente", - "unassigned": "Agente desasignado" - }, - "agentLink": "agente {{id}}", "attachments": { "attachBtn": "Adjuntar captura de pantalla", "attached": "Captura de pantalla adjunta", - "deleteTitle": "Eliminar adjunto", "deleted": "Adjunto eliminado", + "deleteTitle": "Eliminar adjunto", "heading": "Adjuntos", "none": "(sin adjuntos)", "uploading": "Subiendo…" @@ -5749,6 +5693,8 @@ "reattachBtn": "Reconectar rama", "reattached": "Rama reconectada para {{id}} ({{branch}})", "reattachedResult": "{{branch}} reconectada ({{count}} commits por delante de {{base}}).", + "reattachedResult_one": "", + "reattachedResult_other": "", "reattaching": "Reconectando…", "skipped": "Reconexión de rama omitida para {{id}}: {{reason}}", "skippedResult": "Reconexión omitida: {{reason}}" @@ -5764,21 +5710,21 @@ "actionLeft": "dejada", "allowRecreation": "Permitir recreación posterior (desbloqueo de operador)", "allowRecreationDesc": "Permite a los agentes recrear este ID de tarea sin --force-resurrect. Deja sin marcar para mantener esta tarea como tombstoned.", + "archivedAfterUnlink": "{{id}} archivado tras desvincular referencias de linaje", "archiveInstead": "Archivar en su lugar", "archiveUnlinkPrompt": "¿Archivar igualmente desvinculando estas referencias primero?", - "archivedAfterUnlink": "{{id}} archivado tras desvincular referencias de linaje", "ariaLabel": "Eliminar tarea", "btn": "Eliminar", "closeIssue": "Cerrar issue", "confirm": "Eliminar", + "deletedAfterRemovingDeps": "{{id}} eliminado tras eliminar referencias de dependencia", + "deletedAfterUnlinkLineage": "{{id}} eliminado tras desvincular referencias de linaje", + "deletedToast": "{{id}} eliminado{{suffix}}", "deleteIssue": "Eliminar issue", "deleteLinkedIssueMessage": "¿Eliminar {{issueRef}} en GitHub, o dejarlo sin cambios?", "deleteLinkedIssueTitle": "Eliminar issue de GitHub vinculada", "deleteUnlinkDepsPrompt": "¿Eliminar igualmente eliminando estas referencias de dependencia primero?", "deleteUnlinkLineagePrompt": "¿Eliminar igualmente desvinculando estas referencias primero?", - "deletedAfterRemovingDeps": "{{id}} eliminado tras eliminar referencias de dependencia", - "deletedAfterUnlinkLineage": "{{id}} eliminado tras desvincular referencias de linaje", - "deletedToast": "{{id}} eliminado{{suffix}}", "forceDeleteTitle": "Forzar eliminación de tarea", "issueSuffix": "y {{action}} la issue {{ref}}", "leaveUnchanged": "Dejar sin cambios", @@ -5816,8 +5762,8 @@ "autosaveHint": "Los cambios se guardan automáticamente al editar", "autosaving": "Guardado automático…", "nodeOverrideLocked": "La anulación del nodo de ejecución está bloqueada mientras una tarea está activa/en progreso.", - "saveFailed": "Error al guardar", "saved": "Guardado", + "saveFailed": "Error al guardar", "saving": "Guardando…", "sourceExternalIdPlaceholder": "Identificador del issue", "sourceIssueHint": "Deja todos los campos vacíos para borrar los metadatos del issue de origen.", @@ -5892,7 +5838,8 @@ "activityHeading": "Actividad", "agentLog": "Registro del agente", "noActivity": "(sin actividad)", - "truncated": "Mostrando las {{count}} entradas de actividad más recientes." + "truncated_one": "", + "truncated_other": "" }, "longestTimingEvent": "Evento de temporización más largo", "longestWorkflowStep": "Paso del flujo de trabajo más largo", @@ -5908,8 +5855,8 @@ "backToInProgress": "Volver a En progreso", "cancelMove": "Cancelar movimiento", "keepProgress": "Conservar progreso", - "moveTo": "Mover a {{column}}", "movedTo": "Movido a {{column}}", + "moveTo": "Mover a {{column}}", "preserveProgressMessage": "Esta tarea tiene pasos completados. ¿Conservar el progreso antes de mover?", "preserveProgressTitle": "¿Conservar el progreso?", "resetProgress": "Restablecer progreso", @@ -5920,9 +5867,9 @@ "actions": "Elige Archivar para mover esta tarea a archivadas, o Conservar para continuar con esta tarea.", "archiveBtn": "Archivar", "archiveConfirm": "Archivar", + "archived": "{{id}} archivado", "archiveMessage": "¿Archivar {{id}} como duplicado de {{duplicateOf}}?", "archiveTitle": "Archivar tarea casi duplicada", - "archived": "{{id}} archivado", "copy": "Esta tarea parece ser un casi-duplicado de", "headline": "Posible duplicado detectado", "keepBtn": "Conservar", @@ -5941,8 +5888,8 @@ "noSteps": "Sin pasos", "noTimedEvents": "Aún no se han registrado eventos cronometrados.", "noTokenUsage": "Aún no se ha registrado uso de tokens para esta tarea.", - "noWorkflowStepTimings": "Aún no hay tiempos de pasos completados del flujo de trabajo.", "notSet": "No configurado", + "noWorkflowStepTimings": "Aún no hay tiempos de pasos completados del flujo de trabajo.", "outputTokens": "Salida", "pause": { "pauseBtn": "Pausar", @@ -5959,9 +5906,9 @@ "rebuildMessage": "¿Reconstruir el plan para esta tarea? La tarea pasará a planificación.", "rebuildTitle": "Reconstruir plan", "rejectBtn": "Rechazar plan", + "rejected": "Plan rechazado — {{id}} devuelto a Planificación para replanificar", "rejectMessage": "¿Rechazar este plan? La especificación será descartada y regenerada.", "rejectTitle": "Rechazar plan", - "rejected": "Plan rechazado — {{id}} devuelto a Planificación para replanificar", "replanning": "Replanificando {{id}}…" }, "pr": { @@ -5983,31 +5930,18 @@ "progress": { "heading": "Progreso", "noSteps": "(sin pasos definidos)", - "stepCount": "{{count}}/{{total}} pasos" + "stepCount_one": "", + "stepCount_other": "" }, "provenance": { - "agent": "agente", - "api": "API", - "automation": "Automatización", - "chatSession": "Sesión de chat", - "cli": "CLI", "createdBy": "Creado por", - "createdVia": "Creado mediante", - "dashboard": "Panel", - "duplicate": "Duplicado", - "githubImport": "Importación de GitHub", - "openIssue": "Issue abierto", - "quickChat": "Chat rápido", - "recovery": "Recuperación", - "refinement": "Refinamiento", - "research": "Investigación", - "scheduledTask": "Tarea programada", - "workflowStep": "Paso de flujo de trabajo" + "createdVia": "Creado mediante" }, "recoveryState": "Estado de recuperación", "refine": { "btn": "Refinar", - "charCount": "{{count}}/2000 caracteres", + "charCount_one": "", + "charCount_other": "", "createBtn": "Crear tarea de refinamiento", "creating": "Creando...", "feedbackRequired": "Por favor ingresa comentarios que describan qué necesita refinamiento", @@ -6082,8 +6016,8 @@ "loading": "Cargando especificación…", "noPrompt": "(sin indicación)", "placeholder": "Ingresa la especificación de la tarea en Markdown...", - "requestRevisionBtn": "Solicitar revisión de IA", "requesting": "Solicitando…", + "requestRevisionBtn": "Solicitar revisión de IA", "revisionColumnError": "No se puede solicitar revisión: la tarea debe estar en la columna 'triage', 'todo', 'in-progress' o 'in-review'.", "revisionRequested": "Revisión de IA solicitada. La tarea se movió a planificación.", "saving": "Guardando…", @@ -6129,8 +6063,8 @@ "wallClockSinceFirst": "Tiempo real desde la primera ejecución", "workflow": { "loadFailed": "Error al cargar los resultados del flujo de trabajo: {{error}}", - "stepsUpdateFailed": "Error al actualizar los pasos del flujo de trabajo: {{error}}", - "stepsUpdated": "Pasos del flujo de trabajo actualizados" + "stepsUpdated": "Pasos del flujo de trabajo actualizados", + "stepsUpdateFailed": "Error al actualizar los pasos del flujo de trabajo: {{error}}" }, "workflowRuntime": "Tiempo de ejecución del flujo de trabajo", "workflowTimedSteps": "Pasos cronometrados del flujo de trabajo", @@ -6181,8 +6115,8 @@ "taskForm": { "addDependencies": "Agregar dependencias", "attachHint": "También puedes pegar imágenes o arrastrar y soltar", - "attachScreenshot": "Adjuntar captura de pantalla", "attachmentsLabel": "Adjuntos", + "attachScreenshot": "Adjuntar captura de pantalla", "autoMergeDefault": "Predeterminado (seguir configuración del proyecto)", "autoMergeDisabled": "Desactivado", "autoMergeEnabled": "Activado", @@ -6205,7 +6139,8 @@ "branchStrategyLabel": "Estrategia de rama", "collapseDescription": "Contraer descripción", "dependenciesLabel": "Dependencias", - "dependenciesSelected": "{{count}} seleccionado(s)", + "dependenciesSelected_one": "", + "dependenciesSelected_other": "", "descriptionLabel": "Descripción", "descriptionPlaceholder": "¿Qué hay que hacer?", "descriptionRefinedToast": "Descripción refinada con IA", @@ -6228,17 +6163,11 @@ "moveDown": "Bajar", "moveUp": "Subir", "noAvailableTasks": "No hay tareas disponibles", - "noModelsAvailable": "No hay modelos disponibles. Configura la autenticación en Ajustes.", "nodeDefaultOption": "Usar proyecto predeterminado / local", "nodeOverrideHint": "La sustitución de tarea tiene prioridad sobre el enrutamiento de nodo predeterminado del proyecto.", "nodeOverrideLabel": "Sustitución de nodo de ejecución", - "nodeStatusConnecting": "Conectando", - "nodeStatusError": "Error", - "nodeStatusOffline": "Sin conexión", - "nodeStatusOnline": "En línea", + "noModelsAvailable": "No hay modelos disponibles. Configura la autenticación en Ajustes.", "overridePreset": "Anular", - "phasePostMerge": "Post-fusión", - "phasePreMerge": "Pre-fusión", "planButton": "Planificar", "planningLabel": "Planificación", "planningModelLabel": "Modelo de planificación", @@ -6246,10 +6175,6 @@ "presetLabel": "Preajuste", "presetUseDefault": "Usar predeterminado", "priorityLabel": "Prioridad", - "priority_high": "Alta", - "priority_low": "Baja", - "priority_normal": "Normal", - "priority_urgent": "Urgente", "refineAddDetailsDesc": "Agregar detalles de implementación y contexto", "refineAddDetailsTitle": "Agregar detalles", "refineButton": "Refinar", @@ -6264,13 +6189,13 @@ "removeImage": "Eliminar imagen", "removeStep": "Eliminar", "reviewDefault": "Predeterminado (Auto — el triaje decide)", + "reviewerLabel": "Revisor", + "reviewerModelLabel": "Modelo revisor", "reviewLabel": "Revisión", "reviewLevel0": "0 — Ninguna", "reviewLevel1": "1 — Solo plan", "reviewLevel2": "2 — Plan y código", "reviewLevel3": "3 — Completo", - "reviewerLabel": "Revisor", - "reviewerModelLabel": "Modelo revisor", "searchTasksPlaceholder": "Buscar tareas…", "sharedBranchPlaceholder": "ej. clionboarding", "sharedFeatureBranchLabel": "Rama de funcionalidad compartida", @@ -6297,96 +6222,93 @@ "autoMergeOff": "Fusión automática desactivada", "autoMergeOn": "Fusión automática activada", "autoMergePreferenceUpdated": "Preferencia de fusión automática por tarea actualizada", - "completed": "Completado", "completedAtSep": " · Completado: {{timestamp}}", "createPr": "Crear solicitud de extracción", "effective": "Efectivo: {{label}}", "effectiveFrozen": "Efectivo: {{label}} — congelado al entrar en revisión", - "error": "Error", "errorSep": " · Error: {{message}}", "followDefault": "Seguir predeterminado", - "lastRefreshed": "Última actualización", "loadError": "Error al cargar los datos de revisión.", "loadingData": "Cargando datos de revisión…", "markdown": "Markdown", - "never": "Nunca", "noCapturedFeedback": "Aún no se ha capturado ningún comentario de revisión.", "noFeedbackDirect": "Sin comentarios de revisión aún — esta tarea no ha generado comentarios del agente revisor en modo directo.", "noReviewItems": "Sin elementos de revisión aún.", "perTaskAutoMerge": "Fusión automática por tarea", "plain": "Texto plano", - "prSummaryLine": "{{decision}} · {{count}} elemento(s) de revisión", + "prSummaryLine_one": "", + "prSummaryLine_other": "", "queueing": "Encolando…", "refresh": "Actualizar", "refreshDataFailed": "Error al actualizar los datos de revisión.", - "refreshFailed": "Error al actualizar", - "refreshSourceBackground": "Fondo", - "refreshSourceInitialLoad": "Carga inicial", - "refreshSourceManual": "Manual", - "refreshStatusLine": "{{status}} · Última actualización: {{timestamp}} · {{source}}", "refreshed": "Revisión actualizada", + "refreshFailed": "Error al actualizar", "refreshing": "Actualizando…", + "refreshStatusLine": "{{status}} · Última actualización: {{timestamp}} · {{source}}", "requestRevision": "Solicitar revisión", - "reviewerSummaryLine": "{{reviewer}} · {{count}} elemento(s) de revisión", + "reviewerSummaryLine_one": "", + "reviewerSummaryLine_other": "", "revisionQueueFailed": "Error al encolar la revisión", "revisionStarted": "Revisión de IA de la misma tarea iniciada desde el comentario de revisión seleccionado", - "selected": "Seleccionado", "selectedAt": "Seleccionado: {{timestamp}}", "showMarkdown": "Mostrar Markdown formateado", "showRawText": "Mostrar texto sin procesar", - "started": "Iniciado", "startedAtSep": " · Iniciado: {{timestamp}}", - "upToDate": "Actualizado", - "updateFailed": "Error al actualizar {{taskId}}: {{error}}" + "updateFailed": "Error al actualizar {{taskId}}: {{error}}", + "upToDate": "Actualizado" }, "tasks": { "addTaskPlaceholder": "Añadir una tarea...", "agent": "Agente", "agentLabel": "Agente", "archive": "Archivar", + "archived": "{{taskId}} archivado", + "archivedUnlinked": "{{taskId}} archivado tras desvincular las referencias de linaje", "archiveFailed": "Error al archivar {{taskId}}: {{error}}", "archiveLineageConflict": "{{taskId}} tiene elementos secundarios de linaje ({{children}}) que lo referencian como padre de origen.\n\n¿Archivar de todos modos desvinculando primero estas referencias?", "archiveTask": "Archivar tarea", - "archived": "{{taskId}} archivado", - "archivedUnlinked": "{{taskId}} archivado tras desvincular las referencias de linaje", "assignedTo": "Asignado a {{name}}", "attach": "Adjuntar", - "attachCount": "Adjuntar ({{count}})", - "attachFileFailed": "Error al adjuntar {{fileName}}: {{error}}", + "attachCount_one": "", + "attachCount_other": "", "attachedFile": "Se adjuntó {{fileName}} a {{taskId}}", + "attachFileFailed": "Error al adjuntar {{fileName}}: {{error}}", "awaitingApproval": "Esperando aprobación", "baseBranch": "Base", "blockedByTooltip": "Bloqueado por {{taskId}} (superposición de archivos)", "branch": "Rama", "branchMetadata": "Metadatos de rama", + "branchProgress": "", + "branchProgressTitle": "", "cancelMove": "Cancelar mover", "clearSelection": "Borrar selección", "closeIssue": "Cerrar issue", "collapse": "Contraer", + "createdByAgent": "Creado por un agente", + "createdByAgentNamed": "Creado por el agente: {{name}}", + "createdPr": "PR #{{number}} creada", "createFailed": "Error al crear la tarea", "createPr": "Crear PR", "createPrAriaLabel": "Crear pull request", "createPrTitle": "Crear una PR para esta tarea", "createTaskTitle": "Crear tarea", - "createdByAgent": "Creado por un agente", - "createdByAgentNamed": "Creado por el agente: {{name}}", - "createdPr": "PR #{{number}} creada", "creating": "Creando...", "decisionOnly": "solo decisión", "decisionOnlyTitle": "Tarea solo de decisión", "deleteConfirm": "¿Eliminar {{taskId}}?", + "deleted": "{{taskId}} eliminado{{suffix}}", + "deletedRemovedDeps": "{{taskId}} eliminado tras quitar las referencias de dependencia", + "deletedUnlinked": "{{taskId}} eliminado tras desvincular las referencias de linaje", "deleteFailed": "Error al eliminar {{taskId}}: {{error}}", "deleteIssue": "Eliminar issue", "deleteLinkedIssueMessage": "¿Eliminar {{issueLabel}} en GitHub o dejarlo sin cambios?", "deleteLinkedIssueTitle": "Eliminar el issue de GitHub vinculado", "deleteTask": "Eliminar tarea", "deleteTitle": "Eliminar tarea", - "deleted": "{{taskId}} eliminado{{suffix}}", - "deletedRemovedDeps": "{{taskId}} eliminado tras quitar las referencias de dependencia", - "deletedUnlinked": "{{taskId}} eliminado tras desvincular las referencias de linaje", "dependencyConflict": "{{taskId}} es una dependencia de {{dependentList}}.\n\n¿Eliminar de todos modos eliminando primero estas referencias de dependencia?", "deps": "Deps", - "depsCount": "{{count}} deps", + "depsCount_one": "", + "depsCount_other": "", "descriptionPlaceholder": "Descripción de la tarea", "descriptionRefined": "Descripción refinada con IA", "doneNoMerge": "Listo (sin fusión)", @@ -6406,11 +6328,14 @@ "fanoutEscalated": "Superposición escalada", "fanoutEscalationSuffix": " · escalado tras {{minutes}} min en la columna bloqueante", "fanoutHighFanoutSuffix": " (umbral de cuello de botella por superposición: {{threshold}})", - "fanoutStale": "{{count}} desactualizado(s)", - "fanoutTooltip": "Bloqueando {{count}} tarea(s) activa(s); cola blockedBy por superposición: {{queueCount}} pendiente(s){{highFanout}}{{escalation}}", + "fanoutStale_one": "", + "fanoutStale_other": "", + "fanoutTooltip_one": "", + "fanoutTooltip_other": "", "fast": "Rápido", "fastMode": "Modo rápido", - "filesChanged": "{{count}} archivo modificado", + "filesChanged_one": "", + "filesChanged_other": "", "forceDeleteTitle": "Forzar eliminación", "githubTrackingDefaultOff": "desactivado", "githubTrackingDefaultOn": "activado", @@ -6438,23 +6363,25 @@ "loadAgentsFailed": "Error al cargar agentes: {{msg}}", "loadAgentsFailedGeneric": "Error al cargar agentes", "loadDependencyFailed": "Error al cargar la dependencia {{depId}}", - "loadModelsFailed": "Error al cargar modelos", "loadingAgents": "Cargando agentes...", + "loadModelsFailed": "Error al cargar modelos", "missionBadgeTitle": "Misión: {{name}}", "modelExecutor": "Ejecutor", "modelPlan": "Planificar", "modelReviewer": "Revisor", "models": "Modelos", - "modelsCount": "{{count}} modelo", + "modelsCount_one": "", + "modelsCount_other": "", "moreOptions": "Más opciones", "move": "Mover", + "moved": "{{taskId}} movido a {{column}}", "moveFailed": "Error al mover {{taskId}}: {{error}}", "moveTask": "Mover tarea", - "moved": "{{taskId}} movido a {{column}}", "nearDuplicateTitle": "Posible duplicado aproximado de {{id}}", + "needsInput": "", "noAgentsAvailable": "No hay agentes disponibles", - "noExistingTasks": "No hay tareas existentes", "node": "Nodo", + "noExistingTasks": "No hay tareas existentes", "openRetryBreakdown": "Ver desglose de reintentos", "paused": "pausado", "pausedByAgent": "pausado por el agente", @@ -6482,7 +6409,8 @@ "resetProgress": "Restablecer progreso", "resetProgressMessage": "¿Restablecer el progreso de todos los pasos antes de mover esta tarea?", "resetProgressTitle": "¿Restablecer el progreso?", - "retriesAriaLabel": "{{count}} reintentos", + "retriesAriaLabel_one": "", + "retriesAriaLabel_other": "", "retry": "Reintentar", "retryFailed": "Error al reintentar {{taskId}}: {{error}}", "retrying": "Reintentando…", @@ -6498,17 +6426,18 @@ "showSteps": "Mostrar pasos", "stalled": "Detenido", "statusMergingFix": "Fusionando correcciones…", - "stepCount": "{{count}} paso", + "stepCount_one": "", + "stepCount_other": "", "stuck": "Atascado", "subtask": "Subtarea", "subtaskButtonTitle": "Desglosar en subtareas generadas por IA", "toggleFastMode": "Activar/desactivar modo de ejecución rápida", "unarchive": "Desarchivar", + "unarchived": "{{taskId}} desarchivado", "unarchiveFailed": "Error al desarchivar {{taskId}}: {{error}}", "unarchiveTask": "Desarchivar tarea", - "unarchived": "{{taskId}} desarchivado", - "updateFailed": "Error al actualizar {{taskId}}: {{error}}", "updated": "{{taskId}} actualizado", + "updateFailed": "Error al actualizar {{taskId}}: {{error}}", "uploadFailed": "Error al subir: {{files}}", "usingDefault": "Usando el predeterminado", "viewDependency": "Clic para ver {{depId}}", @@ -6539,37 +6468,14 @@ "statusReconnecting": "Reconectando..." }, "theme": { - "colorTheme": { - "default": "Predeterminado" - }, + "colorTheme": "", "colorThemeLabel": "Tema de color", "currentTheme": "Tema actual", - "dark": "Oscuro", - "darkMode": "Modo oscuro", - "fontSize": { - "Default": "Predeterminado", - "Large": "Grande", - "Largest": "Más grande", - "Small": "Pequeño" - }, + "fontSize": "", "fontSizeLabel": "Tamaño de fuente del panel", - "light": "Claro", - "lightMode": "Modo claro", "modeLabel": "Modo de tema", "resetButton": "Restablecer a los predeterminados", - "resetLabel": "Restablecer al tema predeterminado", - "system": "Sistema", - "systemMode": "Modo sistema" - }, - "time": { - "daysAgo": "hace {{n}}d", - "hoursAgo": "hace {{n}}h", - "inAMoment": "en un momento", - "inDays": "en {{n}}d", - "inHours": "en {{n}}h", - "inMinutes": "en {{n}}min", - "justNow": "ahora mismo", - "minutesAgo": "hace {{n}}min" + "resetLabel": "Restablecer al tema predeterminado" }, "todo": { "addItemPlaceholder": "Agregar elemento de tarea", @@ -6593,6 +6499,7 @@ "failedDeleteItemToast": "Error al eliminar el elemento de tareas pendientes", "failedDeleteList": "Error al eliminar la lista", "failedDeleteListToast": "Error al eliminar la lista de tareas pendientes", + "failedLoadLists": "", "failedRenameList": "Error al renombrar la lista", "failedRenameListToast": "Error al renombrar la lista de tareas pendientes", "failedReorderItems": "Error al reordenar los elementos", @@ -6653,7 +6560,8 @@ "resetsInDaysHours": "se restablece en {{days}}d {{hours}}h", "resetsInHours": "se restablece en {{hours}}h", "resetsInMinutes": "se restablece en {{mins}}m", - "showHidden": "Mostrar ocultos ({{count}})", + "showHidden_one": "", + "showHidden_other": "", "statusError": "Error", "statusNotConfigured": "No configurado", "title": "Uso", @@ -6663,9 +6571,9 @@ }, "workflow": { "add": "Añadir", + "adding": "Añadiendo...", "addTemplate": "Añadir plantilla", "addWorkflowStep": "Añadir paso del flujo de trabajo", - "adding": "Añadiendo...", "advisoryExplanation": "Los pasos de flujo de trabajo de asesoramiento señalaron mejoras no bloqueantes:", "agentPromptLabel": "Prompt del agente", "agentPromptPlaceholder": "Deja vacío para usar el refinamiento de IA", @@ -6719,6 +6627,7 @@ "gateModeAdvisoryHint": "Los fallos se registran como informativos y no bloquean la fusión.", "gateModeGate": "Barrera", "gateModeGateHint": "Los fallos bloquean la fusión y solicitan corrección.", + "graphEditor": "", "hideOutput": "Ocultar salida", "loadingBuiltInTemplates": "Cargando plantillas integradas...", "loadingResults": "Cargando resultados del flujo de trabajo…", @@ -6727,12 +6636,12 @@ "modalAriaLabel": "Pasos del flujo de trabajo", "modalTitle": "Pasos del flujo de trabajo", "modeAiPrompt": "Prompt de IA", - "modeScript": "Ejecutar script", "modelHintCustom": "Usando {{provider}}/{{modelId}}", "modelHintDefault": "Usando el modelo predeterminado global", "modelOverrideDropdownLabel": "Reemplazo del modelo para este paso del flujo de trabajo", "modelOverrideLabel": "Reemplazo del modelo", "modelOverridePlaceholder": "Seleccionar un reemplazo de modelo…", + "modeScript": "Ejecutar script", "moveDown": "Mover hacia abajo", "moveUp": "Mover hacia arriba", "needsReview": "Necesita revisión de seguimiento.", @@ -6749,8 +6658,6 @@ "phasePreMergeHint": "Se ejecuta antes de la fusión: puede bloquearla si falla", "plain": "Sin formato", "polishNotes": "Notas de pulido", - "postMerge": "Después de fusionar", - "preMerge": "Antes de fusionar", "promptRefined": "Prompt refinado con IA", "refineWithAi": "Refinar con IA", "refineWithAiAriaLabel": "Refinar prompt con IA", @@ -6761,31 +6668,87 @@ "selectStepsDescription": "Seleccione pasos a ejecutar después de que se complete la implementación de la tarea", "showOutput": "Mostrar salida", "started": "Iniciado:", - "statusAdvisory": "Fallo de advertencia", - "statusFailed": "Fallido", - "statusPassed": "Aprobado", - "statusRunning": "Ejecutándose…", - "statusSkipped": "Omitido", - "stepCount": "{{count}} paso{{count_one::count_other:s}}", + "stepCount_one": "", + "stepCount_other": "", "stepCreated": "Paso del flujo de trabajo creado", "stepDefinitionNotFound": "Definición de paso no encontrada.", "stepDeleted": "Paso del flujo de trabajo eliminado", - "stepUpdated": "Paso del flujo de trabajo actualizado", "steps": "Pasos del flujo de trabajo", "stepsExplanation": "Los pasos previos a la fusión se ejecutan después de la implementación, antes de la fusión. Los pasos posteriores a la fusión se ejecutan después de que la fusión se realice correctamente.", - "summaryAdvisory": "{{count}} aviso(s)", - "summaryFailed": "{{count}} fallido(s)", - "summaryPassed": "{{count}} aprobado(s)", - "summaryRunning": "{{count}} en ejecución", + "stepUpdated": "Paso del flujo de trabajo actualizado", + "summaryAdvisory_one": "", + "summaryAdvisory_other": "", + "summaryFailed_one": "", + "summaryFailed_other": "", + "summaryPassed_one": "", + "summaryPassed_other": "", + "summaryRunning_one": "", + "summaryRunning_other": "", "summarySeparator": " · ", - "summarySkipped": "{{count}} omitido(s)", + "summarySkipped_one": "", + "summarySkipped_other": "", + "summaryStepCount_one": "", + "summaryStepCount_other": "", "switchToMarkdown": "Cambiar a Markdown", "switchToPlain": "Cambiar a texto sin formato", - "tabMySteps": "Mis pasos del flujo de trabajo ({{count}})", - "tabTemplates": "Plantillas ({{count}})", + "tabMySteps_one": "", + "tabMySteps_other": "", + "tabTemplates_one": "", + "tabTemplates_other": "", "templateAdded": "Paso del flujo de trabajo «{{name}}» añadido", - "useDefault": "Usar predeterminado", - "waitingForOutput": "Esperando salida del agente…" + "useDefault": "Usar predeterminado" + }, + "workflowColumns": { + "add": "", + "compositionBlocked": "", + "empty": "", + "moveDown": "", + "moveUp": "", + "nameLabel": "", + "newColumnName": "", + "nodeUnplaced": "", + "readOnlyHint": "", + "remove": "", + "title": "", + "traits": "", + "traitsLoadFailed": "", + "unplacedCount_one": "", + "unplacedCount_other": "" + }, + "workflowNodes": { + "advisory": "", + "failureCollect": "", + "failureFailFast": "", + "failurePolicy": "", + "gateBlocks": "", + "gateMode": "", + "joinAll": "", + "joinAny": "", + "joinMode": "", + "joinQuorum": "", + "mergeBoundaryNote": "", + "quorumN": "", + "releaseCapacity": "", + "releaseCondition": "", + "releaseDependency": "", + "releaseExternal": "", + "releaseManual": "", + "releaseTimer": "", + "splitNote": "" + }, + "workflows": { + "duplicateToCustomize": "", + "readOnlyBuiltin": "", + "saved": "", + "savedNotCompilable": "", + "saveFailed": "", + "selectOrCreate": "" + }, + "workflowSelector": { + "switchActiveMessage": "", + "switchActiveTitle": "", + "switchCancel": "", + "switchConfirm": "" }, "workspace": { "projectRoot": "Raíz del proyecto", diff --git a/packages/i18n/locales/es/cli.json b/packages/i18n/locales/es/cli.json index 990ff1a74d..230ab8ae86 100644 --- a/packages/i18n/locales/es/cli.json +++ b/packages/i18n/locales/es/cli.json @@ -20,11 +20,12 @@ "agentRunId": "ID:", "agentRunLogsBackHint": "[Esc/q] volver a ejecuciones", "agentRunLogsTitle": "Registros de ejecución ({{index}})", + "agentsFooterHints": "[s] iniciar [x] detener [D] eliminar [r] actualizar [Tab] foco ↑↓ seleccionar", + "agentsListTitle_one": "", + "agentsListTitle_other": "", + "agentsNoAgents": "No se encontraron agentes.", "agentStarted": "Agente iniciado", "agentStopped": "Agente detenido", - "agentsFooterHints": "[s] iniciar [x] detener [D] eliminar [r] actualizar [Tab] foco ↑↓ seleccionar", - "agentsListTitle": "Agentes ({{count}})", - "agentsNoAgents": "No se encontraron agentes.", "boardCreateTaskHints": "Enter para crear · Esc para cancelar", "boardCreateTaskNoProject": "No hay proyecto seleccionado", "boardCreateTaskTitleEmpty": "El título no puede estar vacío", @@ -33,6 +34,7 @@ "boardNewTaskProject": "Proyecto: {{name}}", "boardNewTaskTitle": "Nueva tarea", "boardNewTaskTitleLabel": "Título", + "boardOtherReadOnlyHint": "", "copiedSuccess": "✓ ¡Copiado!", "copyFailed": "✗ Error al copiar", "expandedLogHeader": "Entrada {{index}}/{{total}} · [Enter/Esc] cerrar · [c] copiar", @@ -43,26 +45,27 @@ "filesEmpty": "(vacío)", "filesEmptyFile": "(archivo vacío)", "filesFooterHints": "[Tab] cambiar panel [↑↓/jk] mover [Enter] abrir [←/→] colapsar/expandir [.] ocultos [w] ajuste de línea [p] proyecto [r] recargar", - "filesMoreLines": "… {{count}} líneas más", + "filesMoreLines_one": "", + "filesMoreLines_other": "", "filesSelectProject": "Seleccionar proyecto", "filesSelectToPreview": "Selecciona un archivo para previsualizar", "filesTooLarge": "{{size}} — [demasiado grande para previsualizar]", "filesUnableToRead": "No se puede leer el archivo", - "gitFetchFailed": "Error al descargar: {{output}}", "gitFetched": "Descargado", + "gitFetchFailed": "Error al descargar: {{output}}", "gitFetching": "Descargando…", "gitFooterHints": "[r] actualizar {{push}}[F] descargar [↑↓] filas [←→] estado▸ramas{{worktrees}}▸commits▸cambios [p] proyecto [Esc/s] volver", "gitNoCommits": "Sin commits", "gitNoProject": "Sin proyecto", "gitPushDismissHint": "[Esc] cerrar", "gitPushFailed": "Error al subir", + "gitPushingToOrigin": "Subiendo a origin/{{branch}}", "gitPushModalAhead": "adelante", "gitPushModalBranch": "Rama:", "gitPushModalCommits": "Commits a subir (del más antiguo al más reciente):", "gitPushModalHints": "[Enter] subir [Esc] cancelar", "gitPushModalTitle": "Subir al remoto", "gitPushSuccessful": "Subida exitosa", - "gitPushingToOrigin": "Subiendo a origin/{{branch}}", "gitRefreshing": "actualizando", "gitWorkingTreeClean": "Árbol de trabajo limpio", "headerHelpQuitHint": "[?] ayuda [q] salir", @@ -117,14 +120,13 @@ "projectSelectorChangeHint": "[p] cambiar", "projectSelectorLabel": "Proyecto:", "projectSelectorNavHints": "↑↓ navegar · Enter seleccionar · Esc cancelar", - "projectSelectorNoProjects": "(no hay proyectos registrados)", "projectSelectorNone": "(ninguno)", + "projectSelectorNoProjects": "(no hay proyectos registrados)", "projectSelectorPickTitle": "Elige un proyecto", "qrCloseHint": "[Esc] cerrar", "qrGenerating": "Generando QR…", "qrNoTunnelRunning": "No hay túnel remoto en ejecución. Inicia uno en Configuración (g).", "qrOverlayTitle": "Acceso remoto — Escanea para conectarte", - "quit": "Salir", "readyIn": "Listo en {{secs}} s", "runLogNone": "No se capturaron registros para esta ejecución.", "runLogResult": "resultado:", @@ -137,16 +139,6 @@ "runStatusFailed": "Fallido", "runStatusTerminated": "Terminado", "runStatusUnknown": "Desconocido", - "settingAutoMerge": "Fusión automática", - "settingEnginePaused": "Motor en pausa", - "settingGlobalPause": "Pausa global", - "settingMaxConcurrent": "Máximo concurrente", - "settingMaxWorktrees": "Árboles de trabajo máximos", - "settingMergeStrategy": "Estrategia de fusión", - "settingPollIntervalMs": "Intervalo de sondeo (ms)", - "settingRemoteActiveProvider": "Proveedor remoto", - "settingRemoteShortLivedEnabled": "Tokens de corta duración", - "settingRemoteShortLivedTtlMs": "TTL de corta duración (ms)", "settingsActivatedProvider": "Proveedor activado: {{provider}}", "settingsAdjust1": "[+/-] ajustar en 1", "settingsAdjust5000ms": "[+/-] ajustar en 5000 ms", @@ -161,7 +153,8 @@ "settingsFooterHints": "[Tab] cambiar panel ↑↓ seleccionar ajuste [Espacio] alternar bool [+/-] ajustar num [←/→] ciclar enum [C/V/X/P/L/U/K/R] acciones remotas", "settingsInteractivePanelTitle": "Configuración", "settingsLoadingSettings": "Cargando configuración…", - "settingsMoreModels": "… y {{count}} más", + "settingsMoreModels_one": "", + "settingsMoreModels_other": "", "settingsPanelTitle": "Configuración", "settingsPersistentTokenRegenerated": "Token persistente regenerado", "settingsQrFetched": "Carga QR obtenida", diff --git a/packages/i18n/locales/es/common.json b/packages/i18n/locales/es/common.json index 9a1da34108..cd3a95ec14 100644 --- a/packages/i18n/locales/es/common.json +++ b/packages/i18n/locales/es/common.json @@ -4,8 +4,65 @@ "close": "Cerrar", "save": "Guardar" }, + "agents": { + "ratings": { + "trendDeclining": "", + "trendImproving": "", + "trendInsufficient": "", + "trendStable": "" + }, + "reflections": { + "triggerManual": "", + "triggerPeriodic": "", + "triggerPostTask": "", + "triggerUserRequested": "" + }, + "time": { + "daysAgo_one": "", + "daysAgo_other": "", + "hoursAgo_one": "", + "hoursAgo_other": "", + "inAMoment": "", + "inDays_one": "", + "inDays_other": "", + "inHours_one": "", + "inHours_other": "", + "inMinutes_one": "", + "inMinutes_other": "", + "justNow": "", + "minutesAgo_one": "", + "minutesAgo_other": "" + } + }, "archive": "Archivar", + "board": { + "rejection": { + "capacityExhausted": "", + "guardRejected": "", + "mergeBlocked": "", + "unknownColumn": "", + "workflowMismatch": "" + } + }, "cancel": "Cancelar", + "chat": { + "failedToGetResponse": "", + "failureReferenceId": "", + "failureReferenceKind": "", + "failureReferenceLabel": "", + "failureReferenceMetaLabel": "", + "openMailboxMessage": "", + "toolCallArgsPrefix": "", + "toolCallResultPrefix": "", + "toolCallStatusCompleted": "", + "toolCallStatusError": "", + "toolCallStatusErrors": "", + "toolCallStatusRunning": "", + "toolCallsCount_one": "", + "toolCallsCount_other": "", + "toolCallsHeader": "", + "viewFailureDetails": "" + }, "close": "Cerrar", "columns": { "archived": "Archivado", @@ -16,8 +73,162 @@ "triage": "Planificación" }, "delete": "Eliminar", + "health": { + "anomaly": { + "duplicateActiveId": "", + "idInBothStorages": "", + "sequenceOverlap": "", + "unknownPrefix": "" + } + }, + "inline": { + "connecting": "", + "error": "", + "offline": "", + "online": "" + }, + "merge": { + "unknown": "" + }, + "missions": { + "autopilotStateActivating": "", + "autopilotStateCompleting": "", + "autopilotStateInactive": "", + "autopilotStateUnknown": "", + "autopilotStateWatching": "", + "interviewStatusAwaitingInput": "", + "interviewStatusComplete": "", + "interviewStatusError": "", + "interviewStatusGenerating": "", + "runHelperActive": "", + "runHelperBlocked": "", + "runHelperPlanning": "" + }, + "models": { + "messages": { + "modelSetTo": "", + "modelSetToDefault": "" + } + }, + "nodeStatus": { + "connecting": "", + "error": "", + "offline": "", + "online": "", + "unknown": "" + }, + "nodes": { + "auth": { + "differ": "", + "differProviders": "", + "match": "", + "notSynced": "" + }, + "status": { + "connecting": "", + "creating": "", + "deleting": "", + "error": "", + "exited": "", + "offline": "", + "online": "", + "recreating": "", + "running": "", + "stopped": "" + } + }, "refresh": "Actualizar", + "research": { + "providerGitHub": "", + "providerLlmSynthesis": "", + "providerLocalDocs": "", + "providerPageFetch": "", + "providerWebSearch": "" + }, "retry": "Reintentar", + "routing": { + "policyLabel": { + "block": "", + "fallback": "", + "notConfigured": "" + } + }, + "setup": { + "apiKeyFormatError": "", + "apiKeyLabel": { + "fallback": "", + "kimiCoding": "", + "minimax": "", + "ollama": "", + "openai": "", + "openrouter": "", + "zai": "" + }, + "apiKeyPlaceholder": { + "fallback": "", + "kimiCoding": "", + "minimax": "", + "zai": "" + }, + "apiKeyRequired": "", + "apiKeySetup": { + "fallback": "", + "kimiCoding": "", + "minimax": "", + "ollama": "", + "openai": "", + "openrouter": "", + "zai": "" + }, + "apiKeyUsage": { + "fallback": "", + "kimiCoding": "", + "minimax": "", + "ollama": "", + "openai": "", + "openrouter": "", + "zai": "" + }, + "providerDesc": { + "anthropic": "", + "fallback": "", + "gemini": "", + "google": "", + "kimi": "", + "kimiCoding": "", + "minimax": "", + "moonshot": "", + "ollama": "", + "openai": "", + "openaiCodex": "", + "openrouter": "", + "zai": "" + } + }, "skip": "Omitir", - "tryAgain": "Reintentar" + "taskForm": { + "nodeStatusConnecting": "", + "nodeStatusError": "", + "nodeStatusOffline": "", + "nodeStatusOnline": "", + "phasePostMerge": "", + "phasePreMerge": "" + }, + "taskReview": { + "never": "", + "refreshSourceBackground": "", + "refreshSourceInitialLoad": "", + "refreshSourceManual": "" + }, + "tryAgain": "Reintentar", + "workflow": { + "postMerge": "", + "preMerge": "", + "statusAdvisory": "", + "statusFailed": "", + "statusPassed": "", + "statusRunning": "", + "statusSkipped": "", + "waitingForOutput": "" + } } diff --git a/packages/i18n/locales/es/errors.json b/packages/i18n/locales/es/errors.json index 1c05b55ea4..0967ef424b 100644 --- a/packages/i18n/locales/es/errors.json +++ b/packages/i18n/locales/es/errors.json @@ -1,4 +1 @@ -{ - "fetchProjectsFailed": "No se pudieron obtener los proyectos", - "openTaskLogsFailed": "No se pudieron abrir los registros de la tarea: {{detail}}" -} +{} diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index 6b3ef27459..03c1ac65e2 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -16,7 +16,6 @@ "dismissOAuth": "Ignorer la bannière de reconnexion OAuth", "done": "Terminé", "edit": "Modifier", - "generateInsights": "Générer de nouveaux insights", "no": "Non", "openSettings": "Ouvrir les paramètres", "pull": "Récupérer", @@ -66,10 +65,13 @@ "notMerged": "Non fusionné", "refresh": "Actualiser", "time": { - "daysAgo": "il y a {{count}} j", - "hoursAgo": "il y a {{count}} h", + "daysAgo_one": "", + "daysAgo_other": "", + "hoursAgo_one": "", + "hoursAgo_other": "", "justNow": "À l'instant", - "minutesAgo": "il y a {{count}} min" + "minutesAgo_one": "", + "minutesAgo_other": "" }, "title": "Journal d'activité" }, @@ -95,26 +97,30 @@ "hideToolCallsResults": "Masquer les appels d'outil et les résultats", "hideToolOutput": "Masquer la sortie de l'outil", "live": "En direct", - "loadMore": "Charger plus", "loading": "Chargement des journaux d'agent…", "loadingMore": "Chargement…", + "loadMore": "Charger plus", "markdown": "Markdown", "plain": "Brut", "planning": "Planification", "reviewer": "Réviseur", "showFormattedMarkdown": "Afficher le markdown formaté", + "showing": "Affichage de {{visible}} sur {{total}} entrées", "showOutput": "Afficher la sortie", "showRawText": "Afficher le texte brut", "showToolCallsResults": "Afficher les appels d'outil et les résultats", "showToolOutput": "Afficher la sortie de l'outil", - "showing": "Affichage de {{visible}} sur {{total}} entrées", "switchMarkdown": "Passer au mode markdown", "switchPlainText": "Passer au mode texte brut", - "timeDaysAgo": "il y a {{count}} j", - "timeHoursAgo": "il y a {{count}} h", + "timeDaysAgo_one": "", + "timeDaysAgo_other": "", + "timeHoursAgo_one": "", + "timeHoursAgo_other": "", "timeJustNow": "à l'instant", - "timeMinutesAgo": "il y a {{count}} min", - "toolEntriesHidden": "{{count}} entrées d'outil masquées", + "timeMinutesAgo_one": "", + "timeMinutesAgo_other": "", + "toolEntriesHidden_one": "", + "toolEntriesHidden_other": "", "toolsOff": "Outils : Désactivé", "toolsOn": "Outils : Activé", "usingDefault": "Utilisant la valeur par défaut" @@ -239,15 +245,6 @@ "promptDefault": "Défaut : {{preview}}", "templateName": "Par exemple Mon exécuteur personnalisé" }, - "roles": { - "custom": "Agent personnalisé", - "engineer": "Agent ingénieur", - "executor": "Agent d'exécution", - "merger": "Agent de fusion", - "reviewer": "Agent réviseur", - "scheduler": "Agent planificateur", - "triage": "Agent de triage" - }, "sections": { "builtinTemplates": "Modèles intégrés", "customTemplates": "Modèles personnalisés" @@ -281,7 +278,8 @@ }, "agents": { "activate": "Activer", - "activeAgents": "Agents actifs ({{count}})", + "activeAgents_one": "", + "activeAgents_other": "", "activePrefix": "Actif : ", "advancedSettingsDesc": "Options de configuration avancées pour cet agent.", "advancedSettingsTitle": "Paramètres avancés", @@ -291,15 +289,16 @@ "agentMail": "Courrier de l'agent", "agentModelLabel": "Modèle de l'agent", "agentPlural": "agents", + "agentsFound_one": "", + "agentsFound_other": "", "agentSingular": "agent", - "agentSoulLabel": "Âme de l'agent", - "agentsFound": "{{count}} agent{{plural}} trouvé{{plural}}", "agentsLabel": "Agents", + "agentSoulLabel": "Âme de l'agent", "aiInterview": "Entretien IA", "allChangesSaved": "Toutes les modifications enregistrées", - "allTime": "Tout le temps", "allowParallelExecution": "Autoriser l'exécution parallèle", "allowParallelExecutionHint": "Autoriser cet agent à exécuter plusieurs heartbeats simultanément.", + "allTime": "Tout le temps", "alreadyOnDefault": "Déjà par défaut", "applyPreset": "Appliquer le préréglage", "assignedSkills": "Compétences assignées", @@ -330,12 +329,12 @@ "bulkActions": "Actions groupées", "bulkActionsLoadFailed": "Échec du chargement des actions groupées : {{error}}", "bulkAgentActions": "Actions groupées sur les agents", - "bulkConfirmMessage": "{{action}} {{count}} agent(s) ?", + "bulkConfirmMessage_one": "", + "bulkConfirmMessage_other": "", "bulkNoEligible": "Aucun agent éligible", - "bulkResult": "{{action}} {{count}} agent(s)", - "bulkResultWithFailures": "{{action}} {{count}} agent(s), {{failed}} échec(s)", "bulkResult_one": "{{action}} {{successCount}} {{agentWord}} ; ignoré {{skippedCount}}", "bulkResult_other": "{{action}} {{successCount}} {{agentWord}} ; ignoré {{skippedCount}}", + "bulkResultWithFailures": "{{action}} {{count}} agent(s), {{failed}} échec(s)", "bundleDescription": "Configurez comment le bundle de code de cet agent est géré.", "bundleEntryFileHint": "Le fichier d'entrée du bundle géré.", "bundleEntryFileLabel": "Fichier d'entrée", @@ -381,9 +380,9 @@ "copyId": "Copier l'ID", "create": "Créer", "createAgent": "Créer l'agent", + "created": "Agent « {{name}} » créé", "createError": "Échec de la création de l'agent", "createSuccess": "Agent « {{name}} » créé", - "created": "Agent « {{name}} » créé", "creating": "Création en cours…", "creatingAgent": "Création de l'agent...", "currentAgent": "Agent actuel", @@ -400,12 +399,12 @@ "delete": "Supprimer", "deleteAgent": "Supprimer l'agent", "deleteConfirm": "Supprimer l'agent « {{name}} » ? Cette action est irréversible.", + "deleted": "Agent « {{name}} » supprimé", "deleteError": "Échec de la suppression de l'agent : {{error}}", "deleteFailed": "Échec de la suppression de l'agent : {{error}}", "deleteMessage": "Supprimer l'agent « {{name}} » ? Cette action est irréversible.", "deleteSuccess": "Agent « {{name}} » supprimé", "deleteTitle": "Supprimer l'agent", - "deleted": "Agent « {{name}} » supprimé", "deletionNotAvailable": "La suppression n'est pas disponible pendant l'exécution de l'agent.", "deletionPermanent": "Cela supprimera définitivement l'agent et toutes les données associées.", "details": "Détails", @@ -473,6 +472,8 @@ "healthError": "Erreur", "heartbeat": "Pulsation :", "heartbeatAndHealth": "Heartbeat & Santé", + "heartbeatClampedToMin_one": "", + "heartbeatClampedToMin_other": "", "heartbeatCustom": "Exécution heartbeat personnalisée", "heartbeatEnabled": "Heartbeat activé", "heartbeatEnabledHint": "Autorisez cet agent à s'exécuter sur un heartbeat planifié.", @@ -483,12 +484,12 @@ "heartbeatFileLoadFailed": "Échec du chargement du fichier heartbeat", "heartbeatFilePlaceholder": "Contenu de la procédure heartbeat...", "heartbeatFilePreviewMode": "Mode aperçu", - "heartbeatFileSaveFailed": "Échec de l'enregistrement du fichier heartbeat", "heartbeatFileSaved": "Fichier heartbeat enregistré", + "heartbeatFileSaveFailed": "Échec de l'enregistrement du fichier heartbeat", "heartbeatIntervalHint": "Fréquence d'exécution du heartbeat, en secondes.", "heartbeatIntervalLabel": "Intervalle heartbeat (s)", - "heartbeatIntervalUpdateFailed": "Échec de la mise à jour de l'intervalle de pulsation : {{error}}", "heartbeatIntervalUpdated": "Intervalle de pulsation de {{name}} mis à jour : {{interval}}", + "heartbeatIntervalUpdateFailed": "Échec de la mise à jour de l'intervalle de pulsation : {{error}}", "heartbeatMustBeNumber": "L'intervalle de pulsation doit être un nombre valide", "heartbeatMustBePositive": "L'intervalle de pulsation doit être supérieur à 0", "heartbeatOverdue": "Battement de cœur en retard {{elapsed}}", @@ -512,8 +513,8 @@ "heartbeatSpeedPreset": "Préréglage de vitesse de pulsation", "heartbeatSpeedSaveFailed": "Échec de l'enregistrement du multiplicateur de pulsation : {{error}}", "heartbeatSpeedSet": "Vitesse de pulsation définie à ×{{value}}", - "heartbeatStartFailed": "Échec du démarrage du heartbeat", "heartbeatStarted": "Heartbeat démarré", + "heartbeatStartFailed": "Échec du démarrage du heartbeat", "heartbeatTimeoutHint": "Temps maximum en secondes qu'une exécution heartbeat peut prendre avant d'être interrompue.", "heartbeatTimeoutLabel": "Délai d'expiration heartbeat (s)", "heartbeatUpgradeFailed": "Échec de la mise à niveau de la procédure heartbeat", @@ -527,16 +528,18 @@ "importButton": "Importer {{label}}", "importComplete": "Importation terminée", "importDescription": "Importez des agents à partir d'un package Agent Companies. Parcourez le catalogue companies.sh pour découvrir des agents publiés, téléchargez un fichier AGENTS.md, sélectionnez un répertoire ou collez le contenu du manifeste.", - "importingAgents": "Importation de {{count}} agent{{plural}}...", + "importingAgents_one": "", + "importingAgents_other": "", "importingAgentsAndSkills": "Importation de {{agentCount}} agent{{agentPlural}} et {{skillCount}} compétence{{skillPlural}}...", - "importingSkills": "Importation de {{count}} compétence{{plural}}...", - "inProgress": "En cours", + "importingSkills_one": "", + "importingSkills_other": "", "inbox": "Boîte de réception", - "inheritProjectDefault": "Hériter du défaut du projet", "inheritingProjectDefault": "Héritant du défaut du projet", + "inheritProjectDefault": "Hériter du défaut du projet", "inlineMemoryFieldHint": "Cette mémoire est intégrée directement dans le contexte de l'agent.", "inlineMemoryHint": "Mémoire courte injectée à chaque heartbeat.", "inlineMemoryLabel": "Mémoire inline", + "inProgress": "En cours", "input": "Entrée", "inputTokens": "Jetons d'entrée", "installs": "installations", @@ -544,15 +547,15 @@ "instructionsEmptyPreview": "Aucune instruction — passez en mode édition pour en ajouter.", "instructionsFileEditorDesc": "Modifiez directement le fichier d'instructions lié.", "instructionsFileEditorTitle": "Fichier d'instructions", - "instructionsFileSaveFailed": "Échec de l'enregistrement du fichier d'instructions", "instructionsFileSaved": "Fichier d'instructions enregistré", + "instructionsFileSaveFailed": "Échec de l'enregistrement du fichier d'instructions", "instructionsHint": "Ces instructions sont ajoutées au début de chaque invite reçue par cet agent.", "instructionsPathHint": "Chemin vers un fichier markdown contenant les instructions de cet agent.", "instructionsPathLabel": "Chemin du fichier d'instructions", "instructionsPathPlaceholder": "ex. .fusion/agents/reviewer.md", "instructionsPlaceholder": "Entrez les instructions pour cet agent...", - "instructionsSaveFailed": "Échec de l'enregistrement des instructions", "instructionsSaved": "Instructions enregistrées", + "instructionsSaveFailed": "Échec de l'enregistrement des instructions", "instructionsTextPlaceholder": "Ajouter des instructions de comportement personnalisées…", "instructionsTitle": "Instructions", "intentPrompt": "Que voulez-vous que cet agent fasse?", @@ -574,7 +577,6 @@ "liveLogs": "Journaux en direct", "liveRun": "Exécution en direct", "loadError": "Échec du chargement des agents : {{error}}", - "loadTasksFailed": "Échec du chargement des tâches", "loading": "Chargement de l'agent...", "loadingAgents": "Chargement des agents…", "loadingCompanies": "Chargement des entreprises…", @@ -594,15 +596,17 @@ "loadingRuntimes": "Chargement des environnements d'exécution…", "loadingSkillContent": "Chargement du contenu de la compétence...", "loadingTasks": "Chargement des tâches...", - "logEntries": "entrées de journal", + "loadTasksFailed": "Échec du chargement des tâches", + "logEntries_one": "", + "logEntries_other": "", "logsWillAppear": "Les journaux apparaîtront ici une fois l'agent démarré.", "logsWillAppearActive": "Les journaux apparaîtront ici.", + "mailboxLoadFailed": "Échec du chargement de la boîte aux lettres", "mailFrom": "De", "mailSent": "Envoyé", "mailTo": "À", "mailToLabel": "À", "mailType": "Type", - "mailboxLoadFailed": "Échec du chargement de la boîte aux lettres", "manifestContent": "Contenu du manifeste", "manifestPlaceholder": "---\nname: PDG\ntitle: Directeur Général\nreportsTo: null\nskills:\n - review\n---\nInstructions de l'agent ici...", "maxConcurrentRunsHint": "Nombre maximum de heartbeats pouvant s'exécuter simultanément.", @@ -616,8 +620,8 @@ "memoryFileMeta": "{{size}} octets · mis à jour {{date}}", "memoryFilePlaceholder": "Contenu du fichier mémoire...", "memoryFilePreviewMode": "Mode aperçu", - "memoryFileSaveFailed": "Échec de l'enregistrement du fichier mémoire", "memoryFileSaved": "Fichier mémoire enregistré", + "memoryFileSaveFailed": "Échec de l'enregistrement du fichier mémoire", "memoryFilesHint": "Fichiers stockés dans les couches mémoire de l'agent.", "memoryFilesHintSuffix": "Sélectionnez un fichier pour afficher ou modifier son contenu.", "memoryFilesLabel": "Fichiers mémoire", @@ -630,8 +634,8 @@ "memoryLayerLongTermDesc": "Faits et connaissances persistants conservés entre les sessions.", "memoryPlaceholder": "Privé pour cet agent — préférences durables, habitudes de travail et contexte à conserver d'une tâche à l'autre…", "memoryReadOnly": "Lecture seule", - "memorySaveFailed": "Échec de l'enregistrement de la mémoire", "memorySaved": "Mémoire enregistrée", + "memorySaveFailed": "Échec de l'enregistrement de la mémoire", "memoryTitle": "Mémoire", "memoryTooLong": "Le contenu de la mémoire est trop long", "messageResponseModeHint": "Quand cet agent répond aux messages entrants.", @@ -672,6 +676,7 @@ "noLogsForRun": "Aucun journal pour cette exécution", "noManager": "Aucun responsable", "noMemoryFiles": "Aucun fichier mémoire", + "noneUsingBuiltIn": "Aucun (utilisant l'intégré)", "noOutboxMessages": "Aucun message dans la boîte d'envoi", "noOutputCaptured": "Aucune sortie capturée", "noPausedEligible": "Aucun agent en pause éligible à la reprise", @@ -684,11 +689,9 @@ "noSkillsInPackage": "Aucune compétence dans le package", "noTasksAssigned": "Aucune tâche assignée", "noTokenUsageYet": "Aucune utilisation de jetons enregistrée pour le moment. Les totaux des jetons s'afficheront ici une fois que les agents s'exécutent.", - "noneUsingBuiltIn": "Aucun (utilisant l'intégré)", "notScheduled": "Non planifié", "notSelected": "Non sélectionné", "off": "Désactivé", - "onHeartbeat": "Sur heartbeat", "onboarding": { "applyDraftAgent": "Appliquer le brouillon au formulaire d'agent", "applyDraftSettings": "Appliquer le brouillon au formulaire de paramètres", @@ -729,9 +732,9 @@ "updatedDraftReady": "Brouillon mis à jour prêt à réviser", "yes": "oui" }, + "onHeartbeat": "Sur heartbeat", "openDetails": "Ouvrir les détails de {{name}}", "optional": "(facultatif)", - "orPasteManifest": "ou collez le contenu du manifeste", "orgChartCanvas": "Canevas de l'organigramme", "orgChartCenter": "Centrer l'organigramme", "orgChartEmployees": "Employés de {{name}}", @@ -740,6 +743,7 @@ "orgChartView": "Vue organigramme", "orgChartZoomIn": "Zoomer l'organigramme", "orgChartZoomOut": "Dézoomer l'organigramme", + "orPasteManifest": "ou collez le contenu du manifeste", "outbox": "Boîte d'envoi", "output": "Sortie", "outputTokens": "Jetons de sortie", @@ -750,13 +754,21 @@ "pauseAgentsFailed": "Échec de la mise en pause des agents : {{error}}", "pauseAll": "Tout mettre en pause", "pauseAllAgents": "Mettre tous les agents en pause", + "pauseAllConfirm_one": "", + "pauseAllConfirm_other": "", "pauseAllTitle": "Mettre tous les agents en pause", - "pauseCountHint": "{{count}} agent(s) actif(s) sera mis en pause", "pauseCountHint_one": "Mettre en pause {{count}} agent actif/en cours", "pauseCountHint_other": "Mettre en pause {{count}} agents actifs/en cours", + "pauseCountHint_one_one": "", + "pauseCountHint_one_other": "", + "pauseCountHint_other_one": "", + "pauseCountHint_other_other": "", "pausedPast": "mis en pause", + "pausedSummary_one": "", + "pausedSummary_other": "", "pendingApprovals": "Approbations en attente", - "pendingApprovalsCount": "{{count}} en attente", + "pendingApprovalsCount_one": "", + "pendingApprovalsCount_other": "", "performance": { "avgDuration": "Durée moyenne", "noData": "Aucune donnée de performance pour l'instant", @@ -774,6 +786,7 @@ "preview": "Aperçu", "promptSize": "Taille de l'invite", "promptSizeChart": "Graphique de taille d'invite", + "provideManifest": "", "ratings": { "addError": "Impossible d'ajouter l'évaluation : {{error}}", "addRating": "Ajouter une évaluation", @@ -786,7 +799,8 @@ "categorySelect": "Sélectionner une catégorie...", "categorySpeed": "Rapidité", "commentPlaceholder": "Commentaire facultatif...", - "count": "{{count}} évaluation(s)", + "count_one": "", + "count_other": "", "deleteError": "Impossible de supprimer l'évaluation : {{error}}", "deleteRating": "Supprimer l'évaluation", "deleteSuccess": "Évaluation supprimée", @@ -794,14 +808,11 @@ "loadError": "Impossible de charger les évaluations : {{error}}", "loading": "Chargement des évaluations...", "noRatings": "Aucune évaluation pour l'instant", - "starCount": "{{count}} étoile", + "starCount_one": "", + "starCount_other": "", "submitRating": "Soumettre l'évaluation", "submitting": "Envoi en cours...", - "title": "Évaluations des utilisateurs", - "trendDeclining": "↓ En baisse", - "trendImproving": "↑ En amélioration", - "trendInsufficient": "Données insuffisantes", - "trendStable": "→ Stable" + "title": "Évaluations des utilisateurs" }, "recentRuns": "Exécutions récentes", "reflections": { @@ -818,18 +829,14 @@ "metricAvgDuration": "Durée moyenne :", "metricErrors": "Erreurs :", "metricFailed": "Échouées :", - "metricTasks": "Tâches :", "metrics": "Métriques", + "metricTasks": "Tâches :", "noReflections": "Aucune réflexion pour l'instant", + "reflecting": "Réflexion en cours...", "reflectNow": "Réfléchir maintenant", "reflectNowTitle": "Générer une réflexion manuelle", - "reflecting": "Réflexion en cours...", "sectionTitle": "Performance, réflexions et évaluations", - "suggestedImprovements": "Améliorations suggérées", - "triggerManual": "Manuel", - "triggerPeriodic": "Périodique", - "triggerPostTask": "Post-tâche", - "triggerUserRequested": "Demandé par l'utilisateur" + "suggestedImprovements": "Améliorations suggérées" }, "refresh": "Actualiser", "removeAvatar": "Supprimer l'avatar", @@ -842,19 +849,29 @@ "resetDayWeekly": "Jour de la semaine (0=Dim)", "resetting": "Réinitialisation...", "result": "Résultat", - "resultCreated": "{{count}} créé{{plural}}", - "resultErrors": "{{count}} erreur{{plural}}", - "resultSkipped": "{{count}} ignoré{{plural}} (déjà existant{{plural}})", + "resultCreated_one": "", + "resultCreated_other": "", + "resultErrors_one": "", + "resultErrors_other": "", + "resultSkipped_one": "", + "resultSkipped_other": "", "resume": "Reprendre", "resumeAction": "Reprendre", "resumeAgentsFailed": "Échec de la reprise des agents : {{error}}", "resumeAll": "Tout reprendre", "resumeAllAgents": "Reprendre tous les agents", + "resumeAllConfirm_one": "", + "resumeAllConfirm_other": "", "resumeAllTitle": "Reprendre tous les agents", - "resumeCountHint": "{{count}} agent(s) en pause sera repris", "resumeCountHint_one": "Reprendre {{count}} agent mis en pause", "resumeCountHint_other": "Reprendre {{count}} agents mis en pause", + "resumeCountHint_one_one": "", + "resumeCountHint_one_other": "", + "resumeCountHint_other_one": "", + "resumeCountHint_other_other": "", "resumedPast": "repris", + "resumedSummary_one": "", + "resumedSummary_other": "", "retry": "Réessayer", "reviewConfiguration": "Vérifier la configuration générée", "reviewHint": "Vérifiez la configuration de votre agent avant de créer.", @@ -868,20 +885,18 @@ "roleReviewer": "Réviseur", "roleScheduler": "Planificateur", "roleTriage": "Triage", + "roleUpdated": "Rôle de l'agent mis à jour : {{role}}", "roleUpdateError": "Échec de la mise à jour du rôle : {{error}}", "roleUpdateFailed": "Échec de la mise à jour du rôle : {{error}}", "roleUpdateSuccess": "Rôle de l'agent mis à jour : {{role}}", - "roleUpdated": "Rôle de l'agent mis à jour : {{role}}", "runAriaLabel": "Exécution {{id}}", "runDetailsFailed": "Échec du chargement des détails d'exécution", "runMissedHeartbeat": "Exécuter le heartbeat manqué", "runMissedHeartbeatHint": "Déclencher une exécution si l'agent rate un heartbeat planifié.", + "running": "En cours", "runNow": "Exécuter maintenant", "runNowAria": "Exécuter maintenant pour {{name}}", "runNowFor": "Exécuter maintenant pour {{name}}", - "runStarted": "Exécution démarrée", - "runStopped": "Exécution arrêtée", - "running": "En cours", "runs": { "empty": "Aucune exécution pour le moment", "loading": "Chargement des exécutions…", @@ -890,9 +905,12 @@ "stopMessage": "Arrêter cette exécution?", "stopTitle": "Arrêter l'exécution" }, - "runsCount": "{{count}} exécution(s)", + "runsCount_one": "", + "runsCount_other": "", "runsSuccessRate": "{{rate}}% de taux de réussite", + "runStarted": "Exécution démarrée", "runsToday": "Exécutions aujourd'hui", + "runStopped": "Exécution arrêtée", "runtime": "Exécution", "runtimeEmpty": "Aucun environnement d'exécution de plugin disponible", "runtimeLabel": "Environnement", @@ -925,29 +943,37 @@ "selectAllAgents": "Sélectionner tous les agents", "selectAllSkills": "Sélectionner toutes les compétences", "selectAnAgent": "Sélectionner un agent", + "selectCompany": "", "selectDirectory": "Sélectionner un répertoire", + "selected": "Sélectionné:", + "selectedAgentLabel_one": "", + "selectedAgentLabel_other": "", + "selectedSkillLabel_one": "", + "selectedSkillLabel_other": "", "selectMemoryFile": "Sélectionner un fichier mémoire", "selectModel": "Modèle", "selectModelPlaceholder": "Sélectionnez un modèle…", "selectRuntime": "Sélectionner un environnement", "selectSkill": "Sélectionner la compétence {{name}}", - "selected": "Sélectionné:", - "selectedAgentLabel": "{{count}} Agent{{plural}}", - "selectedSkillLabel": "{{count}} Compétence{{plural}}", "setHeartbeatAria": "Définir l'intervalle de pulsation pour {{name}}", - "settingsSaveFailed": "Échec de l'enregistrement des paramètres", "settingsSaved": "Paramètres enregistrés", + "settingsSaveFailed": "Échec de l'enregistrement des paramètres", "setupModeAriaLabel": "Mode de configuration de l'agent", "showSystemAgents": "Afficher les agents système", "skills": "Compétences", "skillsDescription": "Gérez les compétences disponibles pour cet agent.", - "skillsErrors": "{{count}} compétence{{plural}} erreur{{pluralError}}", - "skillsFound": "{{count}} compétence{{plural}} trouvée{{plural}}", + "skillsErrors_one": "", + "skillsErrors_other": "", + "skillsFound_one": "", + "skillsFound_other": "", "skillsHint": "Compétences facultatives à attribuer à cet agent", - "skillsImported": "{{count}} compétence{{plural}} importée{{plural}}", + "skillsImported_one": "", + "skillsImported_other": "", "skillsNone": "Aucune compétence assignée", - "skillsSelected": "{{count}} compétence sélectionnée", - "skillsSkipped": "{{count}} compétence{{plural}} ignorée{{plural}} (déjà existant{{plural}})", + "skillsSelected_one": "", + "skillsSelected_other": "", + "skillsSkipped_one": "", + "skillsSkipped_other": "", "skillsTitle": "Compétences", "skipHeartbeatWhenIdle": "Ignorer le heartbeat en inactivité", "skipHeartbeatWhenIdleHint": "Éviter d'exécuter des heartbeats quand l'agent n'a rien à faire.", @@ -955,23 +981,23 @@ "soulEmptyPreview": "Aucune âme — passez en mode édition pour en ajouter une.", "soulHint": "Décrivez qui est cet agent — son caractère, son ton et ses valeurs.", "soulPlaceholder": "Décrivez la personnalité et le style de communication de l'agent…", - "soulSaveFailed": "Échec de l'enregistrement de l'âme", "soulSaved": "Âme enregistrée", + "soulSaveFailed": "Échec de l'enregistrement de l'âme", "soulTitle": "Âme", "soulTooLong": "Le contenu de l'âme est trop long", "start": "Démarrer", - "startOnboarding": "Commencer l'onboarding", "starting": "Démarrage...", + "startOnboarding": "Commencer l'onboarding", "stateActive": "Actif", "stateAll": "Tous les états", "stateError": "Erreur", "stateIdle": "Inactif", "statePaused": "En pause", "stateRunning": "En cours", + "stateUpdated": "État de l'agent mis à jour : {{state}}", "stateUpdateError": "Échec de la mise à jour de l'état : {{error}}", "stateUpdateFailed": "Échec de la mise à jour de l'état : {{error}}", "stateUpdateSuccess": "État de l'agent mis à jour : {{state}}", - "stateUpdated": "État de l'agent mis à jour : {{state}}", "status": "Statut", "statusCount": "{{activeCount}} actif · {{runningCount}} en cours d'exécution", "step": "Étape {{number}}{{total}}: {{name}}", @@ -1012,16 +1038,6 @@ "thinkingMinimal": "Minimal", "thinkingOff": "Désactivé", "throughput": "Débit", - "time": { - "daysAgo": "il y a {{count}}j", - "hoursAgo": "il y a {{count}}h", - "inAMoment": "dans un instant", - "inDays": "dans {{count}}j", - "inHours": "dans {{count}}h", - "inMinutes": "dans {{count}}m", - "justNow": "à l'instant", - "minutesAgo": "il y a {{count}}m" - }, "title": "Agents", "titleLabel": "Titre", "titlePlaceholder": "ex. Relecteur de code senior", @@ -1070,11 +1086,12 @@ }, "approval": { "dismissBanner": "Ignorer la bannière de notification d'approbation", - "needAttention": "{{count}} {{noun}} d'approbation nécessite votre attention", + "needAttention_one": "", + "needAttention_other": "", "openMailbox": "Ouvrir la boîte aux lettres", "requestPlural": "demandes", - "requestSingular": "demande", - "requests": "Demandes d'approbation" + "requests": "Demandes d'approbation", + "requestSingular": "demande" }, "auth": { "clearAndRetry": "Effacer le jeton et réessayer", @@ -1100,9 +1117,12 @@ "confirmMessage": "Cette session est active dans un autre onglet. Ouvrir quand même ?", "confirmTitle": "Ouvrir une session active", "dismissButton": "Rejeter", - "pillLabel": "IA {{count}}", - "pillTitle": "{{count}} tâche IA en arrière-plan", - "pillTitleWithInput": "{{count}} tâche IA en arrière-plan ({{needsInput}} attend une saisie)", + "pillLabel_one": "", + "pillLabel_other": "", + "pillTitle_one": "", + "pillTitle_other": "", + "pillTitleWithInput_one": "", + "pillTitleWithInput_other": "", "popoverHeader": "Tâches de fond", "status": { "activeElsewhere": "actif dans un autre onglet", @@ -1123,10 +1143,19 @@ "done": "Terminé", "inProgress": "En cours", "inReview": "En révision", + "rejection": { + "capacityExhausted": "", + "guardRejected": "", + "mergeBlocked": "", + "promoteRejected": "", + "unknownColumn": "", + "workflowMismatch": "" + }, "todo": "À faire", "triage": "Triage" }, "branchGroup": { + "abandonGroup": "", "autoMergeEnabled": "Fusion automatique activée", "collapseLabel": "Réduire le groupe de branches", "completionText": "{{landed}} sur {{total}} membres terminés", @@ -1185,13 +1214,8 @@ "failedToCreateSession": "Impossible de créer la session de discussion", "failedToDeleteConversation": "Impossible de supprimer la conversation", "failedToDeleteRoom": "Impossible de supprimer le salon", - "failedToGetResponse": "Impossible d'obtenir une réponse", "failedToSendRoomMessage": "Impossible d'envoyer le message dans le salon", "failureDetails": "Détails de l'échec", - "failureReferenceId": "ID", - "failureReferenceKind": "Type", - "failureReferenceLabel": "Référence", - "failureReferenceMetaLabel": "Libellé", "helpMessageContent": "Commandes disponibles :\n- `/new` ou `/clear` — Effacer la conversation et recommencer\n- `/skill:{name}` — Utiliser une compétence spécifique\n- `/help` — Afficher cette aide", "jumpToLatest": "Dernier", "latest": "Derniers", @@ -1222,14 +1246,16 @@ "noRoomsYet": "Aucun salon pour l'instant.", "noSkillsAvailable": "Aucune compétence disponible", "noSkillsFound": "Aucune compétence trouvée", - "openMailboxMessage": "Ouvrir le message de la boîte de réception", "openQuickChat": "Ouvrir le chat rapide", "queuedMessage": "En file d'attente : {{preview}}", "quickChatTitle": "Chat rapide", - "relativeTimeDays": "il y a {{count}} j", - "relativeTimeHours": "il y a {{count}} h", + "relativeTimeDays_one": "", + "relativeTimeDays_other": "", + "relativeTimeHours_one": "", + "relativeTimeHours_other": "", "relativeTimeJustNow": "à l'instant", - "relativeTimeMinutes": "il y a {{count}} min", + "relativeTimeMinutes_one": "", + "relativeTimeMinutes_other": "", "removeAttachment": "Supprimer {{name}}", "resizePanelBottom": "Redimensionner le panneau depuis le bas", "resizePanelBottomLeft": "Redimensionner le panneau depuis le coin inférieur gauche", @@ -1242,7 +1268,8 @@ "resizeSidebar": "Redimensionner la barre latérale", "responseCopied": "Réponse copiée", "responseFailed": "Échec de la réponse", - "roomMemberCount": "{{count}} membre", + "roomMemberCount_one": "", + "roomMemberCount_other": "", "roomsGroupLabel": "Canaux", "scopeDirect": "Direct", "scopeRooms": "Salons", @@ -1269,19 +1296,12 @@ "thinking": "Réflexion", "thinkingLabel": "Réflexion", "thinkingStatus": "Réflexion en cours…", - "toolCallArgsPrefix": "args", - "toolCallResultPrefix": "résultat", - "toolCallStatusCompleted": "terminé", - "toolCallStatusError": "erreur", - "toolCallStatusErrors": "erreurs", - "toolCallStatusRunning": "en cours", "toolCalls": "Appels d'outils", - "toolCallsCount": "{{count}} appels d'outils", - "toolCallsHeader": "Appels d'outils", + "toolCallsCount_one": "", + "toolCallsCount_other": "", "typeMessage": "Tapez un message…", "unreadMessages": "Messages non lus", "untitledSession": "Sans titre", - "viewFailureDetails": "Voir les détails de l'échec", "you": "Vous" }, "chatRooms": { @@ -1295,8 +1315,8 @@ "cli": { "installBody": "Obtenez les commandes {{fn}} et {{fusion}} sur votre terminal pour pouvoir piloter Fusion de n'importe où. Un clic ci-dessous ou copiez la commande dans votre shell.", "installButton": "Installer avec npm", - "installTitle": "Installer la CLI Fusion", "installing": "Installation en cours…", + "installTitle": "Installer la CLI Fusion", "openSettings": "Ouvrir les paramètres", "updateButton": "Mettre à jour avec npm", "updateTitle": "Mettre à jour la CLI Fusion", @@ -1312,8 +1332,8 @@ "failedExit": "Installation échouée (code de sortie {{code}})", "heading": "Binaire CLI", "help": "L'installation du CLI global vous permet d'exécuter fn et fusion depuis n'importe quel terminal. Les automatisations et les scripts fonctionnent sans cela via npx, mais une installation globale est plus rapide et plus pratique.", - "installWithNpm": "Installer avec npm", "installing": "Installation…", + "installWithNpm": "Installer avec npm", "notOnPath": "Ni fn ni fusion n'ont été trouvés sur PATH.", "orCopyLabel": "Ou copiez et exécutez vous-même :", "refresh": "Actualiser", @@ -1329,9 +1349,11 @@ "actionsTitle": "Actions de colonne", "archiveAllDoneAriaLabel": "Archiver toutes les tâches terminées", "archiveAllDoneTitle": "Archiver toutes les tâches terminées", - "archiveAllMessage": "Archiver toutes les {{count}} tâches terminées ?", + "archiveAllMessage_one": "", + "archiveAllMessage_other": "", "archiveAllTitle": "Archiver tout ce qui est terminé", - "archivedTasks": "{{count}} tâche(s) archivée(s)", + "archivedTasks_one": "", + "archivedTasks_other": "", "autoMerge": "Fusion automatique", "autoMergeDisabled": "Fusion automatique désactivée", "autoMergeEnabled": "Fusion automatique activée", @@ -1342,26 +1364,36 @@ "expandArchivedTitle": "Développer les tâches archivées", "failedToArchive": "Échec de l'archivage des tâches", "keepProgress": "Conserver la progression", - "loadMore": "Charger {{count}} de plus ({{remaining}} restant(s))", + "loadMore_one": "", + "loadMore_other": "", "moveAllToTodo": "Déplacer tout vers À faire", - "moveAllToTodoMessage": "Déplacer tous les {{count}} {{columnLabel}} tâche{{plural}} vers À faire ?", + "moveAllToTodoMessage_one": "", + "moveAllToTodoMessage_other": "", "moveAllToTodoTitle": "Déplacer tout vers À faire", + "movedToPlanning_one": "", + "movedToPlanning_other": "", + "movedToTodo_one": "", + "movedToTodo_other": "", "movePartialFailure": "{{moved}} tâche(s) sur {{total}} déplacée(s) ; {{failed}} échouée(s)", - "moveToTodoHint": "Déplacer {{count}} tâche{{plural}} vers À faire", + "moveToTodoHint_one": "", + "moveToTodoHint_other": "", "moveToTodoPartialFailure": "{{moved}} tâche(s) sur {{total}} déplacée(s) vers À faire ; {{failed}} échouée(s)", - "movedToPlanning": "{{count}} tâche{{plural}} déplacée(s) vers la planification pour replanification", - "movedToTodo": "{{count}} tâche{{plural}} déplacée(s) vers À faire", "newTask": "Nouvelle tâche", "noManuallyPausableTasks": "Aucune tâche pouvant être mise en pause manuellement", "noTasks": "Aucune tâche", "noTasksInColumn": "Aucune tâche dans cette colonne", - "pauseHint": "Mettre en pause {{count}} tâche(s) active(s) non assignée(s)", + "pauseHint_one": "", + "pauseHint_other": "", "preserveProgressMessage": "Cette tâche a des étapes terminées. Conserver la progression avant de déplacer ?", "preserveProgressMoveTodoMessage": "Certaines tâches ont des étapes terminées. Conserver la progression avant de déplacer vers À faire ?", "preserveProgressTitle": "Conserver la progression ?", + "promote": "", + "promoting": "", "replanAll": "Replanifier tout", - "replanAllHint": "Déplacer {{count}} tâche{{plural}} vers Planification", - "replanAllMessage": "Remplacer tous les {{count}} tâche{{plural}} à faire en planification pour être replanifiée(s) ?", + "replanAllHint_one": "", + "replanAllHint_other": "", + "replanAllMessage_one": "", + "replanAllMessage_other": "", "replanAllTitle": "Replanifier toutes les tâches", "resetProgress": "Réinitialiser la progression", "resetProgressConfirm": "Réinitialiser la progression", @@ -1369,10 +1401,12 @@ "resetProgressMoveTodoMessage": "Réinitialiser la progression des étapes des tâches avant de déplacer vers À faire ?", "resetProgressTitle": "Réinitialiser la progression ?", "stopAll": "Arrêter tout", - "stopAllMessage": "Arrêter tous les {{count}} {{columnLabel}} tâche{{plural}} ?", + "stopAllMessage_one": "", + "stopAllMessage_other": "", "stopAllTitle": "Arrêter toutes les tâches", "stopPartialFailure": "{{paused}} tâche(s) sur {{total}} arrêtée(s) ; {{failed}} échouée(s)", - "stoppedTasks": "{{count}} tâche{{plural}} arrêtée(s)" + "stoppedTasks_one": "", + "stoppedTasks_other": "" }, "comments": { "addButton": "Ajouter un commentaire", @@ -1387,7 +1421,8 @@ "updatedSuccess": "Commentaire mis à jour" }, "commit": { - "filesChanged": "Fichiers modifiés ({{count}})" + "filesChanged_one": "", + "filesChanged_other": "" }, "commitDiff": { "error": "Erreur lors du chargement du diff de commit : {{error}}", @@ -1398,6 +1433,7 @@ "noSha": "Aucun SHA de commit disponible." }, "common": { + "archive": "", "back": "Retour", "cancel": "Annuler", "close": "Fermer", @@ -1419,11 +1455,13 @@ "save": "Enregistrer", "saveAndTest": "Enregistrer et tester", "saving": "Enregistrement…", + "skip": "", "somethingWentWrong": "Une erreur s'est produite lors du chargement de cette vue.", "stop": "Arrêter", "test": "Tester", "testing": "Test en cours…", "total": "total", + "tryAgain": "", "unableToLoadData": "Impossible de charger les données", "unknown": "Inconnu", "unsavedChanges": "Modifications non enregistrées", @@ -1436,8 +1474,8 @@ "messagePlaceholder": "Saisissez votre message…", "newMessageTitle": "Nouveau message", "noAgentsAvailable": "Aucun agent disponible", - "replyTitle": "Répondre", "replyingToLabel": "Répondre à :", + "replyTitle": "Répondre", "selectAgent": "Sélectionnez un agent…", "sendingButton": "Envoi en cours…", "toLabel": "À :", @@ -1464,30 +1502,19 @@ "createRoom": { "create": "Créer une salle", "creating": "Création en cours...", - "duplicate": "Une salle portant ce nom existe déjà.", "failedCreate": "Échec de la création de la salle.", "failedLoadAgents": "Échec du chargement des agents.", "loadingAgents": "Chargement des agents...", - "lowercase": "Utilisez uniquement des lettres minuscules.", - "maxLength": "Les noms de salle peuvent contenir au maximum 80 caractères.", "members": "Membres", "nameLabel": "Nom de la salle", - "nameRequired": "Le nom de la salle est requis.", "noAgents": "Aucun agent dans ce projet pour le moment.", - "noEdgeChars": "Les noms de salle ne peuvent pas commencer ou se terminer par un tiret ou un trait de soulignement.", "noMatch": "Aucun agent ne correspond à votre recherche.", "searchAgents": "Rechercher des agents", "selectMember": "Sélectionnez au moins un membre.", - "title": "Créer une salle", - "validChars": "Utilisez uniquement des lettres minuscules, des chiffres, des tirets ou des traits de soulignement." + "title": "Créer une salle" }, "dashboard": { "initializingDashboard": "Initialisation du tableau de bord...", - "loaderSteps": { - "project": "Sélection du projet", - "projects": "Chargement des projets", - "tasks": "Récupération des tâches" - }, "loadingMessage": "Chargement du tableau de bord Fusion", "loadingProgress": "Progression du chargement du tableau de bord", "updatingMessage": "Mise à jour du tableau de bord Fusion", @@ -1537,15 +1564,18 @@ "filterBySeverity": "Filtrer les journaux par sévérité", "info": "Info", "lines": "lignes", - "loadOlderLogs": "Charger les anciens journaux", + "lines_one": "", + "lines_other": "", "loading": "Chargement...", "loadingConfig": "Chargement de la configuration du serveur de développement...", "loadingLogs": "Chargement des journaux…", "loadingOlderLogs": "Chargement des anciens journaux…", + "loadOlderLogs": "Charger les anciens journaux", "logs": "Journaux", "lostConnection": "Connexion au flux de journaux perdue.", "manual": "Manuel", - "matchCount": "{{count}} résultat", + "matchCount_one": "", + "matchCount_other": "", "newLogs": "Nouveaux journaux", "noLogsYet": "Aucun journal pour le moment. Démarrez le serveur de développement pour voir la sortie.", "noMatchesSearch": "Aucune ligne de journal ne correspond à votre recherche.", @@ -1712,7 +1742,8 @@ "clearSearch": "Effacer la recherche", "collapse": "Réduire", "collapseContent": "Réduire le contenu", - "docCount": "{{count}} doc{{plural}}", + "docCount_one": "", + "docCount_other": "", "documentsCreatedIn": "Les documents sont créés dans les onglets de détail des tâches.", "expand": "Développer", "expandContent": "Développer le contenu", @@ -1732,7 +1763,8 @@ "plain": "Brut", "projectFiles": "fichiers du projet", "projectFilesTab": "Fichiers du projet", - "resultCount": "{{count}} résultat{{plural}}", + "resultCount_one": "", + "resultCount_other": "", "retry": "Réessayer", "retryLoading": "Réessayer de charger les documents", "searchProjectFiles": "Rechercher les fichiers markdown du projet…", @@ -1811,21 +1843,26 @@ }, "executor": { "blocked": "Bloqué", - "daysAgo": "il y a {{count}}j", + "daysAgo_one": "", + "daysAgo_other": "", "escalated": "Escaladé", "escalatedSuffix": " (escaladé)", "hideProjectDir": "Masquer le répertoire du projet", - "hoursAgo": "il y a {{count}}h", + "hoursAgo_one": "", + "hoursAgo_other": "", "inReview": "En révision", "justNow": "à l'instant", "loading": "Chargement...", - "minutesAgo": "il y a {{count}}min", + "minutesAgo_one": "", + "minutesAgo_other": "", "noActivity": "aucune activité", - "overlapBottleneck": "Goulot d'étranglement de chevauchement {{status}} {{blockerId}}: {{count}} tâches bloquées via blockedBy (seuil {{threshold}})", + "overlapBottleneck_one": "", + "overlapBottleneck_other": "", "overlapQueue": "File de chevauchement", "queued": "En attente", "running": "En cours", - "secondsAgo": "il y a {{count}}s", + "secondsAgo_one": "", + "secondsAgo_other": "", "showProjectDir": "Afficher le répertoire du projet", "stateIdle": "Inactif", "statePaused": "En pause", @@ -1906,8 +1943,10 @@ "advancesHelpItem2": "reachable / subsumed / orphaned / superseded — déjà traité (y compris les réécritures d'historique où un contenu équivalent a déjà atterri, les SHAs originaux ont disparu, ou HEAD est déjà aligné sur la pointe d'intégration réécrite).", "advancesHelpItem3": "pending + off / not run — la synchronisation automatique est désactivée dans les Paramètres ; la référence de branche a bougé mais votre arbre de travail n'a pas suivi.", "advancesHelpItem4": "pending + stash-failed / would-conflict / similaire — la synchronisation automatique a tenté mais n'a pas pu réconcilier (généralement les modifications locales entrent en conflit avec le nouveau commit).", - "advancesNeedAction": "{{count}} nécessite(nt) une action", - "aheadOfUpstream": "{{count}} commit(s) en avance sur l'amont", + "advancesNeedAction_one": "", + "advancesNeedAction_other": "", + "aheadOfUpstream_one": "", + "aheadOfUpstream_other": "", "aligned": "Aligné", "apply": "Appliquer", "applyStashKeep": "Appliquer la remise (conserver)", @@ -1923,7 +1962,8 @@ "backToIssuesList": "Retour à la liste des tickets", "backToPullsList": "Retour à la liste des pull requests", "baseHead": "Base : HEAD", - "behindUpstream": "{{count}} commit(s) en retard sur l'amont", + "behindUpstream_one": "", + "behindUpstream_other": "", "branchLabel": "Branche :", "cancel": "Annuler", "capturedAt": "Capturé :", @@ -1936,16 +1976,20 @@ "commentLast": "Dernier :", "commit": "Commiter", "commitMessagePlaceholder": "Message de commit…", - "commitStagedChanges": "Commiter les modifications indexées", "commitsOnBranch": "Commits sur {{name}}", - "commitsToPull": "{{count}} à tirer", - "commitsToPush": "{{count}} à pousser", - "commitsToPushHeader": "Commits à pousser ({{count}})", + "commitStagedChanges": "Commiter les modifications indexées", + "commitsToPull_one": "", + "commitsToPull_other": "", + "commitsToPush_one": "", + "commitsToPush_other": "", + "commitsToPushHeader_one": "", + "commitsToPushHeader_other": "", "committedHash": "Commit effectué : {{hash}}", + "conflictedCount_one": "", + "conflictedCount_other": "", "conflictReclaimFailed": "Échec de la mise en file d'attente de la récupération du conflit", "conflictReclaimQueued": "Récupération du conflit mise en file d'attente", "conflictReclaimUnavailable": "Récupération du conflit indisponible", - "conflictedCount": "{{count}} en conflit", "conflictsButton": "Conflits", "copiedButton": "Copié", "copiedLabel": "Copié : {{label}}", @@ -1962,9 +2006,9 @@ "couldNotLoadIssues": "Impossible de charger les tickets", "couldNotLoadPulls": "Impossible de charger les pull requests", "create": "Créer", + "createdBranch": "Branche {{name}} créée", "createPrButton": "Créer une PR", "createPrTitle": "Créer une PR pour cette tâche", - "createdBranch": "Branche {{name}} créée", "defaultBadge": "défaut", "deleteBranch": "Supprimer", "deleteBranchMessage": "Supprimer la branche « {{name}} » ?", @@ -1972,10 +2016,12 @@ "deletedBranch": "Branche {{name}} supprimée", "detectingRemotes": "Détection en cours…", "diffColon": "diff :", - "discardChangesMessage": "Supprimer les modifications de {{count}} fichier(s) ? Cette action est irréversible.", + "discardChangesMessage_one": "", + "discardChangesMessage_other": "", "discardChangesTitle": "Supprimer les modifications", + "discardedFiles_one": "", + "discardedFiles_other": "", "discardSelected": "Supprimer la sélection", - "discardedFiles": "Modifications de {{count}} fichier(s) supprimées", "dismiss": "Ignorer", "dismissPrError": "Ignorer l'erreur de PR", "dropStash": "Supprimer le stash", @@ -2011,9 +2057,9 @@ "fetch": "Récupérer", "fetchCompleted": "Récupération terminée", "fetchFailed": "Échec de la récupération", + "fetchingFromGitHub": "Récupération de la dernière liste depuis GitHub.", "fetchLabel": "Fetch :", "fetchUrlLabel": "URL de fetch", - "fetchingFromGitHub": "Récupération de la dernière liste depuis GitHub.", "filterBranches": "Filtrer les branches…", "filterByLabelsLabel": "Filtrer par étiquettes", "filterByLabelsPlaceholder": "Filtrer : bug,enhancement…", @@ -2025,24 +2071,27 @@ "forceDeletedBranch": "Branche {{name}} supprimée de force", "fullShaAbbrev": "complet", "ghAuthLoginHint": "Exécutez {{code}} pour activer la création de PR.", - "headAheadOfIntegration": "HEAD a {{count}} commit(s) absent(s) de {{branch}}", - "headAheadOfOriginIntegration": "HEAD a {{count}} commit(s) absent(s) de origin/{{branch}}", + "headAheadOfIntegration_one": "", + "headAheadOfIntegration_other": "", + "headAheadOfOriginIntegration_one": "", + "headAheadOfOriginIntegration_other": "", "headVsIntegration": "HEAD vs {{branch}}", "headVsOriginIntegration": "HEAD vs origin/{{branch}}", "hide": "Masquer", "hideExplanation": "Masquer l'explication", "import": "Importer", + "imported": "Importé", + "importedCount_one": "", + "importedCount_other": "", "importFromGitHub": "Importer depuis GitHub", "importSubtitle": "Choisissez un dépôt distant détecté, chargez les tickets ou pull requests ouverts, et importez-en un dans le tableau.", "importTypeAriaLabel": "Type d'importation", - "imported": "Importé", - "importedCount": "{{count}} importé", - "integrationAheadOfHead": "{{branch}} a {{count}} commit(s) absent(s) de HEAD", - "issueCount": "{{count}} ticket", + "integrationAheadOfHead_one": "", + "integrationAheadOfHead_other": "", + "issueCount_one": "", + "issueCount_other": "", "load": "Charger", "loadFromRepoAriaLabel": "Charger {{tab}} depuis le dépôt", - "loadMoreCommits": "Charger plus de commits", - "loadTabTitle": "Charger {{tab}}", "loading": "Chargement…", "loadingAriaLabel": "Chargement de {{tab}}", "loadingCommits": "Chargement des commits…", @@ -2051,23 +2100,28 @@ "loadingPulls": "Chargement des pull requests ouvertes…", "loadingStashDiff": "Chargement du diff de remise…", "loadingTitle": "Chargement…", - "localAheadOfOriginIntegration": "{{branch}} local est {{count}} commit(s) en avance sur origin/{{branch}}", - "localBehindOriginIntegration": "{{branch}} local est {{count}} commit(s) en retard sur origin/{{branch}}", + "loadMoreCommits": "Charger plus de commits", + "loadTabTitle": "Charger {{tab}}", + "localAheadOfOriginIntegration_one": "", + "localAheadOfOriginIntegration_other": "", + "localBehindOriginIntegration_one": "", + "localBehindOriginIntegration_other": "", "localVsOrigin": "{{branch}} local vs origin", "manualPrFlowHint": "Utilisez l'action du pied de page pour effectuer la complétion PR-first pour cette tâche.", "mergeBadge": "fusion", "mergeConflictDetected": "Conflit de fusion détecté. Résolvez-le manuellement.", + "mergedTaskDone": "Fusionné — tâche déplacée vers Terminé", "mergeLabel": "Fusion", "mergePrButton": "Fusionner la pull request", "mergeStrategyMerge": "fusionner", "mergeStrategyRebase": "rebaser", "mergeStrategySquash": "écraser", - "mergedTaskDone": "Fusionné — tâche déplacée vers Terminé", "mergingPrHint": "Fusion de la pull request en cours…", "mergingStatus": "Fusion en cours…", "modalTitle": "Gestionnaire Git", "modified": "Modifié", - "modifiedCount": "{{count}} modifié(s)", + "modifiedCount_one": "", + "modifiedCount_other": "", "newBranchName": "Nom de la nouvelle branche", "noAheadCommitsFound": "Aucun commit en avance trouvé (une récupération est peut-être nécessaire)", "noBranchesFound": "Aucune branche trouvée", @@ -2081,9 +2135,7 @@ "noMatchingBranches": "Aucune branche correspondante", "noMatchingCommits": "Aucun commit correspondant", "noOpenIssues": "Aucun ticket ouvert trouvé", - "noOpenIssuesFound": "Aucun ticket ouvert trouvé", "noOpenPulls": "Aucune pull request ouverte trouvée", - "noOpenPullsFound": "Aucune pull request ouverte trouvée", "noOriginTracking": "pas de suivi origin", "noPullSelected": "Aucune pull request sélectionnée", "noPullSelectedHint": "Choisissez une pull request dans la liste pour inspecter ses détails.", @@ -2097,14 +2149,15 @@ "noStagedChanges": "Aucune modification indexée", "noStagedChangesToCommit": "Aucune modification indexée à commiter", "noStashes": "Aucune remise", - "noUnstagedChanges": "Aucune modification non indexée", + "nothingLoadedInstructions": "Sélectionnez un dépôt et cliquez sur Charger pour commencer à examiner les candidats à l'importation.", + "nothingLoadedYet": "Rien de chargé pour l'instant", "notOnIntegrationBranch": "(pas sur {{branch}})", "notOnIntegrationBranchBtn": "Pas sur la branche d'intégration ({{branch}})", "notOnIntegrationBranchTitle": "Actuellement sur une branche non intégrée", - "nothingLoadedInstructions": "Sélectionnez un dépôt et cliquez sur Charger pour commencer à examiner les candidats à l'importation.", - "nothingLoadedYet": "Rien de chargé pour l'instant", + "noUnstagedChanges": "Aucune modification non indexée", "openPullsFrom": "Pull requests ouvertes de {{remote}}", - "originIntegrationAheadOfHead": "origin/{{branch}} a {{count}} commit(s) absent(s) de HEAD", + "originIntegrationAheadOfHead_one": "", + "originIntegrationAheadOfHead_other": "", "pop": "Dépiler", "popStashTitle": "Dépiler la remise (appliquer et supprimer)", "prAuthUnavailable": "Authentification PR indisponible — exécutez « gh auth login »", @@ -2116,34 +2169,36 @@ "summary": "{{passing}} réussis, {{failing}} échoués, {{pending}} en attente", "viewDetails": "Afficher les détails" }, - "prMergeFailed": "Échec de la fusion de la pull request", + "previewHeading": "Aperçu", + "previewIssueMeta": "Ticket #{{number}}", + "previewPullMeta": "Pull request #{{number}}", "prMerged": "Pull request fusionnée", + "prMergeFailed": "Échec de la fusion de la pull request", + "projectRootNotAvailable": "Chemin racine du projet non disponible", "prRefreshFailed": "Échec du rafraîchissement de la PR", "prStatusRefreshed": "Statut de la PR mis à jour", "prUnlinkConfirm": "Délier la PR #{{number}} de cette tâche ? La PR ne sera pas fermée.", "prUnlinked": "PR #{{number}} déliée", - "previewHeading": "Aperçu", - "previewIssueMeta": "Ticket #{{number}}", - "previewPullMeta": "Pull request #{{number}}", - "projectRootNotAvailable": "Chemin racine du projet non disponible", "pull": "Pull", "pullCompleted": "Pull terminé", - "pullCount": "{{count}} pull request", + "pullCount_one": "", + "pullCount_other": "", "pullFailed": "Échec du pull", "pullOptions": "Options de pull", "pullOptionsMenu": "Menu d'options de pull", "pullRebase": "Pull --rebase", "pullRebaseCompleted": "Pull --rebase terminé", "pullRequestHeading": "Pull request", - "pullRequestsCount": "{{count}} pull requests", + "pullRequestsCount_one": "", + "pullRequestsCount_other": "", "push": "Push", "pushCompleted": "Push terminé", "pushFailed": "Échec du push", "pushLabel": "Push :", "pushUrlLabel": "URL de push", - "reCheckConflicts": "Revérifier les conflits", "recentCommitsOnRemote": "Commits récents sur {{remote}}", "recentIntegrationAdvances": "Avancées récentes de la branche d'intégration", + "reCheckConflicts": "Revérifier les conflits", "refresh": "Actualiser", "refreshPrStatus": "Rafraîchir le statut de la PR", "refreshToCheckMerge": "Rafraîchir le statut de la PR pour vérifier si la fusion est prête", @@ -2176,24 +2231,28 @@ "sectionStashes": "Remises", "sectionStatus": "État", "sectionWorktrees": "Worktrees", + "selectedRemote": "dépôt distant sélectionné", "selectFileToViewDiff": "Sélectionnez un fichier pour afficher son diff", "selectIssueAriaLabel": "Sélectionner le ticket #{{number}}", "selectPullAriaLabel": "Sélectionner la pull request #{{number}}", "selectRemoteAriaLabel": "Sélectionner le dépôt distant Git", "selectRemotePlaceholder": "Sélectionner un dépôt distant…", "selectRemoteToViewDetails": "Sélectionnez un distant pour voir les détails", - "selectedRemote": "dépôt distant sélectionné", "sidebarAriaLabel": "Sections du gestionnaire Git", "stageAll": "Tout indexer", "stageAllAndCommit": "Tout indexer et commiter", "stageAllAndCommitTitle": "Tout indexer et commiter", - "stageCount": "Indexer ({{count}})", + "stageCount_one": "", + "stageCount_other": "", + "staged": "Indexé", + "stagedChanges_one": "", + "stagedChanges_other": "", + "stagedCount_one": "", + "stagedCount_other": "", + "stagedFiles_one": "", + "stagedFiles_other": "", "stageFile": "Indexer le fichier", "stageSelected": "Indexer la sélection", - "staged": "Indexé", - "stagedChanges": "Modifications indexées ({{count}})", - "stagedCount": "{{count}} indexé(s)", - "stagedFiles": "{{count}} fichier(s) indexé(s)", "staleIndexWarning": "Index obsolète détecté. HEAD a avancé (généralement parce que le merger de Fusion a mis à jour la référence de la branche d'intégration) mais l'index reflète toujours l'ancienne pointe — `git status` signalera les nouveaux commits inversés comme « modifications indexées ». Activez mergeAdvanceAutoSync dans les Paramètres pour que le merger réconcilie automatiquement, ou exécutez git reset --hard HEAD pour avancer manuellement.", "stash": "Remiser", "stashApplied": "Remise appliquée", @@ -2220,17 +2279,17 @@ "statusLabelWorkingTree": "Arbre de travail", "switchedToBranch": "Basculé sur {{name}}", "sync": "Synchroniser", + "synced": "Synchronisé", + "syncedWithOrigin": "Synchronisé avec origin (pull --rebase + push)", + "syncedWorktreeToIntegrationTip": "Arbre de travail synchronisé avec la pointe d'intégration locale", "syncFailed": "Échec de la synchronisation", + "syncing": "Synchronisation…", "syncLocalTip": "Sync pointe locale", "syncLocalTipTitle": "Synchroniser l'arbre de travail avec la pointe d'intégration locale (identique au Pull de la bannière)", "syncOriginTitle": "Pull --rebase depuis origin, puis pousser la branche courante", "syncWithOriginFailed": "Échec de la synchronisation avec origin", "syncWorkingTree": "Synchroniser l'arbre de travail", "syncWorkingTreeTitle": "Tirer la branche d'intégration dans votre arbre de travail (remise automatique des modifications non commitées puis restauration)", - "synced": "Synchronisé", - "syncedWithOrigin": "Synchronisé avec origin (pull --rebase + push)", - "syncedWorktreeToIntegrationTip": "Arbre de travail synchronisé avec la pointe d'intégration locale", - "syncing": "Synchronisation…", "tabIssues": "Tickets", "tabPullRequests": "Pull requests", "tip": "pointe", @@ -2239,14 +2298,18 @@ "unlinkButton": "Délier", "unresolvedMergeConflicts": "Conflits de fusion non résolus", "unstageAll": "Tout désindexer", - "unstageCount": "Désindexer ({{count}})", + "unstageCount_one": "", + "unstageCount_other": "", + "unstaged": "Non indexé", + "unstagedChanges_one": "", + "unstagedChanges_other": "", + "unstagedFiles_one": "", + "unstagedFiles_other": "", "unstageFile": "Désindexer le fichier", "unstageSelected": "Désindexer la sélection", - "unstaged": "Non indexé", - "unstagedChanges": "Modifications non indexées ({{count}})", - "unstagedFiles": "{{count}} fichier(s) retiré(s) de l'index", "untracked": "Non suivi", - "untrackedCount": "{{count}} non suivi(s)", + "untrackedCount_one": "", + "untrackedCount_other": "", "upToDate": "À jour", "view": "Voir", "viewOnGithub": "Voir sur GitHub", @@ -2256,18 +2319,21 @@ "workingTreeModified": "Modifié", "worktreeBadgeBare": "nu", "worktreeBadgeMain": "principale", - "worktreesInUse": "{{count}} en cours d'utilisation", - "worktreesTotal": "{{count}} au total" + "worktreesInUse_one": "", + "worktreesInUse_other": "", + "worktreesTotal_one": "", + "worktreesTotal_other": "" }, "goals": { - "activeCount": "{{count}} objectifs actifs", + "activeCount_one": "", + "activeCount_other": "", "addGoal": "Ajouter un objectif", "archive": "Archiver", "capError": "Impossible d'activer plus de 5 objectifs. Résolvez un objectif actif avant d'en activer un autre.", "capWarning": "Approche du plafond de 5 objectifs actifs. Gardez les objectifs actifs concentrés.", "createError": "Impossible de créer l'objectif pour le moment. Veuillez réessayer.", - "draftWithAi": "Rédiger avec l'IA", "drafting": "Rédaction en cours…", + "draftWithAi": "Rédiger avec l'IA", "emptyState": "Aucun objectif pour le moment. Ajoutez-en un pour commencer à suivre les résultats stratégiques.", "labelDescription": "Description", "labelTitle": "Titre", @@ -2281,6 +2347,7 @@ "updateError": "Impossible de mettre à jour le statut de l'objectif pour le moment. Veuillez réessayer." }, "groupTask": { + "abandonGroup": "", "ariaLabel": "Détails du groupe de branches", "autoMergeEnabled": "Fusion automatique activée", "completionText": "{{landed}} sur {{total}} membres terminés", @@ -2290,12 +2357,16 @@ "mergeIntoMain": "Fusionner le groupe dans main", "openPR": "Ouvrir RP", "openTask": "Ouvrir la tâche", + "prClosed": "", + "prMerged": "", "sharedBranch": "Branche partagée", "status": "Statut", "title": "Groupe de branches {{id}}", "unavailable": "Groupe de branches indisponible" }, "header": { + "activePlanningSessions_one": "", + "activePlanningSessions_other": "", "addFirstScript": "Ajouter votre premier script", "additionalHeaderActions": "Actions supplémentaires dans l'en-tête", "agentsView": "Vue agents", @@ -2321,7 +2392,8 @@ "localNode": "Local", "mailbox": "Boîte de réception", "mailboxView": "Vue boîte de réception", - "mailboxWithCount": "Boîte de réception ({{count}})", + "mailboxWithCount_one": "", + "mailboxWithCount_other": "", "manageProjects": "Gérer les projets", "manageScripts": "Gérer les scripts…", "memoryView": "Mémoire", @@ -2330,10 +2402,10 @@ "moreHeaderActions": "Plus d'actions dans l'en-tête", "moreViews": "Plus de vues", "noBaseBranch": "Aucune branche de base", + "nodes": "Nœuds", "noScriptsAddOne": "Aucun script — en ajouter un…", "noScriptsConfigured": "Aucun script configuré", "noWorkingBranch": "Aucune branche de travail", - "nodes": "Nœuds", "openSearch": "Ouvrir la recherche", "openTerminal": "Ouvrir le terminal", "pauseTriage": "Mettre en pause le triage", @@ -2343,7 +2415,8 @@ "reliabilityView": "Fiabilité", "researchView": "Recherche", "resumePlanningSession": "Reprendre la session de planification", - "resumePlanningSessionCount": "Reprendre la session de planification ({{count}})", + "resumePlanningSessionCount_one": "", + "resumePlanningSessionCount_other": "", "resumeScheduling": "Reprendre la planification", "scripts": "Scripts", "scriptsSubmenu": "Sous-menu des scripts", @@ -2364,7 +2437,8 @@ "terminal": "Terminal", "todosView": "Tâches", "unreadChatResponse": "Réponse de chat non lue", - "unreadMessages": "{{count}} message(s) non lu(s)", + "unreadMessages_one": "", + "unreadMessages_other": "", "viewActivityLog": "Voir le journal d'activité", "viewProjects": "Voir les projets", "viewUsage": "Voir l'utilisation", @@ -2373,12 +2447,6 @@ }, "health": { "activeTasks": "Tâches actives", - "anomaly": { - "duplicateActiveId": "ID de tâche actif dupliqué", - "idInBothStorages": "ID de tâche présent dans le stockage actif et archivé", - "sequenceOverlap": "La prochaine séquence de l'allocateur chevauche un ID de tâche existant", - "unknownPrefix": "La ligne de tâche utilise un préfixe en dehors de l'état de l'allocateur" - }, "anomalyBody": "Fusion a détecté un état d'allocateur pouvant entraîner la réutilisation d'ID de tâche ou le remplacement de records de tâche actifs.", "anomalyDetected": "Anomalie d'intégrité détectée pour les ID de tâche", "completed": "Complété", @@ -2436,26 +2504,23 @@ "collapse": "Réduire", "collapseDescription": "Décrire la description", "collapseTaskOptions": "Réduire les options de tâche avancées", - "connecting": "Connexion", "creating": "Création...", "custom": "Personnalisé", "deps": "Dépendances", "editingDescription": "Description d'édition", "enableBrowserVerification": "Activer l'étape de flux de vérification du navigateur", "enterDescriptionFirst": "Entrez d'abord une description", - "error": "Erreur", "expand": "Développer", "expandDescription": "Développer la description", "expandTaskOptions": "Développer les options de tâche avancées", "hintEnterEsc": "Entrée pour créer · Échap pour annuler", "loadingAgents": "Chargement des agents...", - "model": "modèle", + "model_one": "", + "model_other": "", "models": "Modèles", "noAgentsAvailable": "Aucun agent disponible", - "noExistingTasks": "Aucune tâche existante", "node": "Nœud", - "offline": "Hors ligne", - "online": "En ligne", + "noExistingTasks": "Aucune tâche existante", "openPlanningMode": "Ouvrir le mode de planification avec la description actuelle", "plan": "Planifier", "preset": "Préréglage", @@ -2475,39 +2540,22 @@ "allInsights": "Tous les Insights", "alreadyRunning": "La génération d'informations est déjà en cours. Affichage de l'exécution active.", "alreadyRunningShort": "La génération d'informations est déjà en cours", - "archiveLabel": "Archiver cet insight", - "archiveTitle": "Archiver cet insight", "archived": "\"{{title}}\" archivé", "archivedMsg": "Information archivée : {{title}}", + "archiveLabel": "Archiver cet insight", + "archiveTitle": "Archiver cet insight", "archiving": "Archivage de \"{{title}}\"...", "backlogHealth": "Santé du backlog", - "category": { - "architecture": "Architecture", - "competitive_analysis": "Analyse concurrentielle", - "dependency": "Dépendances", - "documentation": "Documentation", - "features": "Fonctionnalités", - "other": "Autre", - "performance": "Performance", - "quality": "Qualité", - "reliability": "Fiabilité", - "research": "Recherche", - "security": "Sécurité", - "testability": "Testabilité", - "trends": "Tendances", - "ux": "Expérience utilisateur", - "workflow": "Flux de travail" - }, "configureModel": "Configurer le modèle de génération d'insights", "configureModelTitle": "Configurer le modèle", "createTaskLabel": "Créer une tâche à partir de cet insight", "createTaskTitle": "Créer une tâche à partir de cet insight", "creatingTask": "Création d'une tâche à partir de \"{{title}}\"...", - "dismissLabel": "Fermer cet insight", - "dismissTitle": "Fermer cet insight", "dismissed": "\"{{title}}\" fermé", "dismissedMsg": "Information fermée : {{title}}", "dismissing": "Fermeture de \"{{title}}\"...", + "dismissLabel": "Fermer cet insight", + "dismissTitle": "Fermer cet insight", "failedToArchive": "Échec de l'archivage de l'information", "failedToCreateTask": "Échec de la création de la tâche", "failedToDismiss": "Échec de la fermeture de l'information", @@ -2516,6 +2564,7 @@ "failedToUnarchive": "Échec de la désarchivation de l'information", "generateDescription": "Générez des insights pour obtenir des recommandations alimentées par l'IA pour votre projet.", "generateFirst": "Générer les premiers Insights", + "generateInsights": "", "generateInsightsBtn": "Générer des Insights", "generating": "Génération en cours...", "generatingInsights": "Génération d'informations...", @@ -2531,16 +2580,17 @@ "runCompleted": "{{created}} créé(s), {{updated}} mis à jour", "showAllInsights": "Afficher tous les insights", "showArchived": "Afficher les insights archivés", - "showArchivedLabel": "Afficher l'archive ({{count}})", + "showArchivedLabel_one": "", + "showArchivedLabel_other": "", "showBacklogHealth": "Afficher uniquement les insights de santé des backlogs", "taskCreated": "Tâche créée à partir de \"{{title}}\"", "taskCreatedMsg": "Tâche créée : {{title}}", "taskCreationUnavailable": "La création de tâches n'est pas disponible dans cette vue", "title": "Insights", - "unarchiveLabel": "Désarchiver cet insight", - "unarchiveTitle": "Désarchiver cet insight", "unarchived": "\"{{title}}\" désarchivé", "unarchivedMsg": "Information désarchivée : {{title}}", + "unarchiveLabel": "Désarchiver cet insight", + "unarchiveTitle": "Désarchiver cet insight", "unarchiving": "Désarchivage de \"{{title}}\"...", "usePlanningDefault": "Utiliser la valeur par défaut de planification" }, @@ -2570,8 +2620,8 @@ "preparingQuestion": "Préparation de la prochaine question...", "progressText": "Question {{progress}} sur ~6", "reconnecting": "Reconnexion…", - "refineScope": "Affiner la portée {{label}} avec l'IA", "refinedScope": "Portée affinée", + "refineScope": "Affiner la portée {{label}} avec l'IA", "sendToBackground": "Envoyer en arrière-plan", "sessionActiveAnotherTab": "La session est active dans un autre onglet.", "showThinking": "Afficher la réflexion", @@ -2586,6 +2636,10 @@ "verificationCriteria": "Critères de vérification", "yes": "Oui" }, + "lane": { + "collapse": "", + "expand": "" + }, "listView": { "apply": "Appliquer", "applying": "Application en cours…", @@ -2593,16 +2647,18 @@ "archiveSelectedTitle": "Archiver les tâches sélectionnées qui sont terminées", "archiveUnavailable": "L'action Archiver est indisponible", "archiveViaButton": "Les tâches ne peuvent être archivées qu'via le bouton d'archivage", - "bulkArchiveDone": "Archiver {{count}} terminée(s)", - "bulkArchiveMessage": "Archiver {{count}} tâche(s) sélectionnée(s) ?", + "bulkArchiveDone_one": "", + "bulkArchiveDone_other": "", + "bulkArchiveMessage_one": "", + "bulkArchiveMessage_other": "", "bulkArchiveNoTasks": "Aucune tâche sélectionnée ne peut être archivée (uniquement les tâches terminées)", "bulkArchiveSummary": "{{archived}} archivée(s) · {{skipped}} ignoré(s) · {{failed}} échoué(s)", "bulkArchiveTitle": "Archiver les tâches sélectionnées", "bulkDeleteAll": "Tout supprimer", "bulkDeleteArchiveSummary": "Archivé {{archived}}, supprimé {{deleted}}, échoué {{failed}}", - "bulkDeleteMessage": "Supprimer {{count}} tâche(s) sélectionnée(s) ?", + "bulkDeleteMessage_one": "", + "bulkDeleteMessage_other": "", "bulkDeleteNoTasks": "Aucune tâche sélectionnée ne peut être supprimée (les tâches archivées sont exclues)", - "bulkDeleteSummary": "{{deleted}} tâche(s) supprimée(s) · {{skipped}} archivée(s) ignorée(s) · {{failed}} échouée(s)", "bulkDeleteSummary_one": "{{count}} tâche supprimée · {{skipped}} archivées ignorées · {{failed}} échouées", "bulkDeleteSummary_other": "{{count}} tâches supprimées · {{skipped}} archivées ignorées · {{failed}} échouées", "bulkDeleteTitle": "Supprimer les tâches sélectionnées", @@ -2616,7 +2672,8 @@ "bulkUnpauseSummary": "{{unpaused}} reprise(s) · {{skipped}} ignoré(s) · {{failed}} échoué(s)", "bulkUpdateFailed": "Échec de la mise à jour des modèles", "bulkUpdateNoTasks": "Aucune tâche valide à mettre à jour (les tâches archivées ne peuvent pas être modifiées)", - "bulkUpdateSuccess": "{{count}} tâche(s) mise(s) à jour", + "bulkUpdateSuccess_one": "", + "bulkUpdateSuccess_other": "", "cancelMove": "Annuler le déplacement", "clear": "Effacer", "clearColumnFilter": "Effacer le filtre de colonne", @@ -2636,7 +2693,8 @@ "filterChip": "Filtre : {{column}}", "forceDelete": "Forcer la suppression", "forceDeleteTitle": "Forcer la suppression de la tâche", - "hidden": "{{count}} masquée(s)", + "hidden_one": "", + "hidden_other": "", "hideDone": "Masquer terminées", "hideDoneTitle": "Masquer les tâches terminées", "keepProgress": "Conserver", @@ -2646,18 +2704,18 @@ "listControlsLabel": "Contrôles de liste", "newTask": "+ Nouvelle tâche", "noChange": "Aucun changement", - "noTasks": "Aucune tâche", - "noTasksMatch": "Aucune tâche ne correspond à votre filtre", - "noTasksYet": "Aucune tâche pour l'instant", "nodeOverrideLabel": "Nœud de substitution", "nodeStatusConnecting": "Connexion en cours", "nodeStatusError": "Erreur", "nodeStatusOffline": "Hors ligne", "nodeStatusOnline": "En ligne", + "noTasks": "Aucune tâche", + "noTasksMatch": "Aucune tâche ne correspond à votre filtre", + "noTasksYet": "Aucune tâche pour l'instant", + "pausedByAgent": "mis en pause par l'agent", "pauseSelected": "Mettre en pause", "pauseSelectedTitle": "Mettre en pause toutes les tâches sélectionnées qui ne sont pas encore en pause", "pauseUnavailable": "L'action Pause est indisponible", - "pausedByAgent": "mis en pause par l'agent", "preserveProgressMessage": "Cette tâche a des étapes complétées. Conserver la progression avant de déplacer ?", "preserveProgressTitle": "Conserver la progression ?", "resetProgress": "Réinitialiser", @@ -2666,9 +2724,10 @@ "resizeSidebar": "Redimensionner la barre latérale de la liste de tâches", "reviewerModel": "Modèle de révision", "selectAll": "Sélectionner toutes les tâches visibles", + "selectedCount_one": "", + "selectedCount_other": "", "selectTask": "Sélectionner {{taskId}}", "selectTaskPrompt": "Sélectionnez une tâche pour voir les détails", - "selectedCount": "{{count}} sélectionnée(s)", "showAll": "Tout afficher", "showAllTitle": "Afficher toutes les tâches", "showDone": "Afficher terminées", @@ -2677,8 +2736,10 @@ "staleOnlyTitle": "Afficher uniquement les tâches obsolètes", "stalePausedReview": "Révision en pause obsolète", "stalePausedReviewTitle": "Afficher uniquement les tâches de révision en pause obsolètes", - "stats": "{{count}} sur {{total}} tâches", - "statsInColumn": "{{count}} sur {{total}} tâches dans {{column}}", + "stats_one": "", + "stats_other": "", + "statsInColumn_one": "", + "statsInColumn_other": "", "statusMergingFix": "Fusion des correctifs…", "stuck": "Bloquée", "taskCreationUnavailable": "Création de tâche indisponible", @@ -2690,8 +2751,6 @@ }, "mailbox": { "agent": "Agent", - "agentById": "Agent : {{id}}", - "agentByName": "Agent : {{name}}", "agents": "Agents", "agentsTab": "Agents", "ago": "il y a", @@ -2702,8 +2761,8 @@ "approvalDeny": "Refuser", "approvalRequested": "Demandé", "approvalRequester": "Demandeur", - "approvalTask": "Tâche", "approvals": "Approbations", + "approvalTask": "Tâche", "back": "Retour", "backButton": "← Retour", "closeAriaLabel": "Fermer", @@ -2732,8 +2791,9 @@ "markAllRead": "Tout marquer comme lu", "markAllReadButton": "Marquer tout comme lu", "markAllReadTitle": "Marquer tout comme lu", + "markedAsRead_one": "", + "markedAsRead_other": "", "markReadFailed": "Impossible de marquer les messages comme lus", - "markedAsRead": "Marqué {{count}} messages comme lus", "messageDeleted": "Message supprimé", "messageSent": "Message envoyé", "noAgentMessages": "Aucun message agent à agent", @@ -2753,15 +2813,18 @@ "refreshTitle": "Actualiser", "reply": "Répondre", "replyButton": "Répondre", - "replyLoadFailed": "Échec du chargement du message de réponse. Cliquez pour réessayer.", "replyingTo": "Répondre à {{preview}}", "replyingToMessage": "Répondre au message", + "replyLoadFailed": "Échec du chargement du message de réponse. Cliquez pour réessayer.", "selectMessageToRead": "Sélectionnez un message à lire", "system": "Système", - "timeDaysAgo": "Il y a {{count}}j", - "timeHoursAgo": "Il y a {{count}}h", + "timeDaysAgo_one": "", + "timeDaysAgo_other": "", + "timeHoursAgo_one": "", + "timeHoursAgo_other": "", "timeJustNow": "À l'instant", - "timeMinsAgo": "Il y a {{count}}m", + "timeMinsAgo_one": "", + "timeMinsAgo_other": "", "title": "Boîte aux lettres", "to": "À", "toLabel": "À :", @@ -2771,7 +2834,6 @@ "typeSystem": "Système", "typeUserToAgent": "Vous → Agent", "user": "Utilisateur", - "userLabel": "Utilisateur : {{id}}", "you": "Vous" }, "memory": { @@ -2789,32 +2851,33 @@ "capReadable": "Lisible", "capWritable": "Modifiable", "categories": "Catégories", - "charCount": "{{count}} caractères", + "charCount_one": "", + "charCount_other": "", "compactFailed": "Échec de la compaction de la mémoire", - "compactSelectedFile": "Compacter le fichier sélectionné", "compacting": "Compaction en cours…", "compactionThresholdHint": "La mémoire sera compactée lorsqu'elle dépassera ce nombre de caractères", "compactionThresholdLabel": "Seuil de compaction (caractères)", + "compactSelectedFile": "Compacter le fichier sélectionné", "currentBackendTitle": "Backend actuel", "description": "Mémoire de travail, insights à long terme et état des moteurs", "disabledMessage": "La mémoire est actuellement désactivée. Activez les outils de mémoire dans les paramètres pour modifier ces automatisations.", + "dreaming": "Traitement des rêves…", "dreamNow": "Traiter maintenant", "dreamNowHint": "Déclencher manuellement le traitement des rêves maintenant.", "dreamProcessingComplete": "Traitement des rêves terminé", "dreamProcessingFailed": "Échec du traitement des rêves", - "dreaming": "Traitement des rêves…", "dreamsEnabledHint": "Transforme les notes quotidiennes en DREAMS.md et promeut les leçons réutilisables dans MEMORY.md.", "dreamsEnabledLabel": "Traiter les rêves depuis la mémoire quotidienne", "dreamsScheduleHint": "Expression cron pour le traitement des rêves.", "dreamsScheduleLabel": "Planification des rêves", - "editRaw": "Modifier le brut", "editorDefaultDescription": "Modifie le fichier mémoire sélectionné.", "editorLabel": "Éditeur de mémoire", - "extractInsightsFailed": "Échec de l'extraction des insights", - "extractNow": "Extraire maintenant", + "editRaw": "Modifier le brut", "extracting": "Extraction en cours…", + "extractInsightsFailed": "Échec de l'extraction des insights", "extractionFailed": "Échec", "extractionSuccess": "Succès", + "extractNow": "Extraire maintenant", "fileCompacted": "Fichier mémoire compacté", "fileLabel": "Fichier mémoire", "fileSummary": "{{size}} octets · mis à jour le {{updatedAt}}", @@ -2824,13 +2887,15 @@ "healthIssues": "Problèmes détectés", "healthStatusTitle": "État de santé", "healthWarning": "Avertissement", - "insightCount": "{{count}} insights", - "insightsExtracted": "{{count}} insights extraits", + "insightCount_one": "", + "insightCount_other": "", + "insightsExtracted_one": "", + "insightsExtracted_other": "", "insightsMemoryLabel": "Mémoire des insights", "insightsSaved": "Insights enregistrés", + "installing": "Installation…", "installQmd": "Installer qmd", "installQmdFailed": "Échec de l'installation de qmd", - "installing": "Installation…", "lastExtractionLabel": "Dernière extraction", "lastUpdated": "Dernière mise à jour", "layerDaily": "Quotidien", @@ -2854,9 +2919,9 @@ "qmdAvailableOnPath": "qmd est disponible dans le PATH.", "qmdChecking": "Vérification", "qmdCheckingAvailability": "Vérification de la disponibilité de qmd…", + "qmdInstalled": "Installé", "qmdInstallSuccess": "qmd installé avec succès", "qmdInstallUnavailable": "L'installation de qmd est terminée, mais qmd n'est toujours pas disponible", - "qmdInstalled": "Installé", "qmdIntegrationTitle": "Intégration QMD", "qmdNotInstalled": "qmd n'est pas installé. La recherche utilisera les fichiers locaux. Installez la récupération indexée :", "qmdPathUsed": "chemin qmd utilisé", @@ -2875,7 +2940,8 @@ "saveSettingsFailed": "Échec de l'enregistrement des paramètres de mémoire", "saving": "Enregistrement…", "searchPlaceholder": "Rechercher dans la mémoire avec qmd", - "sectionCount": "{{count}} sections", + "sectionCount_one": "", + "sectionCount_other": "", "settingsNote": "Remarque : modifier le type de backend dans", "settingsNoteLink": "Paramètres → Mémoire", "settingsNoteToast": "Ouvrez Paramètres → Mémoire pour changer le type de backend", @@ -2884,12 +2950,13 @@ "tabEngines": "Moteurs", "tabInsights": "Insights", "tabWorking": "Mémoire de travail", + "testing": "Test en cours…", "testMemorySearchTitle": "Tester la recherche en mémoire", - "testResultCount": "{{count}} résultat pour « {{query}} »", + "testResultCount_one": "", + "testResultCount_other": "", "testResultStatus": "qmd {{qmdStatus}} · {{fallbackStatus}}", "testRetrieval": "Tester la récupération", "testSearchHint": "Exécute le même chemin memory_search basé sur qmd qu'utilisent les agents.", - "testing": "Test en cours…", "title": "Mémoire", "totalInsights": "Total des insights", "workingMemoryLabel": "Mémoire de travail" @@ -2913,16 +2980,16 @@ "pr": "PR", "pulling": "Récupération en cours…", "pushForceWithLease": "Envoyer (force-with-lease)", - "pushHeading": "Envoyer {{branch}} à origin — en avance de {{count}} commit{{plural}}.", + "pushHeading_one": "", + "pushHeading_other": "", + "pushing": "Envoi en cours…", "pushSuccess": "Envoyé à origin/{{branch}} @ {{sha}}.", "pushToOrigin": "Envoyer à origin", - "pushing": "Envoi en cours…", "recordedNoConfirm": "Enregistré sans confirmation de fusion locale", "shortstatTitle": "Statistiques courtes finales du commit ; pour voir le diff complet de tous les commits de tâches, consultez l'onglet Modifications.", "smartPull": "Pull intelligent", "status": "État", - "title": "Détails de la fusion", - "unknown": "Inconnu" + "title": "Détails de la fusion" }, "mesh": { "ariaLabel": "Visualisation de la topologie du maillage de nœuds", @@ -2947,23 +3014,24 @@ "addAssertion": "Ajouter une assertion", "addContext": "Ajoutez un contexte ou une direction supplémentaires...", "addFeature": "Ajouter une fonctionnalité", + "additionalComments": "Commentaires supplémentaires (optionnel)", "addMilestone": "Ajouter un jalon", "addSlice": "Ajouter un découpage", - "additionalComments": "Commentaires supplémentaires (optionnel)", "aiThinking": "L'IA réfléchit...", "aiValidatedAtRuntime": "Validé par IA à l'exécution", "aiValidatedMissionGate": "Porte de mission validée par IA", "allFeaturesLinked": "Toutes les fonctionnalités sont déjà liées", "approvePlan": "Approuver le plan", - "assertionCreateFailed": "Échec de la création de l'assertion", "assertionCreated": "Assertion créée", + "assertionCreateFailed": "Échec de la création de l'assertion", "assertionFieldsRequired": "Le titre et le texte de l'assertion sont requis", "assertionTextEditPlaceholder": "Texte de l'assertion", "assertionTextPlaceholder": "Texte de l'assertion (ce qui doit être vrai à la fin)", "assertionTitlePlaceholder": "Titre de l'assertion", - "assertionUpdateFailed": "Échec de la mise à jour de l'assertion", "assertionUpdated": "Assertion mise à jour", - "attemptRetries": "Tentative {{attempt}} · {{count}} {{label}} restant(s)", + "assertionUpdateFailed": "Échec de la mise à jour de l'assertion", + "attemptRetries_one": "", + "attemptRetries_other": "", "autopilotActivatingSlice": "Activation du découpage", "autopilotCompleting": "En finalisation", "autopilotDescription": "Lorsqu'il est activé, Fusion active automatiquement le découpage suivant et planifie ses fonctionnalités à mesure que le travail avance.", @@ -2973,11 +3041,6 @@ "autopilotLabel": "Pilote automatique", "autopilotLastActivation": "Dernière activation {{time}}", "autopilotOff": "Désactivé", - "autopilotStateActivating": "Activation de la tranche", - "autopilotStateCompleting": "Finalisation", - "autopilotStateInactive": "Désactivé", - "autopilotStateUnknown": "Inconnu", - "autopilotStateWatching": "En surveillance", "autopilotUpdateFailed": "Échec de la mise à jour du pilote automatique", "autopilotWatching": "Pilote automatique en surveillance", "autopilotWatchingSince": "En surveillance depuis {{time}}", @@ -3009,20 +3072,20 @@ "confirmSlicePlaceholder": "Comment confirmer que cette tranche est terminée...", "contractAssertions": "Assertions de contrat (validées par IA)", "createButton": "Créer", - "createTask": "Créer une tâche", "created": "Mission créée", "createdFromInterview": "Mission créée depuis l'entretien IA", + "createTask": "Créer une tâche", "creatingMission": "Création de la mission...", "defaultInterviewTitle": "Entretien de mission", "deleteAssertion": "Supprimer l'assertion", "deleteButton": "Supprimer", "deleteConfirm": "Supprimer ce/cette {{type}} ? Cette action est irréversible.", + "deleted": "Mission supprimée", "deleteFailed": "Échec de la suppression de la mission", "deleteFeature": "Supprimer la fonctionnalité", "deleteMilestone": "Supprimer le jalon", "deleteMission": "Supprimer la mission", "deleteSlice": "Supprimer le découpage", - "deleted": "Mission supprimée", "describeGoal": "Décrivez ce que vous voulez construire. L'IA vous posera des questions pour comprendre la portée, les contraintes et les exigences, puis produira un plan structuré avec des jalons, des tranches et des fonctionnalités.", "descriptionLabel": "Description de la mission", "descriptionOptional": "Description (facultative)", @@ -3047,23 +3110,24 @@ "failedLoadModels": "Échec du chargement des modèles", "featureCreated": "Fonctionnalité créée", "featureCriteriaAwaitingSync": "Critères de fonctionnalités en attente de synchronisation d'assertion", - "featureDeleteFailed": "Échec de la suppression de la fonctionnalité", "featureDeleted": "Fonctionnalité supprimée", - "featureLinkFailed": "Échec de la liaison de la fonctionnalité", - "featureLinkTaskFailed": "Échec de la liaison de la fonctionnalité à la tâche", + "featureDeleteFailed": "Échec de la suppression de la fonctionnalité", "featureLinkedToAssertion": "Fonctionnalité liée à l'assertion", "featureLinkedToTask": "Fonctionnalité liée à la tâche", + "featureLinkFailed": "Échec de la liaison de la fonctionnalité", + "featureLinkTaskFailed": "Échec de la liaison de la fonctionnalité à la tâche", "featureSaveFailed": "Échec de l'enregistrement de la fonctionnalité", + "featuresCount_one": "", + "featuresCount_other": "", "featureTitlePlaceholder": "Titre de la fonctionnalité", "featureTitleRequired": "Le titre de la fonctionnalité est requis", - "featureTriageFailed": "Échec du triage de la fonctionnalité", "featureTriaged": "Fonctionnalité triée — tâche créée", - "featureUnlinkFailed": "Échec de la dissociation de la fonctionnalité", - "featureUnlinkFromAssertionFailed": "Échec de la dissociation de la fonctionnalité", + "featureTriageFailed": "Échec du triage de la fonctionnalité", "featureUnlinkedFromAssertion": "Fonctionnalité dissociée de l'assertion", "featureUnlinkedFromTask": "Fonctionnalité dissociée de la tâche", + "featureUnlinkFailed": "Échec de la dissociation de la fonctionnalité", + "featureUnlinkFromAssertionFailed": "Échec de la dissociation de la fonctionnalité", "featureUpdated": "Fonctionnalité mise à jour", - "featuresCount": "{{count}} fonctionnalités", "filterAll": "Tous les événements", "filterAutopilot": "Événements du pilote automatique", "filterErrors": "Erreurs et avertissements", @@ -3074,9 +3138,6 @@ "generatedFixFeatures": "Fonctionnalités de correction générées :", "generatedFixFeaturesTitle": "Fonctionnalités de correction générées", "generatedFromFeature": "Généré depuis la fonctionnalité : {{id}}", - "helperTextActive": "L'arrêt met en pause les tâches liées et marque la mission comme bloquée.", - "helperTextBlocked": "La reprise réactive la mission et continue l'exécution.", - "helperTextPlanning": "Le démarrage active le premier découpage pour que le travail puisse commencer.", "hideDetails": "Masquer les détails", "hideMetadata": "Masquer les métadonnées", "hideThinking": "Masquer la réflexion", @@ -3090,42 +3151,39 @@ "interviewErrored": "L'entretien a rencontré une erreur. Réessayez depuis cet élément.", "interviewGenerating": "Génération de la hiérarchie de mission à partir du contexte de l'entretien.", "interviewInProgress": "Entretien en cours", - "interviewStatusAwaitingInput": "En attente d'entrée", - "interviewStatusComplete": "Plan prêt", - "interviewStatusError": "Nouvelle tentative requise", - "interviewStatusGenerating": "Génération du plan", - "interviewStatusNeedsRetry": "Nécessite un réessai", - "interviewStatusPlanReady": "Plan prêt", "interviewWaiting": "L'entretien attend votre prochaine réponse.", "lastValidatorStatus": "Dernier {{status}}", "linkAFeature": "Lier une fonctionnalité", "linkButton": "Lier", - "linkFeatureButton": "Lier une fonctionnalité", - "linkFeatureToTask": "Lier la fonctionnalité à la tâche :", - "linkToTask": "Lier à la tâche", - "linkedCount": "{{count}} lié(s)", - "linkedFeaturesCount": "{{count}} fonctionnalité(s) liée(s)", + "linkedCount_one": "", + "linkedCount_other": "", + "linkedFeaturesCount_one": "", + "linkedFeaturesCount_other": "", "linkedFeaturesLabel": "Fonctionnalités liées", "linkedGoals": "Objectifs liés", "linkedGoalsTitle": "Objectifs liés", + "linkFeatureButton": "Lier une fonctionnalité", + "linkFeatureToTask": "Lier la fonctionnalité à la tâche :", + "linkToTask": "Lier à la tâche", "loadActivityFailed": "Échec du chargement de l'activité de la mission", "loadDetailFailed": "Échec du chargement des détails de la mission", "loadFailed": "Échec du chargement des missions", - "loadMore": "Charger plus", "loadingActivity": "Chargement de l'activité de la mission…", "loadingMissionDetails": "Chargement des détails de la mission…", "loadingMissions": "Chargement des missions…", "loadingModels": "Chargement des modèles…", + "loadMore": "Charger plus", "loopState": "État de boucle : {{state}}", "milestoneCreated": "Jalon créé", - "milestoneDeleteFailed": "Échec de la suppression du jalon", "milestoneDeleted": "Jalon supprimé", + "milestoneDeleteFailed": "Échec de la suppression du jalon", "milestoneDescriptionPlaceholder": "Description du jalon...", "milestoneSaveFailed": "Échec de l'enregistrement du jalon", + "milestonesCount_one": "", + "milestonesCount_other": "", "milestoneTitlePlaceholder": "Titre du jalon", "milestoneTitleRequired": "Le titre du jalon est requis", "milestoneUpdated": "Jalon mis à jour", - "milestonesCount": "{{count}} jalons", "missionHealthAriaLabel": "Santé de la mission : {{state}}", "missionInterviewInProgressDesc": "L'entretien de mission est toujours en cours. Ouvrez cette mission pour continuer la planification.", "missionList": "Liste des missions", @@ -3143,44 +3201,45 @@ "noMilestonesYet": "Aucun jalon pour l'instant. Ajoutez-en un pour commencer.", "noMissionsYetBody": "Les missions sont de grandes initiatives qui regroupent des jalons, des découpages et des fonctionnalités dans un seul plan. Planifiez une mission pour décomposer un objectif de bout en bout et laissez les agents l'exécuter en mode pilote automatique.", "noMissionsYetTitle": "Aucune mission pour l'instant", + "none": "Aucun", "noSlicesYet": "Aucun découpage pour l'instant", "noValidationRunsYet": "Aucune exécution de validation pour l'instant.", - "none": "Aucun", "openMissionAriaLabel": "Ouvrir la mission {{title}}", "orSelect": "Ou sélectionner :", "planMilestone": "Planifier le jalon", "planNewMission": "Planifier une nouvelle mission", + "planningModel": "Modèle de planification", "planReady": "Plan de mission prêt", "planSlice": "Planifier le découpage", "planStateNeedsUpdate": "Nécessite une mise à jour", "planStateNotPlanned": "Non planifié", "planStatePlanned": "Planifié", "planTitle": "Planifier une mission avec l'IA", - "planningModel": "Modèle de planification", "prepareQuestion": "Préparation de la prochaine question...", - "progressText": "Question {{count}} sur ~6", + "progressText_one": "", + "progressText_other": "", "reconnecting": "Reconnexion en cours…", - "relativeTimeDays": "il y a {{count}} j", - "relativeTimeHours": "il y a {{count}} h", + "relativeTimeDays_one": "", + "relativeTimeDays_other": "", + "relativeTimeHours_one": "", + "relativeTimeHours_other": "", "relativeTimeJustNow": "à l'instant", - "relativeTimeMinutes": "il y a {{count}} min", + "relativeTimeMinutes_one": "", + "relativeTimeMinutes_other": "", "removeFeature": "Supprimer la fonctionnalité", "removeMilestone": "Supprimer le jalon", "removeSlice": "Supprimer la tranche", "resizeSidebar": "Redimensionner la barre latérale des missions", + "resumed": "Mission reprise", "resumeFailed": "Échec de la reprise de la mission", "resumeInterviewAriaLabel": "Reprendre l'entretien {{title}}", "resumeMission": "Reprendre la mission", - "resumed": "Mission reprise", "retries": "réessais", "retry": "réessai", "retryBudgetTitle": "Tentatives d'implémentation et budget de réessai restant", "retrying": "Nouvelle tentative en cours...", "roadmapLabel": "Feuille de route", "run": "Exécution :", - "runHelperActive": "L'arrêt met en pause les tâches liées et marque la mission comme bloquée.", - "runHelperBlocked": "La reprise réactive la mission et continue l'exécution.", - "runHelperPlanning": "Le démarrage active la première tranche pour que le travail puisse commencer.", "runSettings": "Paramètres d'exécution de la mission", "runSettingsTitle": "Paramètres d'exécution de la mission", "saveButton": "Enregistrer", @@ -3194,25 +3253,27 @@ "showMetadata": "Afficher les métadonnées", "showThinking": "Afficher la réflexion", "showValidationRounds": "Afficher les tours de validation", - "sliceActivateFailed": "Échec de l'activation du découpage", "sliceActivated": "Découpage activé", + "sliceActivateFailed": "Échec de l'activation du découpage", "sliceCreated": "Découpage créé", - "sliceDeleteFailed": "Échec de la suppression du découpage", "sliceDeleted": "Découpage supprimé", + "sliceDeleteFailed": "Échec de la suppression du découpage", "sliceSaveFailed": "Échec de l'enregistrement du découpage", + "slicesCount_one": "", + "slicesCount_other": "", "sliceTitlePlaceholder": "Titre du découpage", "sliceTitleRequired": "Le titre du découpage est requis", + "sliceTriaged_one": "", + "sliceTriaged_other": "", "sliceTriageFailed": "Échec du triage des fonctionnalités du découpage", - "sliceTriaged": "{{count}} fonctionnalités triées", "sliceUpdated": "Découpage mis à jour", "sliceVerification": "Vérification de la tranche", - "slicesCount": "{{count}} découpages", "source": "Source :", + "started": "Mission démarrée — premier découpage activé", "startFailed": "Échec du démarrage de la mission", "startInterview": "Démarrer l'interview", "startMission": "Démarrer la mission", "startOver": "Recommencer", - "started": "Mission démarrée — premier découpage activé", "statusActive": "Actif", "statusArchived": "Archivé", "statusBlocked": "Bloqué", @@ -3227,9 +3288,11 @@ "statusTriaged": "Trié", "stopFailed": "Échec de l'arrêt de la mission", "stopMission": "Arrêter la mission", - "stopped": "Mission arrêtée ({{count}} tâches en pause)", + "stopped_one": "", + "stopped_other": "", "summaryStats": "{{milestones}} jalons, {{features}} fonctionnalités. Vérifiez et modifiez avant d'approuver.", - "tabActivity": "Activité ({{count}})", + "tabActivity_one": "", + "tabActivity_other": "", "tabStructure": "Structure", "takeControl": "Prendre le contrôle", "takingControl": "Prise de contrôle en cours...", @@ -3237,7 +3300,8 @@ "targetBranchPlaceholder": "p. ex. main", "taskIdPlaceholder": "ID de tâche (p. ex. FN-001)", "taskIdRequired": "L'ID de tâche est requis", - "tasksFailed": "{{count}} échoué(s)", + "tasksFailed_one": "", + "tasksFailed_other": "", "title": "Missions", "titleLabel": "Titre de la mission", "titleRequired": "Le titre de la mission est requis", @@ -3247,21 +3311,23 @@ "triageCreateTask": "Trier — créer une tâche", "tryExample": "Essayez un exemple:", "typeAnswer": "Tapez votre réponse ici...", + "unlinkedBadge": "Non lié", "unlinkFeature": "Dissocier la fonctionnalité", "unlinkTask": "Dissocier la tâche", - "unlinkedBadge": "Non lié", "untitled": "Sans titre", "updateButton": "Mettre à jour", "updated": "Mission mise à jour", "validateFeature": "Valider la fonctionnalité", - "validationRoundsCount": "{{count}} tours", - "validationRoundsLabel": "Tours de validation ({{count}})", + "validationRoundsCount_one": "", + "validationRoundsCount_other": "", + "validationRoundsLabel_one": "", + "validationRoundsLabel_other": "", "validationRuns": "Exécutions de validation", "validationState": "État de validation", "validationStateNotStarted": "Non commencé", "validationTelemetry": "Télémétrie de validation", - "validationTriggerFailed": "Échec du déclenchement de la validation", "validationTriggered": "Validation déclenchée", + "validationTriggerFailed": "Échec du déclenchement de la validation", "verification": "Vérification :", "verificationCriteria": "Critères de vérification", "viewMissionFailures": "Voir les échecs de la mission", @@ -3276,26 +3342,13 @@ "noChange": "Aucun changement", "selectPlaceholder": "Sélectionner un modèle…" }, - "modelSelection": { - "choose": "Choisissez les modèles pour cette tâche. Si non sélectionné, les modèles par défaut seront utilisés.", - "custom": "Personnalisé", - "executorModel": "Modèle exécuteur", - "executorPlaceholder": "Sélectionner le modèle exécuteur…", - "loading": "Chargement des modèles…", - "noModels": "Aucun modèle disponible. Configurez l'authentification dans Paramètres pour activer la sélection des modèles.", - "preset": "Préréglage", - "reviewerModel": "Modèle réviseur", - "reviewerPlaceholder": "Sélectionner le modèle réviseur…", - "title": "Sélectionner les modèles", - "useDefault": "Utiliser par défaut", - "usingDefault": "Par défaut" - }, "models": { "addProviderToFavoritesAriaLabel": "Ajouter {{provider}} aux favoris", "addToFavorites": "Ajouter aux favoris", "addToFavoritesAriaLabel": "Ajouter {{name}} aux favoris", "clearFilter": "Effacer le filtre", - "count": "{{count}} modèle", + "count_one": "", + "count_other": "", "descriptions": { "executor": "Le modèle d'IA utilisé pour mettre en œuvre cette tâche.", "override": "Remplacez les modèles d'IA utilisés pour cette tâche. Si non spécifié, les valeurs par défaut du projet ou globales sont utilisées.", @@ -3320,8 +3373,6 @@ "thinkingLevel": "Niveau de réflexion" }, "messages": { - "modelSetTo": "Modèle {{label}} défini sur {{provider}}/{{modelId}}", - "modelSetToDefault": "Modèle {{label}} défini par défaut", "thinkingLevelSet": "Niveau de réflexion défini sur {{level}}", "thinkingLevelSetDefault": "Niveau de réflexion défini sur la valeur par défaut ({{level}})", "upToDate": "Les paramètres du modèle sont à jour.", @@ -3348,16 +3399,25 @@ "loading": "Chargement des modèles disponibles…", "usingDefault": "Utilisation de la valeur par défaut" }, - "targetLabels": { - "executor": "Exécuteur", - "planning": "Planification", - "validator": "Réviseur" - }, "titles": { "configuration": "Configuration du modèle" }, "useDefault": "Utiliser la valeur par défaut" }, + "modelSelection": { + "choose": "Choisissez les modèles pour cette tâche. Si non sélectionné, les modèles par défaut seront utilisés.", + "custom": "Personnalisé", + "executorModel": "Modèle exécuteur", + "executorPlaceholder": "Sélectionner le modèle exécuteur…", + "loading": "Chargement des modèles…", + "noModels": "Aucun modèle disponible. Configurez l'authentification dans Paramètres pour activer la sélection des modèles.", + "preset": "Préréglage", + "reviewerModel": "Modèle réviseur", + "reviewerPlaceholder": "Sélectionner le modèle réviseur…", + "title": "Sélectionner les modèles", + "useDefault": "Utiliser par défaut", + "usingDefault": "Par défaut" + }, "nav": { "activityLog": "Journal d'activité", "agents": "Agents", @@ -3380,8 +3440,8 @@ "missions": "Missions", "more": "Plus", "moreSheetTitle": "Naviguer", - "noScriptsAddOne": "Aucun script — en ajouter un…", "nodes": "Nœuds", + "noScriptsAddOne": "Aucun script — en ajouter un…", "planning": "Planification", "primaryNavAriaLabel": "Navigation principale", "projects": "Projets", @@ -3417,28 +3477,12 @@ "noAvailableTasks": "Aucune tâche disponible", "searchTasks": "Rechercher des tâches…", "selectAgent": "Sélectionner un agent", - "selectedCount": "{{count}} sélectionné", + "selectedCount_one": "", + "selectedCount_other": "", "taskCreated": "{{taskId}} créé", "title": "Nouvelle tâche", "unsavedChanges": "Vous avez des modifications non enregistrées. Les abandonner ?" }, - "nodeStatus": { - "connecting": "Connexion en cours", - "error": "Erreur", - "local": "Local", - "offline": "Hors ligne", - "online": "En ligne", - "unknown": "Inconnu" - }, - "nodeSync": { - "error": { - "authSyncFailed": "La synchronisation d'authentification a échoué", - "failedToFetchStatus": "Impossible de récupérer l'état de synchronisation", - "pullFailed": "Échec de la récupération des paramètres", - "pushFailed": "Échec de l'envoi des paramètres", - "someRequestsFailed": "Certaines demandes de statut de synchronisation ont échoué" - } - }, "nodes": { "actions": { "connect": "Connecter", @@ -3481,22 +3525,16 @@ "addDockerNode": "Ajouter un nœud Docker", "addDockerNodeTitle": "Ajouter un nœud Docker géré", "addFirstNode": "Ajouter le premier nœud", + "adding": "Ajout...", "addMountButton": "Ajouter un montage", "addNode": "Ajouter un nœud", "addVariableButton": "Ajouter une variable", - "adding": "Ajout...", "apiKey": "Clé API", "apiKeyMode": "Mode de clé API", "apiKeyNotConfigured": "Non configuré", "apiKeyPlaceholder": "Laisser vide pour ne pas modifier", "attachProjects": "Joindre les projets existants", "attachProjectsHint": "Sélectionnez les projets existants à exécuter sur ce nœud et fournissez le chemin absolu spécifique au nœud pour chacun.", - "auth": { - "differ": "Les identifiants diffèrent", - "differProviders": "Les identifiants diffèrent : {{providers}}", - "match": "Les identifiants correspondent", - "notSynced": "Authentification non synchronisée" - }, "authSync": { "differ": "identifiants diffèrent", "label": "Sync auth : {{status}}", @@ -3517,9 +3555,10 @@ "containerLogs": "Journaux du conteneur", "description": "Enregistrez un nœud Fusion existant en fournissant ses détails de connexion et ses paramètres de concurrence.", "discoverBeforeAdding": "Découvrez les projets distants avant d'ajouter ce nœud.", - "discoverRemoteProjects": "Découvrir les projets distants", - "discoveredCount": "{{count}} projet distant découvert{{plural}}.", + "discoveredCount_one": "", + "discoveredCount_other": "", "discovering": "Découverte...", + "discoverRemoteProjects": "Découvrir les projets distants", "discoveryFailed": "Échec de la découverte des projets distants", "dismissError": "Ignorer l'erreur", "docker": "Docker", @@ -3553,8 +3592,8 @@ "dockerPidsLimit": "Limite de PIDs", "dockerPort": "Port", "dockerResourceDefault": "Par défaut", - "dockerResourceSizing": "Dimensionnement des ressources", "dockerResources": "Ressources", + "dockerResourceSizing": "Dimensionnement des ressources", "dockerRetainOnDelete": "Conserver à la suppression", "dockerStatusUnknown": "Inconnu", "dockerTlsCaCert": "Chemin du certificat CA TLS", @@ -3567,11 +3606,11 @@ "editButton": "Modifier", "errorFetching": "Impossible de récupérer les nœuds", "errorPersistMappings": "Échec de la persistance des mappages de projet", - "errorUnregisterAfterMappingFailure": "Échec de la désinscription du nœud après l'échec du mappage", "errors": { "connectFailed": "Impossible de se connecter", "connectToNode": "Impossible de se connecter au nœud" }, + "errorUnregisterAfterMappingFailure": "Échec de la désinscription du nœud après l'échec du mappage", "failedCreateDocker": "Impossible de créer le nœud Docker", "failedRefresh": "Impossible d'actualiser les nœuds", "failedRemove": "Impossible de supprimer le nœud", @@ -3582,10 +3621,6 @@ "fieldCreated": "Créé", "fieldMaxConcurrent": "Concurrence max", "fieldName": "Nom", - "fieldStatus": "Statut", - "fieldType": "Type", - "fieldUpdated": "Mis à jour", - "fieldUrl": "URL", "fields": { "authKey": "Clé d'authentification", "host": "Hôte / Adresse IP", @@ -3594,6 +3629,10 @@ "port": "Port", "url": "URL" }, + "fieldStatus": "Statut", + "fieldType": "Type", + "fieldUpdated": "Mis à jour", + "fieldUrl": "URL", "heading": "Nœuds", "healthCheckButton": "Vérification de santé", "healthCheckComplete": "Vérification de santé du nœud terminée", @@ -3623,6 +3662,7 @@ "namePlaceholder": "Machine de construction", "nameRequired": "Le nom est obligatoire", "no": "Non", + "nodeLabel": "{{name}} ({{type}}) — {{status}}", "noLogsAvailable": "Aucun journal disponible", "noMatch": "Aucune correspondance exacte du nom distant. Entrez ce chemin manuellement.", "noProjects": "Aucun projet n'est actuellement enregistré.", @@ -3630,7 +3670,6 @@ "noProjectsDiscovered": "Aucun projet découvert sur le nœud distant.", "noProjectsRunning": "Aucun projet n'est en cours d'exécution sur ce nœud.", "noRegistered": "Aucun nœud n'est enregistré pour le moment.", - "nodeLabel": "{{name}} ({{type}}) — {{status}}", "offline": "Hors ligne", "online": "En ligne", "pathDiscovered": "Chemin faisant autorité distant découvert : {{path}}", @@ -3642,22 +3681,23 @@ "optional": "Optionnel" }, "provideManually": "Fournir manuellement la clé", + "pulling": "Récupération…", "pullSettings": "Récupérer les paramètres", "pullSettingsFailed": "Échec de la récupération des paramètres", "pullSettingsSuccess": "Paramètres récupérés avec succès", - "pulling": "Récupération…", + "pushing": "Envoi…", "pushSettings": "Envoyer les paramètres", "pushSettingsFailed": "Échec de l'envoi des paramètres", "pushSettingsSuccess": "Paramètres envoyés avec succès", - "pushing": "Envoi…", "reachableUrl": "URL accessible / Nom d'hôte", "readOnly": "Lecture seule", "refresh": "Actualiser", - "refreshStatus": "Actualiser le statut", "refreshing": "Actualisation…", - "registerFailed": "Échec de l'enregistrement du nœud", + "refreshStatus": "Actualiser le statut", "registered": "Nœud « {{name}} » enregistré", - "registeredCount": "{{count}} enregistré(s)", + "registeredCount_one": "", + "registeredCount_other": "", + "registerFailed": "Échec de l'enregistrement du nœud", "remote": "À distance", "removeButton": "Supprimer", "removed": "Nœud supprimé", @@ -3674,18 +3714,6 @@ "sectionSettingsSync": "Synchronisation des paramètres", "sectionSyncHistory": "Historique de synchronisation", "startButton": "Démarrer", - "status": { - "connecting": "Connexion en cours", - "creating": "Création en cours", - "deleting": "Suppression en cours", - "error": "Erreur", - "exited": "Fermé", - "offline": "Hors ligne", - "online": "En ligne", - "recreating": "Recréation en cours", - "running": "En cours d'exécution", - "stopped": "Arrêté" - }, "statusConnecting": "Connexion en cours", "statusError": "Erreur", "statusOffline": "Hors ligne", @@ -3699,10 +3727,10 @@ "syncAuthFailed": "Échec de la synchronisation de l'auth", "syncAuthSuccess": "Identifiants d'authentification synchronisés avec succès", "syncDifferences": "Différences :", - "syncLastSync": "Dernière sync :", - "syncNeverSynced": "Jamais synchronisé", "synced": "Synchronisé", "syncing": "Synchronisation…", + "syncLastSync": "Dernière sync :", + "syncNeverSynced": "Jamais synchronisé", "total": "Total", "type": { "local": "Local", @@ -3722,6 +3750,18 @@ "viewLogsButton": "Voir les journaux", "yes": "Oui" }, + "nodeStatus": { + "local": "Local" + }, + "nodeSync": { + "error": { + "authSyncFailed": "La synchronisation d'authentification a échoué", + "failedToFetchStatus": "Impossible de récupérer l'état de synchronisation", + "pullFailed": "Échec de la récupération des paramètres", + "pushFailed": "Échec de l'envoi des paramètres", + "someRequestsFailed": "Certaines demandes de statut de synchronisation ont échoué" + } + }, "onboarding": { "authToken": "Jeton d'authentification (optionnel)", "continue": "Continuer", @@ -3736,8 +3776,8 @@ "remoteServer": "Serveur distant", "resumeOnboarding": "Reprendre l'intégration", "saving": "Enregistrement…", - "scanQr": "Scanner QR", "scanning": "Numérisation…", + "scanQr": "Scanner QR", "serverUrl": "URL du serveur", "serverUrlPlaceholder": "https://votre-hote-fusion", "stepContinue": "étape. Continuez où vous vous êtes arrêté pour terminer la configuration de votre tableau de bord.", @@ -3797,10 +3837,10 @@ "companyHelp": "Sélectionnez une entreprise Paperclip.", "companyIdRequired": "L'ID de l'entreprise est requis pour émettre une clé API Paperclip.", "companyLabel": "Entreprise", - "connectToPopulate": "Connectez-vous pour remplir", "connected": "Connecté.", "connectedAsAgent": "Connecté en tant que {{agentName}}{{companyInfo}}.", "connectionModeAriaLabel": "Mode de connexion Paperclip", + "connectToPopulate": "Connectez-vous pour remplir", "description": "Pilotez un agent Paperclip (employé) dans une entreprise Paperclip. Chaque invite envoie une demande en forme de tâche ; la gouvernance, les budgets et les approbations sont appliqués par Paperclip. Attendez-vous à une latence de quelques secondes à quelques minutes par tour.", "docsLink": "Documentation Paperclip", "githubLink": "GitHub", @@ -3808,16 +3848,6 @@ "goalIdLabel": "ID de l'objectif (optionnel)", "mintButton": "Créer une clé API via paperclipai", "mintFailed": "Émission échouée : {{reason}}. Exécutez d'abord `paperclipai onboard` si votre CLI n'est pas authentifiée.", - "mode": { - "issue-per-prompt": "Problème par invite", - "rolling-issue": "Problème continu (par défaut)", - "wakeup-only": "Réveil uniquement (avancé)" - }, - "modeHelp": { - "issue-per-prompt": "Chaque invite crée un nouveau ticket Paperclip de premier niveau. Maximalement explicite ; tend à encombrer le tableau.", - "rolling-issue": "Un ticket Paperclip par session Fusion ; les invites suivantes sont ajoutées en commentaires. L'expérience la plus proche d'un chat.", - "wakeup-only": "Pas d'effets secondaires sur les tickets ; l'invite est transmise uniquement via le payload de réveil. Nécessite que le modèle d'invite de l'agent sache gérer un réveil piloté par payload." - }, "modeLabel": "Mode de conversation", "name": "Paperclip", "noAgentsDiscovered": "Aucun agent découvert", @@ -3860,13 +3890,13 @@ "filterSkills": "Compétences :", "filterThemes": "Thèmes :", "installFailed": "Impossible d'installer le paquet : {{error}}", - "installSuccess": "Paquet installé avec succès", "installing": "Installation…", + "installSuccess": "Paquet installé avec succès", "loadExtensionsFailed": "Impossible de charger les extensions : {{error}}", - "loadSettingsFailed": "Impossible de charger les paramètres Pi : {{error}}", "loading": "Chargement des paramètres Pi…", "loadingExtensions": "Chargement des extensions…", "loadingFailed": "Impossible de charger les paramètres Pi.", + "loadSettingsFailed": "Impossible de charger les paramètres Pi : {{error}}", "noExtensions": "Aucune extension découverte.", "noPackages": "Aucun paquet configuré.", "noPackagesHelp": "Ajoutez une source de paquet ci-dessus pour commencer.", @@ -3876,8 +3906,8 @@ "refreshExtensions": "Actualiser les extensions", "reinstallButton": "Réinstaller la compétence Fusion", "reinstallFailed": "Impossible de réinstaller la compétence Fusion : {{error}}", - "reinstallSuccess": "Compétence Fusion réinstallée avec succès", "reinstalling": "Réinstallation de Fusion…", + "reinstallSuccess": "Compétence Fusion réinstallée avec succès", "removeFailed": "Impossible de supprimer le paquet : {{error}}", "removePackage": "Supprimer le package", "removePackageLabel": "Supprimer le package {{label}}", @@ -3893,9 +3923,9 @@ "updateSettingsFailed": "Impossible de mettre à jour les paramètres : {{error}}" }, "planning": { - "addSubtask": "Ajouter une sous-tâche", "additionalComments": "Commentaires supplémentaires (facultatif)", "additionalCommentsPlaceholder": "Ajoutez du contexte ou des précisions supplémentaires…", + "addSubtask": "Ajouter une sous-tâche", "advancedSettings": "Paramètres de planification avancés", "aiThinking": "L'IA réfléchit…", "archiveSession": "Archiver la session", @@ -3910,10 +3940,10 @@ "branchNameRequired": "Le nom de la branche est requis pour cette stratégie.", "branchProjectDefault": "Utiliser la branche par défaut du projet", "branchStrategy": "Stratégie de branche", - "breakIntoTasks": "Décomposer en tâches", - "breakIntoTasksTitle": "Décomposer le plan en plusieurs tâches avec dépendances", "breakdownSubheading": "Vérifiez et modifiez les sous-tâches générées à partir de votre plan. Ajustez les titres, descriptions, tailles, priorités et dépendances avant de créer.", "breakingDown": "Décomposition…", + "breakIntoTasks": "Décomposer en tâches", + "breakIntoTasksTitle": "Décomposer le plan en plusieurs tâches avec dépendances", "collapse": "Réduire", "continue": "Continuer", "createSingleTask": "Créer une seule tâche", @@ -3977,11 +4007,15 @@ "questionsLabel": "Questions", "reconnecting": "Reconnexion…", "refineFurther": "Affiner davantage", - "relativeTimeDays": "il y a {{count}}j", - "relativeTimeHours": "il y a {{count}}h", + "relativeTimeDays_one": "", + "relativeTimeDays_other": "", + "relativeTimeHours_one": "", + "relativeTimeHours_other": "", "relativeTimeJustNow": "à l'instant", - "relativeTimeMinutes": "il y a {{count}}min", - "relativeTimeWeeks": "il y a {{count}}sem", + "relativeTimeMinutes_one": "", + "relativeTimeMinutes_other": "", + "relativeTimeWeeks_one": "", + "relativeTimeWeeks_other": "", "remove": "Supprimer", "retryFailed": "Nouvelle tentative échouée. Veuillez réessayer.", "retrying": "Nouvelle tentative…", @@ -4024,30 +4058,13 @@ }, "plugins": { "addItem": "Ajouter un élément", - "agentBrowser": { - "groupBrowser": "Navigateur", - "groupGeneral": "Général", - "groupPromptContributions": "Contributions de prompt", - "groupSkills": "Compétences", - "labelAllowedDomains": "Domaines autorisés", - "labelCommandTimeoutMs": "Délai d'expiration de commande (ms)", - "labelEnabled": "Activer le navigateur agent", - "labelHeadlessMode": "Mode sans interface", - "labelInstallChannel": "Canal d'installation", - "labelPromptExecutorSystem": "Prompt système de l'exécuteur", - "labelPromptExecutorTask": "Prompt de tâche de l'exécuteur", - "labelPromptHeartbeat": "Prompt de pulsation", - "labelPromptReviewer": "Prompt du réviseur", - "labelPromptTriage": "Prompt de triage", - "labelSkillExposure": "Exposition des compétences" - }, "aiScanDisabled": "Analyse IA au chargement désactivée", "aiScanEnabled": "Analyse IA au chargement activée", "aiScanHint": "Activer cette option met uniquement à jour la configuration. Utilisez Réanalyser et recharger pour l'exécuter maintenant.", "author": "Auteur :", "backToList": "Retour à la liste des plugins", - "builtinInstallFailed": "Échec de l'installation de {{name}} : {{error}}", "builtinInstalledGlobally": "{{name}} installé globalement", + "builtinInstallFailed": "Échec de l'installation de {{name}} : {{error}}", "builtinMetadataOnly": "Métadonnées intégrées uniquement", "builtinNoPackage": "{{name}} est intégré et ne possède pas encore de package installable", "builtinPluginRecommendations": "Recommandations de plugins intégrés", @@ -4057,34 +4074,35 @@ "checkingSetup": "Vérification de la configuration…", "componentUnavailable": "Composant du plugin indisponible", "couldNotResolve": "Le tableau de bord n'a pas pu résoudre cette surface de plugin du registre d'hôte statique.", + "disabledForProject": "{{name}} désactivé pour ce projet", "disableInProject": "Désactiver dans le projet", "disablePlugin": "Désactiver {{name}}", "disablePluginFailed": "Échec de la désactivation du plugin : {{error}}", - "disabledForProject": "{{name}} désactivé pour ce projet", "droidOnboardingTip": "Astuce : Activez Droid CLI pour réutiliser votre abonnement Factory AI sans ajouter de clé API.", "droidRecommendDesc": "Utilisez votre session Droid CLI locale comme fournisseur d'IA dans Fusion.", "droidRecommendTitle": "Activer Droid CLI", "enableAiScanBeforeLoad": "Activer l'analyse IA avant chargement/rechargement", "enableAiSecurityScan": "Activer l'analyse de sécurité IA au chargement", + "enabledForProject": "{{name}} activé pour ce projet", "enableFailed": "Échec de l'activation de {{name}} : {{error}}", "enableInProject": "Activer dans le projet", "enablePlugin": "Activer {{name}}", "enablePluginFailed": "Échec de l'activation du plugin : {{error}}", - "enabledForProject": "{{name}} activé pour ce projet", "experimental": "Expérimental", - "findings": "Résultats ({{count}})", + "findings_one": "", + "findings_other": "", "homepage": "Page d'accueil :", "install": "Installer", + "installedGlobally": "Plugin installé globalement", + "installedPlugins": "Plugins installés", "installFailed": "Échec de l'installation du plugin : {{error}}", "installHint": "Naviguez jusqu'à la racine du package du plugin (contenant manifest.json) ou un répertoire dist compilé.", + "installing": "Installation…", "installNamed": "Installer {{name}}", "installPathPlaceholder": "Chemin absolu vers le répertoire du plugin ou le dossier dist", "installPathRequired": "Veuillez saisir un chemin de plugin", "installPluginGlobally": "Installer le plugin globalement", "installSetup": "Installer la configuration", - "installedGlobally": "Plugin installé globalement", - "installedPlugins": "Plugins installés", - "installing": "Installation…", "loadFailed": "Échec du chargement des plugins : {{error}}", "loading": "Chargement…", "loadingPlugins": "Chargement des plugins…", @@ -4098,8 +4116,8 @@ "refresh": "Actualiser", "refreshPluginList": "Actualiser la liste des plugins", "reload": "Recharger", - "reloadFailed": "Échec du rechargement du plugin : {{error}}", "reloaded": "{{name}} rechargé", + "reloadFailed": "Échec du rechargement du plugin : {{error}}", "reloading": "Rechargement…", "removeItem": "Supprimer l'élément", "rescanAndReload": "Réanalyser et recharger", @@ -4109,11 +4127,11 @@ "saveSettingsFailed": "Échec de l'enregistrement des paramètres : {{error}}", "securityScan": "Analyse de sécurité", "selectOption": "Sélectionner…", - "settingUp": "Configuration…", "settings": "Paramètres", "settingsSaved": "Paramètres enregistrés", - "setupInstallFailed": "Échec de l'installation de la configuration de {{name}} : {{error}}", + "settingUp": "Configuration…", "setupInstalled": "Configuration de {{name}} installée", + "setupInstallFailed": "Échec de l'installation de la configuration de {{name}} : {{error}}", "setupReady": "Configuration prête", "setupRequired": "Configuration requise", "startPluginToCheckSetup": "Démarrez le plugin pour vérifier la configuration", @@ -4121,11 +4139,11 @@ "statusInstalled": "Installé", "statusNotInstalled": "Non installé", "uninstallConfirm": "Voulez-vous vraiment désinstaller « {{name}} » globalement (tous les projets) ?", + "uninstalledGlobally": "{{name}} désinstallé globalement", "uninstallFailed": "Échec de la désinstallation du plugin : {{error}}", "uninstallGlobally": "Désinstaller globalement", "uninstallGloballyTitle": "Désinstaller globalement", "uninstallTitle": "Désinstaller le plugin globalement", - "uninstalledGlobally": "{{name}} désinstallé globalement", "unknownError": "erreur inconnue", "updateFailed": "Échec de la mise à jour du plugin : {{error}}", "version": "Version :" @@ -4151,7 +4169,6 @@ "createPr": "Créer une RP", "createTitle": "Créer une demande de tirage", "dismissError": "Ignorer l'erreur de RP", - "loadingMetadata": "Chargement des métadonnées de la RP…", "noConflicts": "Aucun conflit de fusion détecté.", "preflightChecks": "Vérifications de pré-vol", "previewTitle": "Aperçu des différences et des commits", @@ -4176,15 +4193,19 @@ "confirm": "Confirmer", "confirmRemove": "Confirmer la suppression", "confirmRemoveProject": "Confirmer la suppression du projet", - "daysAgo": "Il y a {{count}}j", - "hoursAgo": "Il y a {{count}}h", + "daysAgo_one": "", + "daysAgo_other": "", + "hoursAgo_one": "", + "hoursAgo_other": "", "justNow": "À l'instant", "lastActivity": "Dernière activité :", - "minutesAgo": "Il y a {{count}}m", - "moreItems": "+{{count}} de plus", + "minutesAgo_one": "", + "minutesAgo_other": "", + "moreItems_one": "", + "moreItems_other": "", "never": "Jamais", - "noHealthData": "Aucune donnée de santé disponible", "nodeAvailability": "Disponibilité du nœud du projet", + "noHealthData": "Aucune donnée de santé disponible", "open": "Ouvrir", "openProject": "Ouvrir le projet", "pause": "Pause", @@ -4199,20 +4220,13 @@ "emptyHint": "Essayez un chemin de base différent ou ajoutez un projet manuellement", "noDbWarning": "Aucune base de données fn trouvée - sera initialisée", "registerAll": "Enregistrer tout", - "registerSelected": "Enregistrer la sélection ({{count}})", "registering": "Enregistrement en cours...", - "selectAll": "Tout sélectionner ({{count}})", - "selectedCount": "{{count}} sélectionné(s)" - }, - "projectSelector": { - "allProjects": "Tous les projets", - "ariaLabel": "Sélectionner le projet", - "clearSearch": "Effacer la recherche", - "noResults": "Aucun projet ne correspond à votre recherche", - "recent": "Récent", - "searchPlaceholder": "Rechercher des projets...", - "selectProject": "Sélectionner le projet", - "viewAll": "Voir tous les projets" + "registerSelected_one": "", + "registerSelected_other": "", + "selectAll_one": "", + "selectAll_other": "", + "selectedCount_one": "", + "selectedCount_other": "" }, "projects": { "actions": { @@ -4237,9 +4251,9 @@ "filterByNode": "Filtrer par nœud", "filterErrored": "Erreurs", "filterPaused": "En pause", + "nodesLabel": "Nœuds", "noMatch": "Aucun projet ne correspond au filtre actuel", "noProjectsFound": "Aucun projet trouvé", - "nodesLabel": "Nœuds", "setup": { "success": "Le projet {{name}} a été enregistré avec succès" }, @@ -4254,13 +4268,24 @@ "title": "Projets", "totalLabel": "Total" }, + "projectSelector": { + "allProjects": "Tous les projets", + "ariaLabel": "Sélectionner le projet", + "clearSearch": "Effacer la recherche", + "noResults": "Aucun projet ne correspond à votre recherche", + "recent": "Récent", + "searchPlaceholder": "Rechercher des projets...", + "selectProject": "Sélectionner le projet", + "viewAll": "Voir tous les projets" + }, "providers": { "actions": { "addModel": "+ Ajouter un modèle", + "detecting": "Détection en cours…", "detectModels": "Détecter les modèles", "detectModelsTitle": "Appeler le point d'accès /models du fournisseur pour découvrir les modèles disponibles", - "detecting": "Détection en cours…", - "removeModel": "Supprimer le modèle", + "removeModel_one": "", + "removeModel_other": "", "save": "Enregistrer le fournisseur", "saving": "Enregistrement en cours..." }, @@ -4280,9 +4305,9 @@ "noModels": "Aucun modèle trouvé. Le fournisseur peut nécessiter une clé API.", "urlRequired": "L'URL de base est requise pour détecter les modèles." }, + "detecting": "Détection en cours…", "detectModels": "Détecter les modèles", "detectTitle": "Détection automatique des modèles à partir du point de terminaison /models du fournisseur", - "detecting": "Détection en cours…", "editLabel": "Modifier {{name}}", "failedDelete": "Échec de la suppression du fournisseur.", "failedDetect": "Échec de la détection des modèles", @@ -4297,6 +4322,7 @@ "maxTokens": "Tokens max", "modelId": "ID du modèle", "modelName": "Nom d'affichage", + "modelNameLabel": "", "models": "Modèles", "name": "Nom d'affichage", "reasoning": "Raisonnement" @@ -4360,8 +4386,10 @@ "p95": "P95", "p95Raw": "P95 brut : {{value}} ms", "reason": "Raison : {{reason}}", - "sampleCount": "Nombre d'échantillons : {{count}}", - "samples": "Échantillons : {{count}}" + "sampleCount_one": "", + "sampleCount_other": "", + "samples_one": "", + "samples_other": "" }, "failureRate": "Taux d'échec : {{rate}}", "heading": "Fiabilité", @@ -4370,12 +4398,14 @@ "insufficientData": "Données insuffisantes — {{reason}}", "mergeAttempts": { "heading": "Tentatives de fusion", - "histogramTotal": "Total de l'histogramme : {{count}}", + "histogramTotal_one": "", + "histogramTotal_other": "", "max": "Max", "mean": "Moyenne", "moreStats": "Plus de statistiques", "reason": "Raison : {{reason}}", - "tasksCounted": "Tâches comptées : {{count}}" + "tasksCounted_one": "", + "tasksCounted_other": "" }, "reason": "Raison : {{reason}}", "resetBaseline": "Réinitialiser la ligne de base : {{date}}", @@ -4413,11 +4443,11 @@ "enrichTaskButton": "Enrichir la tâche", "enrichTaskTitle": "Enrichir la tâche existante", "enterTaskId": "Entrez l'ID de la tâche", + "exportedFile": "Exporté {{filename}}", "exportFailed": "L'exportation a échoué", "exportHtml": "Exporter en HTML", "exportJson": "Exporter en JSON", "exportMd": "Exporter en MD", - "exportedFile": "Exporté {{filename}}", "findingLabel": "Résultat :", "loadingRuns": "Chargement des exécutions…", "loadingTasks": "Chargement des tâches…", @@ -4434,11 +4464,6 @@ "priorityLow": "Faible", "priorityNormal": "Normal", "priorityUrgent": "Urgent", - "providerGitHub": "GitHub", - "providerLlmSynthesis": "Synthèse LLM", - "providerLocalDocs": "Docs locaux", - "providerPageFetch": "Récupération de page", - "providerWebSearch": "Recherche web", "providersLabel": "Fournisseurs", "queryLabel": "Requête", "runCancelled": "Exécution annulée", @@ -4461,7 +4486,8 @@ "viewLabel": "Affichage de la recherche" }, "routine": { - "andMore": "…et {{count}} de plus", + "andMore_one": "", + "andMore_other": "", "delete": "Supprimer", "deleteMessage": "Supprimer la routine {{name}} ? Cette action ne peut pas être annulée.", "deleteName": "Supprimer {{name}}", @@ -4474,11 +4500,13 @@ "enableName": "Activer {{name}}", "resultFailed": "Échoué", "resultSuccess": "Succès", - "runHistory": "Historique d'exécution ({{count}})", + "runHistory_one": "", + "runHistory_other": "", "runNameNow": "Exécuter {{name}} maintenant", - "runNow": "Exécuter maintenant", "running": "Exécution…", - "stepCount": "{{count}} étape" + "runNow": "Exécuter maintenant", + "stepCount_one": "", + "stepCount_other": "" }, "routing": { "cannotChangeWhileActive": "La substitution du nœud ne peut pas être modifiée tant que la tâche est active.", @@ -4494,11 +4522,6 @@ "overrideSection": "Substitution du nœud", "overrideSetTo": "Substitution définie sur", "overrideUpdated": "Substitution du nœud mise à jour", - "policyLabel": { - "block": "Bloquer l'exécution", - "fallback": "Basculer vers local", - "notConfigured": "Non configuré" - }, "selectLabel": "Sélectionner le nœud d'exécution", "source": { "noRouting": "Aucun routage", @@ -4532,11 +4555,13 @@ "advancedMode": "Multi-étapes", "advancedModeHelp": "Exécuter plusieurs étapes séquentiellement (commandes et invites IA)", "aiPromptType": "Invite IA", - "andMore": "…et {{count}} de plus", + "andMore_one": "", + "andMore_other": "", "apiEndpointHint": "Chemin du point de terminaison API qui déclenche cette routine", "apiEndpointLabel": "Point de terminaison API", "apiEndpointPlaceholder": "/api/routine/my-routine", - "automationCount": "{{count}} automatisation{{plural}}", + "automationCount_one": "", + "automationCount_other": "", "cancelButton": "Annuler", "catchUpPolicyHint": "Que faire lorsqu'une exécution planifiée est manquée", "catchUpPolicyLabel": "Politique de rattrapage", @@ -4591,10 +4616,10 @@ "editTitle": "Modifier le calendrier", "emptyStateDescription": "Créez une automatisation avec un calendrier, un webhook, une API ou un déclencheur manuel.", "enable": "Activer", - "enableName": "Activer {{name}}", "enabledHelp": "Lorsqu'il est désactivé, le calendrier ne s'exécutera pas automatiquement", "enabledHint": "Lorsque désactivée, la routine ne s'exécutera pas automatiquement", "enabledLabel": "Activé", + "enableName": "Activer {{name}}", "errorApiEndpointRequired": "Le point de terminaison API est obligatoire", "errorCommandRequired": "La commande est obligatoire", "errorCronInvalid": "Format cron invalide — 5 champs attendus (ex. '0 */6 * * *')", @@ -4607,9 +4632,9 @@ "errorStepCommandRequired": "Étape {{n}} : la commande est obligatoire", "errorStepNameRequired": "Étape {{n}} : le nom est obligatoire", "errorStepPromptRequired": "Étape {{n}} : l'invite est obligatoire", - "errorStepTaskDescRequired": "Étape {{n}} : la description de la tâche est obligatoire", "errorStepsEditing": "Veuillez enregistrer ou annuler toutes les modifications des étapes avant d'enregistrer la routine", "errorStepsRequired": "Au moins une étape est requise", + "errorStepTaskDescRequired": "Étape {{n}} : la description de la tâche est obligatoire", "errorTaskDescriptionRequired": "La description de la tâche est obligatoire", "errorTimeoutMin": "Le délai d'expiration doit être d'au moins 1 seconde (1000 ms)", "errorWebhookPathRequired": "Le chemin du webhook est obligatoire", @@ -4626,13 +4651,13 @@ "frequencyLabel": "Fréquence", "global": "Global", "globalScope": "Automatisations globales (au niveau de l'utilisateur)", - "globalScopeTitle": "Portée mondiale", "globalScoped": "Ce calendrier sera créé à portée mondiale.", + "globalScopeTitle": "Portée mondiale", "loadRoutinesError": "Impossible de charger les routines", "manualTriggerInfo": "Cette routine sera déclenchée manuellement via le tableau de bord ou l'API.", "modeAriaLabel": "Mode d'exécution", - "modeLabel": "Mode d'exécution", "model": "Modèle", + "modeLabel": "Mode d'exécution", "modelConsistency": "Le fournisseur de modèle et l'ID du modèle doivent tous deux être définis ou tous deux être vides", "modelDropdownLabel": "Modèle", "modelHelp": "Modèle IA pour cette étape. Utilise la valeur par défaut si elle n'est pas sélectionnée.", @@ -4656,9 +4681,9 @@ "project": "Projet", "projectRequired": "Les entrées spécifiques au projet nécessitent un projet actif.", "projectScope": "Automatisations limitées au projet", + "projectScoped": "Ce calendrier sera limité au projet actuel.", "projectScopeDisabled": "Sélectionnez un projet pour activer la portée du projet", "projectScopeTitle": "Portée du projet", - "projectScoped": "Ce calendrier sera limité au projet actuel.", "prompt": "Invite", "promptHelp": "Invite IA à exécuter. Fournir des instructions claires pour la tâche.", "promptHint": "Invite IA à exécuter.", @@ -4675,10 +4700,11 @@ "routineSuccess": "\"{{name}}\" terminé avec succès", "routineUpdated": "Routine mise à jour", "runError": "Impossible d'exécuter la routine", - "runHistory": "Historique d'exécution ({{count}})", + "runHistory_one": "", + "runHistory_other": "", "runNameNow": "Exécuter {{name}} maintenant", - "runNow": "Exécuter maintenant", "running": "Exécution…", + "runNow": "Exécuter maintenant", "saveChanges": "Enregistrer les modifications", "saveStep": "Enregistrer l'étape", "saving": "Enregistrement…", @@ -4695,15 +4721,16 @@ "simpleMode": "Simplifier", "simpleModeHelp": "Exécuter une commande shell unique ou une invite IA", "stepCommandRequired": "Étape {{index}} : La commande est requise", - "stepCount": "{{count}} étape", + "stepCount_one": "", + "stepCount_other": "", "stepName": "Nom de l'étape", "stepNamePlaceholder": "p. ex. Exécuter les tests", "stepNameRequired": "Le nom de l'étape est obligatoire", "stepPromptRequired": "Étape {{index}} : L'invite est requise", - "stepType": "Type d'étape", "steps": "Étapes", "stepsEditing": "Veuillez enregistrer ou annuler toutes les modifications d'étapes avant d'enregistrer le calendrier", "stepsRequired": "Au moins une étape est requise", + "stepType": "Type d'étape", "targetColumn": "Colonne cible", "targetColumnHelp": "Colonne dans laquelle la nouvelle tâche sera créée", "targetColumnLabel": "Colonne cible", @@ -4780,7 +4807,8 @@ "saving": "Enregistrement...", "scriptAlreadyExists": "Un script portant ce nom existe déjà", "scriptCommandRequired": "La commande de script est requise", - "scriptCount": "{{count}} script", + "scriptCount_one": "", + "scriptCount_other": "", "scriptCreated": "Script créé", "scriptDeleted": "Script supprimé", "scriptName": "Nom du script", @@ -4852,20 +4880,17 @@ "failed": "Échoué", "headerAwaitingAndErrorPlural": "{{awaitingCount}} sessions IA nécessitent votre saisie, {{errorCount}} échouée(s)", "headerAwaitingAndErrorSingular": "{{awaitingCount}} session IA nécessite votre saisie, {{errorCount}} échouée(s)", - "headerAwaitingPlural": "{{count}} sessions IA nécessitent votre saisie", - "headerAwaitingSingular": "{{count}} session IA nécessite votre saisie", - "headerErrorPlural": "{{count}} sessions IA ont échoué", - "headerErrorSingular": "{{count}} session IA a échoué", + "headerAwaitingPlural_one": "", + "headerAwaitingPlural_other": "", + "headerAwaitingSingular_one": "", + "headerAwaitingSingular_other": "", + "headerErrorPlural_one": "", + "headerErrorPlural_other": "", + "headerErrorSingular_one": "", + "headerErrorSingular_other": "", "regionLabel": "Sessions IA nécessitant une entrée ou ayant échoué", "resume": "Reprendre", - "retry": "Réessayer", - "typeLabel": { - "milestoneInterview": "Entretien de jalon", - "missionInterview": "Entretien de mission", - "planning": "Planification", - "sliceInterview": "Entretien de tranche", - "subtask": "Décomposition en sous-tâches" - } + "retry": "Réessayer" }, "settings": { "actions": { @@ -4876,7 +4901,6 @@ "language": "Langue", "languageAuto": "Auto", "languageAutoHint": "Suivre la langue du navigateur", - "languageHint": "Choisissez la langue de l'interface {{brand}}.", "title": "Apparence" }, "auth": { @@ -4930,8 +4954,8 @@ "general": { "learnMore": "En savoir plus", "settingsSaved": "Paramètres enregistrés", - "upToDate": "Vous êtes à jour ✓", - "updateAvailablePrefix": "v{{version}} disponible" + "updateAvailablePrefix": "v{{version}} disponible", + "upToDate": "Vous êtes à jour ✓" }, "header": { "discord": "Discord" @@ -4941,8 +4965,8 @@ "exportBtn": "Exporter", "exportTitle": "Exporter les paramètres vers un fichier JSON", "importBtn": "Importer", - "importTitle": "Importer les paramètres", "importing": "Importation…", + "importTitle": "Importer les paramètres", "loadingFile": "Chargement…", "reviewPrompt": "Vérifiez les paramètres à importer :" }, @@ -4951,17 +4975,17 @@ "keepRemote": "Garder la version distante", "loading": "Chargement…", "memory": { - "compactSelectedFile": "Compacter le fichier sélectionné", "compacting": "Compactage…", + "compactSelectedFile": "Compacter le fichier sélectionné", "dreamCompleted": "Traitement des rêves terminé", "dreamNow": "Rêver maintenant", - "installQmd": "Installer qmd", "installing": "Installation…", + "installQmd": "Installer qmd", "memoryCompacted": "Fichier mémoire compacté", "memorySaved": "Mémoire enregistrée", "saveMemory": "Enregistrer la mémoire", - "testRetrieval": "Tester la récupération", - "testing": "Test en cours…" + "testing": "Test en cours…", + "testRetrieval": "Tester la récupération" }, "mergeManually": "Fusionner manuellement", "mobileNav": { @@ -4976,45 +5000,14 @@ "savePreset": "Enregistrer le préréglage" }, "nav": { - "accountHeader": "Compte", - "agentPermissions": "Permissions de l'agent", - "appearance": "Apparence", "aria": { "global": "Paramètre global", "project": "Paramètre de projet" }, - "authentication": "Authentification", - "backups": "Sauvegardes", - "commands": "Commandes", - "experimental": "Fonctionnalités expérimentales", - "globalGeneral": "Général", - "globalHeader": "Global", - "globalModels": "Modèles", - "hermesRuntime": "Hermes", - "memory": "Mémoire", - "merge": "Fusion", - "nodeRouting": "Routage des nœuds", - "nodeSync": "Synchronisation des nœuds", - "notifications": "Notifications", - "openclawRuntime": "OpenClaw", - "paperclipRuntime": "Paperclip", - "plugins": "Extensions", - "projectGeneral": "Paramètres généraux du projet", - "projectHeader": "Projet", - "projectModels": "Modèles du projet", - "prompts": "Invites", - "remote": "Accès distant", - "researchGlobal": "Paramètres de recherche par défaut", - "researchProject": "Recherche", - "runtimesHeader": "Environnements d'exécution", - "scheduledEvals": "Évaluations planifiées", - "scheduling": "Planification", - "secrets": "Secrets", "tooltip": { "global": "Partagé entre tous les projets", "project": "Spécifique à ce projet" - }, - "worktrees": "Arborescences de travail" + } }, "notifications": { "sending": "Envoi…", @@ -5028,10 +5021,10 @@ "restarting": "Redémarrage…", "shortLivedTokenGenerated": "Jeton de courte durée généré", "startFresh": "Redémarrer", - "startTunnel": "Démarrer le tunnel", "starting": "Démarrage…", - "stopTunnel": "Arrêter le tunnel", + "startTunnel": "Démarrer le tunnel", "stopping": "Arrêt…", + "stopTunnel": "Arrêter le tunnel", "tunnelRestarted": "Tunnel distant redémarré", "tunnelStarted": "Tunnel distant démarré", "tunnelStopped": "Tunnel distant arrêté", @@ -5039,8 +5032,8 @@ }, "resolveAllLocal": "Résoudre tous : Garder la version locale", "resolveAllRemote": "Résoudre tous : Garder la version distante", - "resolveFailed": "Impossible de résoudre les conflits", "resolvedSuccess": "Conflits de paramètres résolus avec succès", + "resolveFailed": "Impossible de résoudre les conflits", "resolving": "Résolution...", "scheduling": { "selectCurrentDir": "Sélectionner le répertoire actuel", @@ -5068,46 +5061,11 @@ "aiSetupDescription": "Fusion utilise des modèles IA pour planifier, écrire et réviser du code pour vous. Connectez un fournisseur IA ci-dessous pour commencer — utilisez un service hébergé ou saisissez une clé API.", "allProvidersShown": "Tous les fournisseurs disponibles sont déjà affichés ci-dessus.", "allSet": "Tout est prêt !", - "apiKeyFormatError": "Les clés {{providerName}} doivent suivre ce format : {{hint}} (par ex. {{example}})", "apiKeyFormatHint": "Format : {{hint}}", "apiKeyHint": "Clé : {{keyHint}}", - "apiKeyLabel": { - "fallback": "Clé API", - "kimiCoding": "Clé API Kimi", - "minimax": "Clé API MiniMax", - "ollama": "Point de terminaison Ollama", - "openai": "Clé API OpenAI", - "openrouter": "Clé API OpenRouter", - "zai": "Clé API Zhipu AI" - }, - "apiKeyPlaceholder": { - "fallback": "Entrez la clé API", - "kimiCoding": "Entrez votre clé API Kimi", - "minimax": "Entrez votre clé API MiniMax", - "zai": "Entrez votre clé API Zhipu AI" - }, "apiKeyRemoved": "Clé API supprimée", - "apiKeyRequired": "La clé API est requise", "apiKeySaved": "✓ Clé API enregistrée", "apiKeySavedToast": "Clé API enregistrée", - "apiKeySetup": { - "fallback": "Entrez votre clé API pour ce fournisseur.", - "kimiCoding": "Créez votre clé API dans les paramètres de compte de la plateforme Moonshot.", - "minimax": "Générez une clé API depuis la console développeur de la plateforme MiniMax.", - "ollama": "Entrez l'URL de votre point de terminaison Ollama (par exemple http://localhost:11434).", - "openai": "Créez une clé API depuis votre tableau de bord OpenAI sous Clés API.", - "openrouter": "Créez une clé API depuis la page de gestion des clés de votre compte OpenRouter.", - "zai": "Créez une clé API dans les paramètres de compte de la plateforme ouverte Zhipu AI." - }, - "apiKeyUsage": { - "fallback": "Utilisé par Fusion pour authentifier les requêtes vers ce fournisseur", - "kimiCoding": "Utilisé pour les modèles Kimi/Moonshot AI dans l'exécution et la planification des tâches", - "minimax": "Utilisé pour les modèles MiniMax dans l'exécution des tâches", - "ollama": "Se connecte à votre instance Ollama locale", - "openai": "Utilisé pour les modèles GPT dans l'exécution et la planification des tâches", - "openrouter": "Achemine vers plusieurs fournisseurs de modèles IA via une seule clé", - "zai": "Utilisé pour les modèles GLM dans l'exécution des tâches" - }, "ariaDismissRecommendations": "Ignorer les recommandations", "ariaSetupRecommendations": "Recommandations de configuration", "authCodeAlreadySubmitted": "Ce code d'autorisation a déjà été soumis. En attente de la connexion…", @@ -5158,13 +5116,13 @@ "connectAiProvider": "Connecter un fournisseur IA", "connectAiProviderDesc": "Connectez un fournisseur IA pour activer les agents IA pour la planification des tâches et la génération de code", "connectAnyway": "Connecter quand même", + "connectedProviders": "Fournisseurs connectés", "connectGitHub": "Connecter GitHub", "connectGitHubAnytime": "Pas de souci si vous n'êtes pas prêt — connectez GitHub à tout moment depuis Paramètres → Authentification.", "connectGitHubButton": "Connecter GitHub", "connectGitHubDesc": "Connectez GitHub pour importer des problèmes et suivre les demandes de tirage", "connectOauthOptional": "Connecter OAuth (facultatif)", "connectRemoteServer": "Connecter le serveur Fusion distant", - "connectedProviders": "Fournisseurs connectés", "continueToLogin": "Continuer la connexion", "continueWithGhCli": "Continuer avec l'auth gh CLI →", "continueWithoutGitHub": "Continuer sans GitHub →", @@ -5229,10 +5187,10 @@ "githubSkipped": "GitHub a été ignoré. Vous pouvez vous connecter à tout moment depuis Paramètres → Authentification.", "goBackToStep": "Retour à {{label}}", "goToDashboard": "Aller au tableau de bord", - "howDoIChooseModel": "Comment choisir un modèle ?", - "howDoIChooseModelBody": "Les modèles varient en vitesse, capacité et coût. Un bon défaut est généralement le dernier modèle de votre fournisseur connecté. Vous pouvez toujours modifier cela dans les Paramètres.", "howDoesLoginWork": "Comment fonctionne la connexion ?", "howDoesLoginWorkBody": "Cliquer sur Connexion ouvre le site du fournisseur dans un nouvel onglet pour vous connecter. Une fois Fusion autorisé, cette page détectera automatiquement la connexion. Vos identifiants ne sont jamais stockés dans Fusion.", + "howDoIChooseModel": "Comment choisir un modèle ?", + "howDoIChooseModelBody": "Les modèles varient en vitesse, capacité et coût. Un bon défaut est généralement le dernier modèle de votre fournisseur connecté. Vous pouvez toujours modifier cela dans les Paramètres.", "importFromGitHub": "Importer depuis GitHub", "importFromGitHubSubtitle": "Transformez les tickets GitHub en tâches que vous pouvez suivre ici", "inProcess": "En cours", @@ -5294,25 +5252,11 @@ "projectRequired": "Un projet est requis avant que les actions de première tâche soient disponibles.", "projectSelected": "Projet sélectionné — la création de tâches et les imports sont disponibles.", "projectSetupDescription": "Choisissez votre premier projet avant de créer ou d'importer des tâches. Vous pouvez enregistrer un répertoire local existant ou cloner une URL de dépôt GitHub via l'assistant de configuration.", - "providerDesc": { - "anthropic": "Modèles Claude — excellence en raisonnement, analyse et code", - "fallback": "Fournisseur d'IA — connectez-vous pour commencer à utiliser des modèles d'IA", - "gemini": "Modèles Gemini — multimodaux avec un raisonnement puissant", - "google": "Modèles Gemini — multimodaux avec un raisonnement puissant", - "kimi": "Kimi par Moonshot AI — capacités de long contexte", - "kimiCoding": "Kimi par Moonshot AI — capacités de long contexte", - "minimax": "Modèles MiniMax — économiques pour une utilisation à volume élevé", - "moonshot": "Kimi par Moonshot AI — capacités de long contexte", - "ollama": "Exécutez des modèles open-source localement sur votre machine", - "openai": "Modèles GPT — polyvalents pour une large gamme de tâches", - "openaiCodex": "Modèles Codex par OpenAI — optimisés pour les tâches de codage", - "openrouter": "OpenRouter — acheminez les requêtes vers plusieurs fournisseurs d'IA", - "zai": "Modèles GLM par Zhipu AI — fort support multilingue" - }, "providersConnectedSummary": "✓ {{connected}} fournisseur(s) sur {{total}} connecté(s)", - "providersSkippedSummary": "{{count}} fournisseur(s) ignoré(s)", - "providersSkippedSummary_one": "{{count}} fournisseur ignoré", - "providersSkippedSummary_other": "{{count}} fournisseurs ignorés", + "providersSkippedSummary_one_one": "", + "providersSkippedSummary_one_other": "", + "providersSkippedSummary_other_one": "", + "providersSkippedSummary_other_other": "", "quickStartProviders": "Fournisseurs de démarrage rapide", "readinessAiProviderConnected": "{{name}} connecté — les agents IA peuvent travailler sur les tâches", "readinessAiProviderLabel": "Fournisseur IA", @@ -5331,8 +5275,8 @@ "readinessSummaryHeader": "Résumé de la configuration", "recommended": "Recommandé", "recommendedNextSteps": "Étapes suivantes recommandées", - "registerProject": "Enregistrer le projet", "registering": "Enregistrement...", + "registerProject": "Enregistrer le projet", "remoteServerNote": "Votre shell natif nécessite un profil distant actif pour que le transfert vers le tableau de bord puisse s'effectuer.", "remoteServerProfileSaved": "Profil de serveur distant enregistré", "removeKey": "Supprimer la clé", @@ -5346,9 +5290,9 @@ "retry": "Réessayer", "reviewStep": "Revoir {{label}}", "runtimeNode": "Nœud d'exécution", + "savedProfileButFailedToActivate": "Profil enregistré mais échec de l'activation", "saveKey": "Enregistrer", "saveRemoteServer": "Enregistrer le serveur distant", - "savedProfileButFailedToActivate": "Profil enregistré mais échec de l'activation", "saving": "Enregistrement…", "savingKey": "Enregistrement…", "savingRemoteServer": "Enregistrement…", @@ -5361,9 +5305,9 @@ "setToken": "Jeton défini", "setTokenContinue": "Définir le jeton et continuer", "setUpAi": "Configurer l'IA", - "setUpProject": "Configurer le projet", "setupComplete": "Configuration terminée !", "setupMode": "Mode de configuration", + "setUpProject": "Configurer le projet", "setupWizardHint": "Dans l'assistant de configuration, choisissez un répertoire existant ou collez une URL de clone GitHub.", "skip": "Sauter", "skipForNow": "Ignorer pour l'instant", @@ -5449,21 +5393,22 @@ "catalogUnavailable": "Le catalogue n'est pas disponible pour le moment. Veuillez réessayer plus tard.", "closeDetail": "Fermer le détail de la compétence", "closeView": "Fermer la vue des compétences", - "disableSkill": "Désactiver {{name}}", "disabled": "Compétence désactivée", + "disableSkill": "Désactiver {{name}}", "discovered": "découvertes", - "discoveredCount": "{{count}} compétences découvertes", + "discoveredCount_one": "", + "discoveredCount_other": "", "discoveredSection": "Compétences découvertes", - "enableSkill": "Activer {{name}}", "enabled": "Compétence activée", + "enableSkill": "Activer {{name}}", "filesLabel": "Fichiers", "install": "Installer", "installError": "Impossible d'installer la compétence", "installFailed": "Impossible d'installer {{name}} : {{message}}", - "installSkill": "Installer {{name}}", - "installSuccess": "{{name}} installé", "installing": "Installation en cours…", "installsCount": "{{count}} installations", + "installSkill": "Installer {{name}}", + "installSuccess": "{{name}} installé", "loadCatalogError": "Impossible de charger le catalogue", "loadContentError": "Impossible de charger le contenu de la compétence", "loadDiscoveredError": "Impossible de charger les compétences découvertes", @@ -5491,8 +5436,8 @@ "feedbackPlaceholder": "par exemple, 'Ajouter plus de détails sur la gestion des erreurs', 'Diviser ceci en étapes plus petites', 'Inclure des tests pour les points de terminaison de l'API'...", "keyboardHint": "Appuyez sur Ctrl+Entrée (ou Cmd+Entrée) pour enregistrer", "placeholder": "Entrez la spécification de la tâche en Markdown...", - "requestRevision": "Demander une révision de l'IA", "requesting": "Demande en cours…", + "requestRevision": "Demander une révision de l'IA", "revisionHelp": "Fournissez des commentaires à l'IA pour améliorer cette spécification. La tâche passera à la phase de planification pour une nouvelle planification.", "revisionTitle": "Demander à l'IA de réviser", "saving": "Enregistrement…", @@ -5514,12 +5459,14 @@ "dropTitle": "Supprimer la sauvegarde orpheline ?", "failedToLoadDiff": "Échec du chargement du diff", "failedToLoadOrphans": "Échec du chargement des orphelins", - "fileCount": "{{count}} fichiers", + "fileCount_one": "", + "fileCount_other": "", "inspectDiff": "Inspecter diff", "loadingDiff": "Chargement du diff…", "noDiffOutput": "Aucune sortie diff disponible.", "noOrphans": "Aucune sauvegarde automatique de fusion orpheline trouvée.", - "orphanCount": "{{count}} orphelins", + "orphanCount_one": "", + "orphanCount_other": "", "shaLabel": "SHA", "title": "Récupération de stash", "unknownSource": "Source inconnue" @@ -5592,7 +5539,8 @@ "untitled": "Sans titre" }, "syncLog": { - "entryCount": "{{count}} entrée", + "entryCount_one": "", + "entryCount_other": "", "filterAll": "Tous", "filterAllNodes": "Tous les nœuds", "filterDirection": "Direction :", @@ -5624,11 +5572,12 @@ "errorLoadVitestSettings": "Échec du chargement des paramètres vitest", "errorSaveVitestSettings": "Échec de l'enregistrement des paramètres vitest", "footerRefreshFailed": "Dernière actualisation échouée : {{error}}", + "killedProcesses_one": "", + "killedProcesses_other": "", "killThresholdInputAriaLabel": "Seuil d'arrêt (%)", "killThresholdLabel": "Seuil d'arrêt (%)", "killThresholdSliderAriaLabel": "Curseur de seuil d'arrêt (%)", "killVitest": "Arrêter les processus vitest", - "killedProcesses": "{{count}} processus arrêtés", "lastAutoKill": "Dernier arrêt automatique : {{time}}", "loading": "Chargement des statistiques système…", "notYet": "Pas encore", @@ -5672,18 +5621,14 @@ "taskChanges": { "attributionFailed": "L'ensemble des fichiers déposés peut inclure des commits étrangers (attribution indisponible).", "disableWordWrap": "Désactiver le retour à la ligne", - "emptyWorktreeHint": "Le diff du répertoire de travail en direct est vide. Affichage des derniers chemins de fichiers capturés lors de l'exécution — correctifs non disponibles.", "enableWordWrap": "Activer le retour à la ligne", "error": "Erreur lors du chargement des modifications : {{error}}", - "executionFilesHint": "Il s'agit de fichiers capturés à partir du répertoire de travail lors de l'exécution. Ils peuvent différer des fichiers qui ont réellement atterri sur la branche principale. Le diff soutenu par la lignée n'est pas disponible pour cette tâche.", "expandDiff": "Développer la vue de diff en plein écran", "expandDiffView": "Développer la vue de diff", - "fileCount": "{{count}} fichier{{plural}} modifié(s).", - "filesChangedHeading": "Fichiers modifiés ({{count}})", - "landedFilesHint": "Il s'agit de fichiers capturés à partir des métadonnées de commit fusionné. Le diff soutenu par la lignée n'est pas disponible pour cette tâche.", + "filesChangedHeading_one": "", + "filesChangedHeading_other": "", "loadError": "Impossible de charger les modifications de la tâche", "loading": "Chargement des modifications...", - "merged": "Fusionné {{date}}", "mergedAt": "Fusionné {{date}}", "nextFile": "Fichier suivant", "noExecutionModifications": "L'agent n'a modifié aucun fichier lors de l'exécution.", @@ -5694,7 +5639,6 @@ "noWorktree": "Aucun répertoire de travail disponible pour cette tâche.", "noWorktreeHint": "Les modifications s'afficheront une fois la tâche en cours.", "previousFile": "Fichier précédent", - "statusUnknown": "état inconnu", "summaryHint": "Résumé du commit final : {{files}} fichier{{plural}} modifié(s), +{{additions}} additions, -{{deletions}} suppressions. Ne compte que le commit de fusion/compression enregistré, pas la lignée complète de la tâche.", "toggleWordWrap": "Basculer le retour à la ligne", "unavailable": "Modifications de fichiers détaillées non disponibles." @@ -5703,6 +5647,19 @@ "actions": { "menuBtn": "Actions" }, + "agent": { + "assignBtn": "Assigner un agent", + "assignedUpdated": "Agent assigné mis à jour", + "assignFailed": "Échec de l'affectation de l'agent : {{error}}", + "label": "Agent", + "loadFailed": "Échec du chargement des agents : {{error}}", + "loadingAgents": "Chargement des agents...", + "noAgents": "Aucun agent disponible", + "unassigned": "Agent désaffecté", + "unassignFailed": "Échec de la désaffectation de l'agent : {{error}}", + "unassignTitle": "Désaffecter l'agent" + }, + "agentLink": "agent {{id}}", "ageStaleness": { "active": "Actif", "age": "Âge", @@ -5713,24 +5670,11 @@ "title": "Ancienneté de la tâche", "warning": "Avertissement" }, - "agent": { - "assignBtn": "Assigner un agent", - "assignFailed": "Échec de l'affectation de l'agent : {{error}}", - "assignedUpdated": "Agent assigné mis à jour", - "label": "Agent", - "loadFailed": "Échec du chargement des agents : {{error}}", - "loadingAgents": "Chargement des agents...", - "noAgents": "Aucun agent disponible", - "unassignFailed": "Échec de la désaffectation de l'agent : {{error}}", - "unassignTitle": "Désaffecter l'agent", - "unassigned": "Agent désaffecté" - }, - "agentLink": "agent {{id}}", "attachments": { "attachBtn": "Joindre une capture d'écran", "attached": "Capture d'écran jointe", - "deleteTitle": "Supprimer la pièce jointe", "deleted": "Pièce jointe supprimée", + "deleteTitle": "Supprimer la pièce jointe", "heading": "Pièces jointes", "none": "(aucune pièce jointe)", "uploading": "Téléversement…" @@ -5749,6 +5693,8 @@ "reattachBtn": "Rattacher la branche", "reattached": "Branche rattachée pour {{id}} ({{branch}})", "reattachedResult": "{{branch}} rattachée ({{count}} commits devant {{base}}).", + "reattachedResult_one": "", + "reattachedResult_other": "", "reattaching": "Rattachement…", "skipped": "Rattachement de branche ignoré pour {{id}} : {{reason}}", "skippedResult": "Rattachement ignoré : {{reason}}" @@ -5764,21 +5710,21 @@ "actionLeft": "laissé", "allowRecreation": "Autoriser la recréation ultérieure (déverrouillage opérateur)", "allowRecreationDesc": "Permet aux agents de recréer cet ID de tâche sans --force-resurrect. Laissez décoché pour conserver l'état de tombstone.", + "archivedAfterUnlink": "{{id}} archivé après dissociation des références de lignage", "archiveInstead": "Archiver à la place", "archiveUnlinkPrompt": "Archiver quand même en dissociant ces références d'abord ?", - "archivedAfterUnlink": "{{id}} archivé après dissociation des références de lignage", "ariaLabel": "Supprimer la tâche", "btn": "Supprimer", "closeIssue": "Fermer l'issue", "confirm": "Supprimer", + "deletedAfterRemovingDeps": "{{id}} supprimé après la suppression des références de dépendance", + "deletedAfterUnlinkLineage": "{{id}} supprimé après dissociation des références de lignage", + "deletedToast": "{{id}} supprimé{{suffix}}", "deleteIssue": "Supprimer l'issue", "deleteLinkedIssueMessage": "Supprimer {{issueRef}} sur GitHub, ou le laisser inchangé ?", "deleteLinkedIssueTitle": "Supprimer l'issue GitHub liée", "deleteUnlinkDepsPrompt": "Supprimer quand même en supprimant ces références de dépendance d'abord ?", "deleteUnlinkLineagePrompt": "Supprimer quand même en dissociant ces références d'abord ?", - "deletedAfterRemovingDeps": "{{id}} supprimé après la suppression des références de dépendance", - "deletedAfterUnlinkLineage": "{{id}} supprimé après dissociation des références de lignage", - "deletedToast": "{{id}} supprimé{{suffix}}", "forceDeleteTitle": "Forcer la suppression de la tâche", "issueSuffix": "et {{action}} l'issue {{ref}}", "leaveUnchanged": "Laisser inchangé", @@ -5816,8 +5762,8 @@ "autosaveHint": "Les modifications sont enregistrées automatiquement", "autosaving": "Enregistrement automatique…", "nodeOverrideLocked": "Le remplacement du nœud d'exécution est verrouillé pendant qu'une tâche est active/en cours.", - "saveFailed": "Échec de l'enregistrement", "saved": "Enregistré", + "saveFailed": "Échec de l'enregistrement", "saving": "Enregistrement…", "sourceExternalIdPlaceholder": "Identifiant de l'issue", "sourceIssueHint": "Laissez tous les champs vides pour effacer les métadonnées de l'issue source.", @@ -5892,7 +5838,8 @@ "activityHeading": "Activité", "agentLog": "Journal de l'agent", "noActivity": "(aucune activité)", - "truncated": "Affichage des {{count}} dernières entrées d'activité." + "truncated_one": "", + "truncated_other": "" }, "longestTimingEvent": "Événement de minutage le plus long", "longestWorkflowStep": "Étape de workflow la plus longue", @@ -5908,8 +5855,8 @@ "backToInProgress": "Retour à En cours", "cancelMove": "Annuler le déplacement", "keepProgress": "Conserver la progression", - "moveTo": "Déplacer vers {{column}}", "movedTo": "Déplacé vers {{column}}", + "moveTo": "Déplacer vers {{column}}", "preserveProgressMessage": "Cette tâche a des étapes terminées. Conserver la progression avant de déplacer ?", "preserveProgressTitle": "Conserver la progression ?", "resetProgress": "Réinitialiser la progression", @@ -5920,9 +5867,9 @@ "actions": "Choisissez Archiver pour déplacer cette tâche vers les archives, ou Conserver pour continuer avec cette tâche.", "archiveBtn": "Archiver", "archiveConfirm": "Archiver", + "archived": "{{id}} archivé", "archiveMessage": "Archiver {{id}} en tant que doublon de {{duplicateOf}} ?", "archiveTitle": "Archiver la tâche quasi-dupliquée", - "archived": "{{id}} archivé", "copy": "Cette tâche semble être un quasi-doublon de", "headline": "Doublon potentiel détecté", "keepBtn": "Conserver", @@ -5941,8 +5888,8 @@ "noSteps": "Aucune étape", "noTimedEvents": "Aucun événement chronométré enregistré pour l'instant.", "noTokenUsage": "Aucune utilisation de tokens enregistrée pour cette tâche pour l'instant.", - "noWorkflowStepTimings": "Aucune durée d'étape de workflow terminée pour l'instant.", "notSet": "Non défini", + "noWorkflowStepTimings": "Aucune durée d'étape de workflow terminée pour l'instant.", "outputTokens": "Sortie", "pause": { "pauseBtn": "Mettre en pause", @@ -5959,9 +5906,9 @@ "rebuildMessage": "Reconstruire le plan pour cette tâche ? La tâche passera en planification.", "rebuildTitle": "Reconstruire le plan", "rejectBtn": "Rejeter le plan", + "rejected": "Plan rejeté — {{id}} renvoyé en planification pour replanning", "rejectMessage": "Rejeter ce plan ? La spécification sera supprimée et régénérée.", "rejectTitle": "Rejeter le plan", - "rejected": "Plan rejeté — {{id}} renvoyé en planification pour replanning", "replanning": "Replanning de {{id}}…" }, "pr": { @@ -5983,31 +5930,18 @@ "progress": { "heading": "Progression", "noSteps": "(aucune étape définie)", - "stepCount": "{{count}}/{{total}} étapes" + "stepCount_one": "", + "stepCount_other": "" }, "provenance": { - "agent": "agent", - "api": "API", - "automation": "Automatisation", - "chatSession": "Session de chat", - "cli": "CLI", "createdBy": "Créé par", - "createdVia": "Créé via", - "dashboard": "Tableau de bord", - "duplicate": "Doublon", - "githubImport": "Import GitHub", - "openIssue": "Issue ouverte", - "quickChat": "Chat rapide", - "recovery": "Récupération", - "refinement": "Raffinement", - "research": "Recherche", - "scheduledTask": "Tâche planifiée", - "workflowStep": "Étape de workflow" + "createdVia": "Créé via" }, "recoveryState": "État de récupération", "refine": { "btn": "Affiner", - "charCount": "{{count}}/2000 caractères", + "charCount_one": "", + "charCount_other": "", "createBtn": "Créer une tâche de raffinement", "creating": "Création...", "feedbackRequired": "Veuillez saisir des commentaires décrivant ce qui doit être affiné", @@ -6082,8 +6016,8 @@ "loading": "Chargement de la spécification…", "noPrompt": "(aucune invite)", "placeholder": "Saisissez la spécification de la tâche en Markdown...", - "requestRevisionBtn": "Demander une révision IA", "requesting": "Demande en cours…", + "requestRevisionBtn": "Demander une révision IA", "revisionColumnError": "Impossible de demander une révision : la tâche doit être dans la colonne « triage », « todo », « in-progress » ou « in-review ».", "revisionRequested": "Révision IA demandée. La tâche a été déplacée en planification.", "saving": "Enregistrement…", @@ -6129,8 +6063,8 @@ "wallClockSinceFirst": "Temps réel depuis la première exécution", "workflow": { "loadFailed": "Échec du chargement des résultats du workflow : {{error}}", - "stepsUpdateFailed": "Échec de la mise à jour des étapes du workflow : {{error}}", - "stepsUpdated": "Étapes du workflow mises à jour" + "stepsUpdated": "Étapes du workflow mises à jour", + "stepsUpdateFailed": "Échec de la mise à jour des étapes du workflow : {{error}}" }, "workflowRuntime": "Durée d'exécution du workflow", "workflowTimedSteps": "Étapes chronométrées du workflow", @@ -6181,8 +6115,8 @@ "taskForm": { "addDependencies": "Ajouter des dépendances", "attachHint": "Vous pouvez aussi coller des images ou les glisser-déposer", - "attachScreenshot": "Joindre une capture d'écran", "attachmentsLabel": "Pièces jointes", + "attachScreenshot": "Joindre une capture d'écran", "autoMergeDefault": "Défaut (suivre le paramètre du projet)", "autoMergeDisabled": "Désactivé", "autoMergeEnabled": "Activé", @@ -6205,7 +6139,8 @@ "branchStrategyLabel": "Stratégie de branche", "collapseDescription": "Réduire la description", "dependenciesLabel": "Dépendances", - "dependenciesSelected": "{{count}} sélectionné(s)", + "dependenciesSelected_one": "", + "dependenciesSelected_other": "", "descriptionLabel": "Description", "descriptionPlaceholder": "Que faut-il faire ?", "descriptionRefinedToast": "Description affinée avec l'IA", @@ -6228,17 +6163,11 @@ "moveDown": "Descendre", "moveUp": "Monter", "noAvailableTasks": "Aucune tâche disponible", - "noModelsAvailable": "Aucun modèle disponible. Configurez l'authentification dans les paramètres.", "nodeDefaultOption": "Utiliser le nœud par défaut du projet / local", "nodeOverrideHint": "La substitution de tâche a la priorité sur le routage de nœud par défaut du projet.", "nodeOverrideLabel": "Substitution du nœud d'exécution", - "nodeStatusConnecting": "Connexion en cours", - "nodeStatusError": "Erreur", - "nodeStatusOffline": "Hors ligne", - "nodeStatusOnline": "En ligne", + "noModelsAvailable": "Aucun modèle disponible. Configurez l'authentification dans les paramètres.", "overridePreset": "Remplacer", - "phasePostMerge": "Post-fusion", - "phasePreMerge": "Pré-fusion", "planButton": "Planifier", "planningLabel": "Planification", "planningModelLabel": "Modèle de planification", @@ -6246,10 +6175,6 @@ "presetLabel": "Préréglage", "presetUseDefault": "Utiliser le défaut", "priorityLabel": "Priorité", - "priority_high": "Élevée", - "priority_low": "Faible", - "priority_normal": "Normal", - "priority_urgent": "Urgent", "refineAddDetailsDesc": "Ajouter des détails d'implémentation et du contexte", "refineAddDetailsTitle": "Ajouter des détails", "refineButton": "Affiner", @@ -6264,13 +6189,13 @@ "removeImage": "Supprimer l'image", "removeStep": "Supprimer", "reviewDefault": "Défaut (Auto — le triage décide)", + "reviewerLabel": "Réviseur", + "reviewerModelLabel": "Modèle réviseur", "reviewLabel": "Révision", "reviewLevel0": "0 — Aucune", "reviewLevel1": "1 — Plan seulement", "reviewLevel2": "2 — Plan et code", "reviewLevel3": "3 — Complet", - "reviewerLabel": "Réviseur", - "reviewerModelLabel": "Modèle réviseur", "searchTasksPlaceholder": "Rechercher des tâches…", "sharedBranchPlaceholder": "ex. clionboarding", "sharedFeatureBranchLabel": "Branche de fonctionnalité partagée", @@ -6297,96 +6222,93 @@ "autoMergeOff": "Fusion automatique désactivée", "autoMergeOn": "Fusion automatique activée", "autoMergePreferenceUpdated": "Préférence de fusion automatique par tâche mise à jour", - "completed": "Complété", "completedAtSep": " · Terminé : {{timestamp}}", "createPr": "Créer une demande de tirage", "effective": "Effectif : {{label}}", "effectiveFrozen": "Effectif : {{label}} — gelé à l'entrée de la révision", - "error": "Erreur", "errorSep": " · Erreur : {{message}}", "followDefault": "Suivre par défaut", - "lastRefreshed": "Dernière actualisation", "loadError": "Impossible de charger les données de révision.", "loadingData": "Chargement des données de révision…", "markdown": "Markdown", - "never": "Jamais", "noCapturedFeedback": "Aucun retour d'examen capturé pour le moment.", "noFeedbackDirect": "Aucun retour d'examen pour le moment — cette tâche n'a pas généré de retour d'agent d'examen en mode direct.", "noReviewItems": "Aucun élément d'examen pour le moment.", "perTaskAutoMerge": "Fusion automatique par tâche", "plain": "Texte brut", - "prSummaryLine": "{{decision}} · {{count}} élément(s) de révision", + "prSummaryLine_one": "", + "prSummaryLine_other": "", "queueing": "Mise en file d'attente en cours…", "refresh": "Actualiser", "refreshDataFailed": "Impossible de rafraîchir les données de révision.", - "refreshFailed": "Échec de l'actualisation", - "refreshSourceBackground": "Arrière-plan", - "refreshSourceInitialLoad": "Chargement initial", - "refreshSourceManual": "Manuel", - "refreshStatusLine": "{{status}} · Dernière actualisation : {{timestamp}} · {{source}}", "refreshed": "Examen actualisé", + "refreshFailed": "Échec de l'actualisation", "refreshing": "Actualisation en cours…", + "refreshStatusLine": "{{status}} · Dernière actualisation : {{timestamp}} · {{source}}", "requestRevision": "Demander une révision", - "reviewerSummaryLine": "{{reviewer}} · {{count}} élément(s) de révision", + "reviewerSummaryLine_one": "", + "reviewerSummaryLine_other": "", "revisionQueueFailed": "Impossible de mettre en file d'attente la révision", "revisionStarted": "Révision IA de même tâche démarrée à partir du retour d'examen sélectionné", - "selected": "Sélectionné", "selectedAt": "Sélectionné : {{timestamp}}", "showMarkdown": "Afficher le Markdown formaté", "showRawText": "Afficher le texte brut", - "started": "Commencé", "startedAtSep": " · Démarré : {{timestamp}}", - "upToDate": "À jour", - "updateFailed": "Échec de la mise à jour {{taskId}} : {{error}}" + "updateFailed": "Échec de la mise à jour {{taskId}} : {{error}}", + "upToDate": "À jour" }, "tasks": { "addTaskPlaceholder": "Ajouter une tâche…", "agent": "Agent", "agentLabel": "Agent", "archive": "Archiver", + "archived": "{{taskId}} archivé", + "archivedUnlinked": "{{taskId}} archivé après dissociation des références de lignée", "archiveFailed": "Impossible d'archiver {{taskId}} : {{error}}", "archiveLineageConflict": "{{taskId}} a des enfants de lignée ({{children}}) qui le référencent comme parent source.\n\nArchiver quand même en dissociant d'abord ces références ?", "archiveTask": "Archiver la tâche", - "archived": "{{taskId}} archivé", - "archivedUnlinked": "{{taskId}} archivé après dissociation des références de lignée", "assignedTo": "Assigné à {{name}}", "attach": "Joindre", - "attachCount": "Joindre ({{count}})", - "attachFileFailed": "Échec de la pièce jointe {{fileName}} : {{error}}", + "attachCount_one": "", + "attachCount_other": "", "attachedFile": "{{fileName}} joint à {{taskId}}", + "attachFileFailed": "Échec de la pièce jointe {{fileName}} : {{error}}", "awaitingApproval": "En attente d'approbation", "baseBranch": "Base", "blockedByTooltip": "Bloqué par {{taskId}} (conflit de fichiers)", "branch": "Branche", "branchMetadata": "Métadonnées de branche", + "branchProgress": "", + "branchProgressTitle": "", "cancelMove": "Annuler le déplacement", "clearSelection": "Effacer la sélection", "closeIssue": "Fermer l'issue", "collapse": "Réduire", + "createdByAgent": "Créé par un agent", + "createdByAgentNamed": "Créé par l'agent : {{name}}", + "createdPr": "PR #{{number}} créée", "createFailed": "Impossible de créer la tâche", "createPr": "Créer une PR", "createPrAriaLabel": "Créer une pull request", "createPrTitle": "Créer une PR pour cette tâche", "createTaskTitle": "Créer une tâche", - "createdByAgent": "Créé par un agent", - "createdByAgentNamed": "Créé par l'agent : {{name}}", - "createdPr": "PR #{{number}} créée", "creating": "Création en cours…", "decisionOnly": "décision uniquement", "decisionOnlyTitle": "Tâche décisionnelle uniquement", "deleteConfirm": "Supprimer {{taskId}} ?", + "deleted": "{{taskId}} supprimé{{suffix}}", + "deletedRemovedDeps": "{{taskId}} supprimé après suppression des références de dépendance", + "deletedUnlinked": "{{taskId}} supprimé après dissociation des références de lignée", "deleteFailed": "Impossible de supprimer {{taskId}} : {{error}}", "deleteIssue": "Supprimer l'issue", "deleteLinkedIssueMessage": "Supprimer {{issueLabel}} sur GitHub ou le laisser intact ?", "deleteLinkedIssueTitle": "Supprimer l'issue GitHub liée", "deleteTask": "Supprimer la tâche", "deleteTitle": "Supprimer la tâche", - "deleted": "{{taskId}} supprimé{{suffix}}", - "deletedRemovedDeps": "{{taskId}} supprimé après suppression des références de dépendance", - "deletedUnlinked": "{{taskId}} supprimé après dissociation des références de lignée", "dependencyConflict": "{{taskId}} est une dépendance de {{dependentList}}.\n\nSupprimer quand même en retirant d'abord ces références de dépendance ?", "deps": "Dép.", - "depsCount": "{{count}} dép.", + "depsCount_one": "", + "depsCount_other": "", "descriptionPlaceholder": "Description de la tâche", "descriptionRefined": "Description affinée par l'IA", "doneNoMerge": "Terminé (sans fusion)", @@ -6406,11 +6328,14 @@ "fanoutEscalated": "Chevauchement escaladé", "fanoutEscalationSuffix": " · escaladé après {{minutes}} min dans la colonne bloquante", "fanoutHighFanoutSuffix": " (seuil de goulot d'étranglement : {{threshold}})", - "fanoutStale": "{{count}} obsolète(s)", - "fanoutTooltip": "Bloque {{count}} tâche(s) active(s) ; file blockedBy avec chevauchement : {{queueCount}} todo{{highFanout}}{{escalation}}", + "fanoutStale_one": "", + "fanoutStale_other": "", + "fanoutTooltip_one": "", + "fanoutTooltip_other": "", "fast": "Rapide", "fastMode": "Mode rapide", - "filesChanged": "{{count}} fichier modifié", + "filesChanged_one": "", + "filesChanged_other": "", "forceDeleteTitle": "Forcer la suppression", "githubTrackingDefaultOff": "désactivé", "githubTrackingDefaultOn": "activé", @@ -6438,23 +6363,25 @@ "loadAgentsFailed": "Impossible de charger les agents : {{msg}}", "loadAgentsFailedGeneric": "Impossible de charger les agents", "loadDependencyFailed": "Impossible de charger la dépendance {{depId}}", - "loadModelsFailed": "Impossible de charger les modèles", "loadingAgents": "Chargement des agents…", + "loadModelsFailed": "Impossible de charger les modèles", "missionBadgeTitle": "Mission : {{name}}", "modelExecutor": "Exécuteur", "modelPlan": "Planifier", "modelReviewer": "Réviseur", "models": "Modèles", - "modelsCount": "{{count}} modèle", + "modelsCount_one": "", + "modelsCount_other": "", "moreOptions": "Plus d'options", "move": "Déplacer", + "moved": "{{taskId}} déplacé vers {{column}}", "moveFailed": "Impossible de déplacer {{taskId}} : {{error}}", "moveTask": "Déplacer la tâche", - "moved": "{{taskId}} déplacé vers {{column}}", "nearDuplicateTitle": "Potentiel quasi-doublon de {{id}}", + "needsInput": "", "noAgentsAvailable": "Aucun agent disponible", - "noExistingTasks": "Aucune tâche existante", "node": "Nœud", + "noExistingTasks": "Aucune tâche existante", "openRetryBreakdown": "Voir le détail des tentatives", "paused": "en pause", "pausedByAgent": "mis en pause par l'agent", @@ -6482,7 +6409,8 @@ "resetProgress": "Réinitialiser la progression", "resetProgressMessage": "Réinitialiser la progression de toutes les étapes avant de déplacer cette tâche ?", "resetProgressTitle": "Réinitialiser la progression ?", - "retriesAriaLabel": "{{count}} tentatives", + "retriesAriaLabel_one": "", + "retriesAriaLabel_other": "", "retry": "Réessayer", "retryFailed": "Impossible de relancer {{taskId}} : {{error}}", "retrying": "Nouvelle tentative…", @@ -6498,17 +6426,18 @@ "showSteps": "Afficher les étapes", "stalled": "En attente", "statusMergingFix": "Fusion des correctifs…", - "stepCount": "{{count}} étape", + "stepCount_one": "", + "stepCount_other": "", "stuck": "Bloqué", "subtask": "Sous-tâche", "subtaskButtonTitle": "Décomposer en sous-tâches générées par l'IA", "toggleFastMode": "Activer/désactiver le mode d'exécution rapide", "unarchive": "Désarchiver", + "unarchived": "{{taskId}} désarchivé", "unarchiveFailed": "Impossible de désarchiver {{taskId}} : {{error}}", "unarchiveTask": "Désarchiver la tâche", - "unarchived": "{{taskId}} désarchivé", - "updateFailed": "Impossible de mettre à jour {{taskId}} : {{error}}", "updated": "{{taskId}} mis à jour", + "updateFailed": "Impossible de mettre à jour {{taskId}} : {{error}}", "uploadFailed": "Échec de l'envoi : {{files}}", "usingDefault": "Valeur par défaut", "viewDependency": "Cliquer pour afficher {{depId}}", @@ -6539,37 +6468,14 @@ "statusReconnecting": "Reconnexion en cours..." }, "theme": { - "colorTheme": { - "default": "Par défaut" - }, + "colorTheme": "", "colorThemeLabel": "Thème de couleur", "currentTheme": "Thème actuel", - "dark": "Foncé", - "darkMode": "Mode sombre", - "fontSize": { - "Default": "Par défaut", - "Large": "Grand", - "Largest": "Le plus grand", - "Small": "Petit" - }, + "fontSize": "", "fontSizeLabel": "Taille de police du tableau de bord", - "light": "Clair", - "lightMode": "Mode clair", "modeLabel": "Mode de thème", "resetButton": "Réinitialiser aux valeurs par défaut", - "resetLabel": "Réinitialiser au thème par défaut", - "system": "Système", - "systemMode": "Mode système" - }, - "time": { - "daysAgo": "il y a {{n}}j", - "hoursAgo": "il y a {{n}}h", - "inAMoment": "dans un instant", - "inDays": "dans {{n}}j", - "inHours": "dans {{n}}h", - "inMinutes": "dans {{n}}min", - "justNow": "à l'instant", - "minutesAgo": "il y a {{n}}min" + "resetLabel": "Réinitialiser au thème par défaut" }, "todo": { "addItemPlaceholder": "Ajouter un élément de tâche", @@ -6593,6 +6499,7 @@ "failedDeleteItemToast": "Échec de la suppression de la tâche à faire", "failedDeleteList": "Échec de la suppression de la liste", "failedDeleteListToast": "Échec de la suppression de la liste de tâches", + "failedLoadLists": "", "failedRenameList": "Échec du renommage de la liste", "failedRenameListToast": "Échec du renommage de la liste de tâches", "failedReorderItems": "Échec de la réorganisation des éléments", @@ -6653,7 +6560,8 @@ "resetsInDaysHours": "réinitialise dans {{days}}j {{hours}}h", "resetsInHours": "réinitialise dans {{hours}}h", "resetsInMinutes": "réinitialise dans {{mins}}m", - "showHidden": "Afficher les cachés ({{count}})", + "showHidden_one": "", + "showHidden_other": "", "statusError": "Erreur", "statusNotConfigured": "Non configuré", "title": "Utilisation", @@ -6663,9 +6571,9 @@ }, "workflow": { "add": "Ajouter", + "adding": "Ajout en cours...", "addTemplate": "Ajouter le modèle", "addWorkflowStep": "Ajouter une étape de workflow", - "adding": "Ajout en cours...", "advisoryExplanation": "Les étapes de flux de travail consultatif ont signalé des améliorations non bloquantes :", "agentPromptLabel": "Invite d'agent", "agentPromptPlaceholder": "Laisser vide pour utiliser l'affinement IA", @@ -6719,6 +6627,7 @@ "gateModeAdvisoryHint": "Les échecs sont enregistrés comme consultatifs et ne bloquent pas la fusion.", "gateModeGate": "Bloquant", "gateModeGateHint": "Les échecs bloquent la fusion et demandent une correction.", + "graphEditor": "", "hideOutput": "Masquer le résultat", "loadingBuiltInTemplates": "Chargement des modèles intégrés...", "loadingResults": "Chargement des résultats du flux de travail…", @@ -6727,12 +6636,12 @@ "modalAriaLabel": "Étapes du workflow", "modalTitle": "Étapes du workflow", "modeAiPrompt": "Invite IA", - "modeScript": "Exécuter un script", "modelHintCustom": "Utilisation de {{provider}}/{{modelId}}", "modelHintDefault": "Utilisation du modèle global par défaut", "modelOverrideDropdownLabel": "Remplacement du modèle pour cette étape de workflow", "modelOverrideLabel": "Remplacement du modèle", "modelOverridePlaceholder": "Sélectionner un remplacement de modèle…", + "modeScript": "Exécuter un script", "moveDown": "Déplacer vers le bas", "moveUp": "Déplacer vers le haut", "needsReview": "Nécessite un examen de suivi.", @@ -6749,8 +6658,6 @@ "phasePreMergeHint": "S'exécute avant la fusion — peut bloquer la fusion en cas d'échec", "plain": "Texte brut", "polishNotes": "Notes de polonais", - "postMerge": "Après fusion", - "preMerge": "Avant fusion", "promptRefined": "Invite affinée avec l'IA", "refineWithAi": "Affiner avec l'IA", "refineWithAiAriaLabel": "Affiner l'invite avec l'IA", @@ -6761,31 +6668,87 @@ "selectStepsDescription": "Sélectionnez les étapes à exécuter après l'implémentation de la tâche", "showOutput": "Afficher le résultat", "started": "Démarré :", - "statusAdvisory": "Avertissement", - "statusFailed": "Échoué", - "statusPassed": "Réussi", - "statusRunning": "Exécution…", - "statusSkipped": "Ignoré", - "stepCount": "{{count}} étape{{count_one::count_other:s}}", + "stepCount_one": "", + "stepCount_other": "", "stepCreated": "Étape de workflow créée", "stepDefinitionNotFound": "Définition d'étape introuvable.", "stepDeleted": "Étape de workflow supprimée", - "stepUpdated": "Étape de workflow mise à jour", "steps": "Étapes du flux de travail", "stepsExplanation": "Les étapes de pré-fusion s'exécutent après l'implémentation, avant la fusion. Les étapes post-fusion s'exécutent après la réussite de la fusion.", - "summaryAdvisory": "{{count}} avis", - "summaryFailed": "{{count}} échoué(s)", - "summaryPassed": "{{count}} réussi(s)", - "summaryRunning": "{{count}} en cours", + "stepUpdated": "Étape de workflow mise à jour", + "summaryAdvisory_one": "", + "summaryAdvisory_other": "", + "summaryFailed_one": "", + "summaryFailed_other": "", + "summaryPassed_one": "", + "summaryPassed_other": "", + "summaryRunning_one": "", + "summaryRunning_other": "", "summarySeparator": " · ", - "summarySkipped": "{{count}} ignoré(s)", + "summarySkipped_one": "", + "summarySkipped_other": "", + "summaryStepCount_one": "", + "summaryStepCount_other": "", "switchToMarkdown": "Basculer vers Markdown", "switchToPlain": "Basculer vers texte brut", - "tabMySteps": "Mes étapes de workflow ({{count}})", - "tabTemplates": "Modèles ({{count}})", + "tabMySteps_one": "", + "tabMySteps_other": "", + "tabTemplates_one": "", + "tabTemplates_other": "", "templateAdded": "Étape de workflow « {{name}} » ajoutée", - "useDefault": "Utiliser le défaut", - "waitingForOutput": "En attente de la sortie de l'agent…" + "useDefault": "Utiliser le défaut" + }, + "workflowColumns": { + "add": "", + "compositionBlocked": "", + "empty": "", + "moveDown": "", + "moveUp": "", + "nameLabel": "", + "newColumnName": "", + "nodeUnplaced": "", + "readOnlyHint": "", + "remove": "", + "title": "", + "traits": "", + "traitsLoadFailed": "", + "unplacedCount_one": "", + "unplacedCount_other": "" + }, + "workflowNodes": { + "advisory": "", + "failureCollect": "", + "failureFailFast": "", + "failurePolicy": "", + "gateBlocks": "", + "gateMode": "", + "joinAll": "", + "joinAny": "", + "joinMode": "", + "joinQuorum": "", + "mergeBoundaryNote": "", + "quorumN": "", + "releaseCapacity": "", + "releaseCondition": "", + "releaseDependency": "", + "releaseExternal": "", + "releaseManual": "", + "releaseTimer": "", + "splitNote": "" + }, + "workflows": { + "duplicateToCustomize": "", + "readOnlyBuiltin": "", + "saved": "", + "savedNotCompilable": "", + "saveFailed": "", + "selectOrCreate": "" + }, + "workflowSelector": { + "switchActiveMessage": "", + "switchActiveTitle": "", + "switchCancel": "", + "switchConfirm": "" }, "workspace": { "projectRoot": "Racine du projet", diff --git a/packages/i18n/locales/fr/cli.json b/packages/i18n/locales/fr/cli.json index 66128a96c9..9647ffe262 100644 --- a/packages/i18n/locales/fr/cli.json +++ b/packages/i18n/locales/fr/cli.json @@ -20,11 +20,12 @@ "agentRunId": "ID :", "agentRunLogsBackHint": "[Échap/q] retour aux exécutions", "agentRunLogsTitle": "Journaux d'exécution ({{index}})", + "agentsFooterHints": "[s] démarrer [x] arrêter [D] supprimer [r] actualiser [Tab] focus ↑↓ sélectionner", + "agentsListTitle_one": "", + "agentsListTitle_other": "", + "agentsNoAgents": "Aucun agent trouvé.", "agentStarted": "Agent démarré", "agentStopped": "Agent arrêté", - "agentsFooterHints": "[s] démarrer [x] arrêter [D] supprimer [r] actualiser [Tab] focus ↑↓ sélectionner", - "agentsListTitle": "Agents ({{count}})", - "agentsNoAgents": "Aucun agent trouvé.", "boardCreateTaskHints": "Entrée pour créer · Échap pour annuler", "boardCreateTaskNoProject": "Aucun projet sélectionné", "boardCreateTaskTitleEmpty": "Le titre ne peut pas être vide", @@ -33,6 +34,7 @@ "boardNewTaskProject": "Projet : {{name}}", "boardNewTaskTitle": "Nouvelle tâche", "boardNewTaskTitleLabel": "Titre", + "boardOtherReadOnlyHint": "", "copiedSuccess": "✓ Copié !", "copyFailed": "✗ Échec de la copie", "expandedLogHeader": "Entrée {{index}}/{{total}} · [Entrée/Échap] fermer · [c] copier", @@ -43,26 +45,27 @@ "filesEmpty": "(vide)", "filesEmptyFile": "(fichier vide)", "filesFooterHints": "[Tab] changer de volet [↑↓/jk] déplacer [Entrée] ouvrir [←/→] réduire/développer [.] fichiers cachés [w] retour à la ligne [p] projet [r] recharger", - "filesMoreLines": "… {{count}} ligne(s) de plus", + "filesMoreLines_one": "", + "filesMoreLines_other": "", "filesSelectProject": "Sélectionner un projet", "filesSelectToPreview": "Sélectionnez un fichier à prévisualiser", "filesTooLarge": "{{size}} — [trop volumineux pour être prévisualisé]", "filesUnableToRead": "Impossible de lire le fichier", - "gitFetchFailed": "Échec de la récupération : {{output}}", "gitFetched": "Récupéré", + "gitFetchFailed": "Échec de la récupération : {{output}}", "gitFetching": "Récupération…", "gitFooterHints": "[r] actualiser {{push}}[F] récupérer [↑↓] lignes [←→] statut▸branches{{worktrees}}▸commits▸modifications [p] projet [Échap/s] retour", "gitNoCommits": "Aucun commit", "gitNoProject": "Aucun projet", "gitPushDismissHint": "[Échap] fermer", "gitPushFailed": "Échec de la poussée", + "gitPushingToOrigin": "Poussée vers origin/{{branch}}", "gitPushModalAhead": "en avance", "gitPushModalBranch": "Branche :", "gitPushModalCommits": "Commits à pousser (du plus ancien au plus récent) :", "gitPushModalHints": "[Entrée] pousser [Échap] annuler", "gitPushModalTitle": "Pousser vers le distant", "gitPushSuccessful": "Poussée réussie", - "gitPushingToOrigin": "Poussée vers origin/{{branch}}", "gitRefreshing": "actualisation", "gitWorkingTreeClean": "Arbre de travail propre", "headerHelpQuitHint": "[?] aide [q] quitter", @@ -117,14 +120,13 @@ "projectSelectorChangeHint": "[p] changer", "projectSelectorLabel": "Projet :", "projectSelectorNavHints": "↑↓ naviguer · Entrée sélectionner · Échap annuler", - "projectSelectorNoProjects": "(aucun projet enregistré)", "projectSelectorNone": "(aucun)", + "projectSelectorNoProjects": "(aucun projet enregistré)", "projectSelectorPickTitle": "Choisir un projet", "qrCloseHint": "[Échap] fermer", "qrGenerating": "Génération du QR…", "qrNoTunnelRunning": "Aucun tunnel distant n'est en cours d'exécution. Démarrez-en un dans les paramètres (g).", "qrOverlayTitle": "Accès distant — Scannez pour vous connecter", - "quit": "Quitter", "readyIn": "Prêt en {{secs}} s", "runLogNone": "Aucun log capturé pour cette exécution.", "runLogResult": "résultat :", @@ -137,16 +139,6 @@ "runStatusFailed": "Échoué", "runStatusTerminated": "Terminé de force", "runStatusUnknown": "Inconnu", - "settingAutoMerge": "Fusion automatique", - "settingEnginePaused": "Moteur en pause", - "settingGlobalPause": "Pause globale", - "settingMaxConcurrent": "Concurrence max", - "settingMaxWorktrees": "Arborescences max", - "settingMergeStrategy": "Stratégie de fusion", - "settingPollIntervalMs": "Intervalle de sondage (ms)", - "settingRemoteActiveProvider": "Fournisseur distant", - "settingRemoteShortLivedEnabled": "Jetons de courte durée", - "settingRemoteShortLivedTtlMs": "TTL de courte durée (ms)", "settingsActivatedProvider": "Fournisseur activé : {{provider}}", "settingsAdjust1": "[+/-] ajuster de 1", "settingsAdjust5000ms": "[+/-] ajuster de 5000 ms", @@ -161,7 +153,8 @@ "settingsFooterHints": "[Tab] changer de panneau ↑↓ sélectionner le paramètre [Espace] basculer bool [+/-] ajuster num [←/→] cycle enum [C/V/X/P/L/U/K/R] actions distantes", "settingsInteractivePanelTitle": "Paramètres", "settingsLoadingSettings": "Chargement des paramètres…", - "settingsMoreModels": "… et {{count}} de plus", + "settingsMoreModels_one": "", + "settingsMoreModels_other": "", "settingsPanelTitle": "Paramètres", "settingsPersistentTokenRegenerated": "Jeton persistant régénéré", "settingsQrFetched": "Payload QR récupéré", diff --git a/packages/i18n/locales/fr/common.json b/packages/i18n/locales/fr/common.json index 697fdd85fa..3e950dc4b7 100644 --- a/packages/i18n/locales/fr/common.json +++ b/packages/i18n/locales/fr/common.json @@ -4,8 +4,65 @@ "close": "Fermer", "save": "Enregistrer" }, + "agents": { + "ratings": { + "trendDeclining": "", + "trendImproving": "", + "trendInsufficient": "", + "trendStable": "" + }, + "reflections": { + "triggerManual": "", + "triggerPeriodic": "", + "triggerPostTask": "", + "triggerUserRequested": "" + }, + "time": { + "daysAgo_one": "", + "daysAgo_other": "", + "hoursAgo_one": "", + "hoursAgo_other": "", + "inAMoment": "", + "inDays_one": "", + "inDays_other": "", + "inHours_one": "", + "inHours_other": "", + "inMinutes_one": "", + "inMinutes_other": "", + "justNow": "", + "minutesAgo_one": "", + "minutesAgo_other": "" + } + }, "archive": "Archiver", + "board": { + "rejection": { + "capacityExhausted": "", + "guardRejected": "", + "mergeBlocked": "", + "unknownColumn": "", + "workflowMismatch": "" + } + }, "cancel": "Annuler", + "chat": { + "failedToGetResponse": "", + "failureReferenceId": "", + "failureReferenceKind": "", + "failureReferenceLabel": "", + "failureReferenceMetaLabel": "", + "openMailboxMessage": "", + "toolCallArgsPrefix": "", + "toolCallResultPrefix": "", + "toolCallStatusCompleted": "", + "toolCallStatusError": "", + "toolCallStatusErrors": "", + "toolCallStatusRunning": "", + "toolCallsCount_one": "", + "toolCallsCount_other": "", + "toolCallsHeader": "", + "viewFailureDetails": "" + }, "close": "Fermer", "columns": { "archived": "Archivé", @@ -16,8 +73,162 @@ "triage": "Planification" }, "delete": "Supprimer", + "health": { + "anomaly": { + "duplicateActiveId": "", + "idInBothStorages": "", + "sequenceOverlap": "", + "unknownPrefix": "" + } + }, + "inline": { + "connecting": "", + "error": "", + "offline": "", + "online": "" + }, + "merge": { + "unknown": "" + }, + "missions": { + "autopilotStateActivating": "", + "autopilotStateCompleting": "", + "autopilotStateInactive": "", + "autopilotStateUnknown": "", + "autopilotStateWatching": "", + "interviewStatusAwaitingInput": "", + "interviewStatusComplete": "", + "interviewStatusError": "", + "interviewStatusGenerating": "", + "runHelperActive": "", + "runHelperBlocked": "", + "runHelperPlanning": "" + }, + "models": { + "messages": { + "modelSetTo": "", + "modelSetToDefault": "" + } + }, + "nodeStatus": { + "connecting": "", + "error": "", + "offline": "", + "online": "", + "unknown": "" + }, + "nodes": { + "auth": { + "differ": "", + "differProviders": "", + "match": "", + "notSynced": "" + }, + "status": { + "connecting": "", + "creating": "", + "deleting": "", + "error": "", + "exited": "", + "offline": "", + "online": "", + "recreating": "", + "running": "", + "stopped": "" + } + }, "refresh": "Actualiser", + "research": { + "providerGitHub": "", + "providerLlmSynthesis": "", + "providerLocalDocs": "", + "providerPageFetch": "", + "providerWebSearch": "" + }, "retry": "Réessayer", + "routing": { + "policyLabel": { + "block": "", + "fallback": "", + "notConfigured": "" + } + }, + "setup": { + "apiKeyFormatError": "", + "apiKeyLabel": { + "fallback": "", + "kimiCoding": "", + "minimax": "", + "ollama": "", + "openai": "", + "openrouter": "", + "zai": "" + }, + "apiKeyPlaceholder": { + "fallback": "", + "kimiCoding": "", + "minimax": "", + "zai": "" + }, + "apiKeyRequired": "", + "apiKeySetup": { + "fallback": "", + "kimiCoding": "", + "minimax": "", + "ollama": "", + "openai": "", + "openrouter": "", + "zai": "" + }, + "apiKeyUsage": { + "fallback": "", + "kimiCoding": "", + "minimax": "", + "ollama": "", + "openai": "", + "openrouter": "", + "zai": "" + }, + "providerDesc": { + "anthropic": "", + "fallback": "", + "gemini": "", + "google": "", + "kimi": "", + "kimiCoding": "", + "minimax": "", + "moonshot": "", + "ollama": "", + "openai": "", + "openaiCodex": "", + "openrouter": "", + "zai": "" + } + }, "skip": "Ignorer", - "tryAgain": "Réessayer" + "taskForm": { + "nodeStatusConnecting": "", + "nodeStatusError": "", + "nodeStatusOffline": "", + "nodeStatusOnline": "", + "phasePostMerge": "", + "phasePreMerge": "" + }, + "taskReview": { + "never": "", + "refreshSourceBackground": "", + "refreshSourceInitialLoad": "", + "refreshSourceManual": "" + }, + "tryAgain": "Réessayer", + "workflow": { + "postMerge": "", + "preMerge": "", + "statusAdvisory": "", + "statusFailed": "", + "statusPassed": "", + "statusRunning": "", + "statusSkipped": "", + "waitingForOutput": "" + } } diff --git a/packages/i18n/locales/fr/errors.json b/packages/i18n/locales/fr/errors.json index 608b4e6466..0967ef424b 100644 --- a/packages/i18n/locales/fr/errors.json +++ b/packages/i18n/locales/fr/errors.json @@ -1,4 +1 @@ -{ - "fetchProjectsFailed": "Échec de la récupération des projets", - "openTaskLogsFailed": "Échec de l'ouverture des journaux de tâche : {{detail}}" -} +{} diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 5dea2e2439..8c337823f0 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -16,7 +16,6 @@ "dismissOAuth": "OAuth 재로그인 배너 닫기", "done": "완료", "edit": "편집", - "generateInsights": "새 인사이트 생성", "no": "아니오", "openSettings": "설정 열기", "pull": "Pull", @@ -66,10 +65,13 @@ "notMerged": "병합되지 않음", "refresh": "새로고침", "time": { - "daysAgo": "{{count}}일 전", - "hoursAgo": "{{count}}시간 전", + "daysAgo_other": "", + "hoursAgo_other": "", "justNow": "방금", - "minutesAgo": "{{count}}분 전" + "minutesAgo_other": "", + "daysAgo_one": "", + "hoursAgo_one": "", + "minutesAgo_one": "" }, "title": "활동 로그" }, @@ -95,29 +97,33 @@ "hideToolCallsResults": "도구 호출 및 결과 숨기기", "hideToolOutput": "도구 출력 숨기기", "live": "실시간", - "loadMore": "더 불러오기", "loading": "에이전트 로그를 불러오는 중…", "loadingMore": "불러오는 중…", + "loadMore": "더 불러오기", "markdown": "Markdown", "plain": "일반 텍스트", "planning": "계획 중", "reviewer": "검토자", "showFormattedMarkdown": "형식화된 마크다운 표시", + "showing": "{{total}}개 중 {{visible}}개 표시", "showOutput": "출력 표시", "showRawText": "원시 텍스트 표시", "showToolCallsResults": "도구 호출 및 결과 표시", "showToolOutput": "도구 출력 표시", - "showing": "{{total}}개 중 {{visible}}개 표시", "switchMarkdown": "마크다운 모드로 전환", "switchPlainText": "일반 텍스트 모드로 전환", - "timeDaysAgo": "{{count}}일 전", - "timeHoursAgo": "{{count}}시간 전", + "timeDaysAgo_other": "", + "timeHoursAgo_other": "", "timeJustNow": "방금", - "timeMinutesAgo": "{{count}}분 전", - "toolEntriesHidden": "도구 항목 {{count}}개 숨김", + "timeMinutesAgo_other": "", + "toolEntriesHidden_other": "", "toolsOff": "도구: 끔", "toolsOn": "도구: 켬", - "usingDefault": "기본값 사용 중" + "usingDefault": "기본값 사용 중", + "timeDaysAgo_one": "", + "timeHoursAgo_one": "", + "timeMinutesAgo_one": "", + "toolEntriesHidden_one": "" }, "agentMention": { "membersOf": "#{{roomName}} 멤버", @@ -239,15 +245,6 @@ "promptDefault": "기본값: {{preview}}", "templateName": "예: My Custom Executor" }, - "roles": { - "custom": "사용자 정의 에이전트", - "engineer": "엔지니어 에이전트", - "executor": "실행기 에이전트", - "merger": "병합기 에이전트", - "reviewer": "검토자 에이전트", - "scheduler": "스케줄러 에이전트", - "triage": "분류 에이전트" - }, "sections": { "builtinTemplates": "기본 제공 템플릿", "customTemplates": "사용자 정의 템플릿" @@ -281,7 +278,7 @@ }, "agents": { "activate": "활성화", - "activeAgents": "활성 에이전트 ({{count}})", + "activeAgents_other": "", "activePrefix": "활성: ", "advancedSettingsDesc": "이 에이전트의 하위 수준 설정 옵션.", "advancedSettingsTitle": "고급 설정", @@ -291,15 +288,15 @@ "agentMail": "에이전트 메일", "agentModelLabel": "에이전트 모델", "agentPlural": "에이전트들", + "agentsFound_other": "", "agentSingular": "에이전트", - "agentSoulLabel": "에이전트 소울", - "agentsFound": "{{count}}개 에이전트{{plural}} 찾음", "agentsLabel": "에이전트", + "agentSoulLabel": "에이전트 소울", "aiInterview": "AI 인터뷰", "allChangesSaved": "모든 변경사항이 저장되었습니다", - "allTime": "전체 기간", "allowParallelExecution": "병렬 실행 허용", "allowParallelExecutionHint": "이 에이전트가 여러 하트비트를 동시에 실행하도록 허용합니다.", + "allTime": "전체 기간", "alreadyOnDefault": "이미 기본값입니다", "applyPreset": "프리셋 적용", "assignedSkills": "할당된 스킬", @@ -330,12 +327,11 @@ "bulkActions": "일괄 작업", "bulkActionsLoadFailed": "일괄 에이전트 작업 불러오기 실패: {{error}}", "bulkAgentActions": "에이전트 일괄 작업", - "bulkConfirmMessage": "{{count}}개 에이전트를 {{action}}하시겠습니까?", + "bulkConfirmMessage_other": "", "bulkNoEligible": "해당 에이전트 없음", - "bulkResult": "{{count}}개 에이전트 {{action}}됨", - "bulkResultWithFailures": "{{count}}개 에이전트 {{action}}됨, {{failed}}개 실패", "bulkResult_one": "{{agentWord}} {{successCount}}개 {{action}}; {{skippedCount}}개 건너뜀", "bulkResult_other": "{{agentWord}} {{successCount}}개 {{action}}; {{skippedCount}}개 건너뜀", + "bulkResultWithFailures": "{{count}}개 에이전트 {{action}}됨, {{failed}}개 실패", "bundleDescription": "이 에이전트의 코드 번들 관리 방식을 설정합니다.", "bundleEntryFileHint": "관리되는 번들의 진입 파일.", "bundleEntryFileLabel": "진입 파일", @@ -381,9 +377,9 @@ "copyId": "ID 복사", "create": "만들기", "createAgent": "에이전트 만들기", + "created": "에이전트 \"{{name}}\"이(가) 생성되었습니다", "createError": "에이전트 만들기 실패: {{error}}", "createSuccess": "에이전트 \"{{name}}\"이(가) 생성되었습니다", - "created": "에이전트 \"{{name}}\"이(가) 생성되었습니다", "creating": "만드는 중...", "creatingAgent": "에이전트 만드는 중...", "currentAgent": "현재 에이전트", @@ -400,12 +396,12 @@ "delete": "삭제", "deleteAgent": "에이전트 삭제", "deleteConfirm": "이 에이전트를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", + "deleted": "에이전트가 삭제되었습니다", "deleteError": "에이전트 삭제 실패: {{error}}", "deleteFailed": "에이전트 삭제 실패", "deleteMessage": "에이전트 \"{{name}}\"을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", "deleteSuccess": "에이전트 \"{{name}}\"이(가) 삭제되었습니다", "deleteTitle": "에이전트 삭제", - "deleted": "에이전트가 삭제되었습니다", "deletionNotAvailable": "에이전트가 실행 중일 때는 삭제할 수 없습니다.", "deletionPermanent": "에이전트와 모든 관련 데이터가 영구적으로 삭제됩니다.", "details": "세부 정보", @@ -473,6 +469,7 @@ "healthError": "오류", "heartbeat": "하트비트:", "heartbeatAndHealth": "하트비트 및 상태", + "heartbeatClampedToMin_other": "", "heartbeatCustom": "커스텀 하트비트 실행", "heartbeatEnabled": "하트비트 활성화", "heartbeatEnabledHint": "이 에이전트를 예약된 하트비트로 실행할 수 있도록 허용합니다.", @@ -483,12 +480,12 @@ "heartbeatFileLoadFailed": "하트비트 파일을 불러오지 못했습니다", "heartbeatFilePlaceholder": "하트비트 절차 내용...", "heartbeatFilePreviewMode": "미리보기 모드", - "heartbeatFileSaveFailed": "하트비트 파일을 저장하지 못했습니다", "heartbeatFileSaved": "하트비트 파일이 저장되었습니다", + "heartbeatFileSaveFailed": "하트비트 파일을 저장하지 못했습니다", "heartbeatIntervalHint": "하트비트가 실행되는 빈도를 초 단위로 설정합니다.", "heartbeatIntervalLabel": "하트비트 간격 (초)", - "heartbeatIntervalUpdateFailed": "하트비트 간격을 업데이트하지 못했습니다: {{error}}", "heartbeatIntervalUpdated": "{{name}}의 하트비트 간격이 {{interval}}(으)로 업데이트되었습니다", + "heartbeatIntervalUpdateFailed": "하트비트 간격을 업데이트하지 못했습니다: {{error}}", "heartbeatMustBeNumber": "하트비트 간격은 유효한 숫자여야 합니다", "heartbeatMustBePositive": "하트비트 간격은 0보다 커야 합니다", "heartbeatOverdue": "하트비트 {{elapsed}} 지연", @@ -512,8 +509,8 @@ "heartbeatSpeedPreset": "하트비트 속도 프리셋", "heartbeatSpeedSaveFailed": "하트비트 배율을 저장하지 못했습니다: {{error}}", "heartbeatSpeedSet": "하트비트 속도가 ×{{value}}로 설정되었습니다", - "heartbeatStartFailed": "하트비트를 시작하지 못했습니다", "heartbeatStarted": "하트비트가 시작되었습니다", + "heartbeatStartFailed": "하트비트를 시작하지 못했습니다", "heartbeatTimeoutHint": "하트비트 실행이 종료되기 전까지 허용되는 최대 시간(초)입니다.", "heartbeatTimeoutLabel": "하트비트 타임아웃 (초)", "heartbeatUpgradeFailed": "하트비트 절차를 업그레이드하지 못했습니다", @@ -527,16 +524,16 @@ "importButton": "{{label}} 가져오기", "importComplete": "가져오기 완료", "importDescription": "Agent Companies 패키지에서 에이전트를 가져옵니다. companies.sh 카탈로그에서 공개된 에이전트를 찾아보고, AGENTS.md 파일을 업로드하거나, 디렉터리를 선택하거나, 매니페스트 내용을 붙여넣으세요.", - "importingAgents": "에이전트 {{count}}개{{plural}} 가져오는 중...", + "importingAgents_other": "", "importingAgentsAndSkills": "에이전트 {{agentCount}}개{{agentPlural}}와 스킬 {{skillCount}}개{{skillPlural}} 가져오는 중...", - "importingSkills": "스킬 {{count}}개{{plural}} 가져오는 중...", - "inProgress": "진행 중", + "importingSkills_other": "", "inbox": "받은 편지함", - "inheritProjectDefault": "프로젝트 기본값 상속", "inheritingProjectDefault": "프로젝트 기본값을 상속 중", + "inheritProjectDefault": "프로젝트 기본값 상속", "inlineMemoryFieldHint": "이 메모리는 에이전트 컨텍스트에 직접 포함됩니다.", "inlineMemoryHint": "매 하트비트마다 주입되는 단기 메모리입니다.", "inlineMemoryLabel": "인라인 메모리", + "inProgress": "진행 중", "input": "입력", "inputTokens": "입력", "installs": "설치", @@ -544,15 +541,15 @@ "instructionsEmptyPreview": "아직 지침이 없습니다 — 편집 모드로 전환하여 추가하세요.", "instructionsFileEditorDesc": "연결된 지침 파일을 직접 편집합니다.", "instructionsFileEditorTitle": "지침 파일", - "instructionsFileSaveFailed": "지침 파일을 저장하지 못했습니다", "instructionsFileSaved": "지침 파일이 저장되었습니다", + "instructionsFileSaveFailed": "지침 파일을 저장하지 못했습니다", "instructionsHint": "이 지침은 에이전트가 받는 모든 프롬프트 앞에 추가됩니다.", "instructionsPathHint": "이 에이전트의 지침이 포함된 마크다운 파일 경로입니다.", "instructionsPathLabel": "지침 파일 경로", "instructionsPathPlaceholder": "/path/to/instructions.md", "instructionsPlaceholder": "이 에이전트의 지침을 입력하세요...", - "instructionsSaveFailed": "지침을 저장하지 못했습니다", "instructionsSaved": "지침이 저장되었습니다", + "instructionsSaveFailed": "지침을 저장하지 못했습니다", "instructionsTextPlaceholder": "커스텀 동작 지침을 추가하세요...", "instructionsTitle": "지침", "intentPrompt": "이 에이전트가 무엇을 수행하길 원하시나요?", @@ -574,7 +571,6 @@ "liveLogs": "실시간 로그", "liveRun": "실시간 실행", "loadError": "에이전트를 불러오지 못했습니다: {{error}}", - "loadTasksFailed": "작업을 불러오지 못했습니다", "loading": "에이전트 불러오는 중...", "loadingAgents": "에이전트 불러오는 중...", "loadingCompanies": "회사 목록 불러오는 중…", @@ -594,15 +590,16 @@ "loadingRuntimes": "런타임 불러오는 중...", "loadingSkillContent": "스킬 내용 불러오는 중...", "loadingTasks": "작업 불러오는 중...", - "logEntries": "로그 항목", + "loadTasksFailed": "작업을 불러오지 못했습니다", + "logEntries_other": "", "logsWillAppear": "에이전트가 실행되면 로그가 여기에 표시됩니다.", "logsWillAppearActive": "로그가 여기에 표시됩니다.", + "mailboxLoadFailed": "메일함을 불러오지 못했습니다", "mailFrom": "보낸 사람", "mailSent": "보낸 날짜", "mailTo": "받는 사람", "mailToLabel": "받는 사람", "mailType": "유형", - "mailboxLoadFailed": "메일함을 불러오지 못했습니다", "manifestContent": "매니페스트 내용", "manifestPlaceholder": "---\nname: CEO\ntitle: Chief Executive Officer\nreportsTo: null\nskills:\n - review\n---\nAgent instructions go here...", "maxConcurrentRunsHint": "동시에 실행할 수 있는 최대 하트비트 수입니다.", @@ -616,8 +613,8 @@ "memoryFileMeta": "{{size}}바이트 · {{date}} 업데이트됨", "memoryFilePlaceholder": "메모리 파일 내용...", "memoryFilePreviewMode": "미리보기 모드", - "memoryFileSaveFailed": "메모리 파일을 저장하지 못했습니다", "memoryFileSaved": "메모리 파일이 저장되었습니다", + "memoryFileSaveFailed": "메모리 파일을 저장하지 못했습니다", "memoryFilesHint": "에이전트의 메모리 레이어에 저장된 파일입니다.", "memoryFilesHintSuffix": "파일을 선택하여 내용을 보거나 편집하세요.", "memoryFilesLabel": "메모리 파일", @@ -630,8 +627,8 @@ "memoryLayerLongTermDesc": "세션 간에 유지되는 영구적인 사실과 지식입니다.", "memoryPlaceholder": "이 에이전트의 인라인 메모리를 입력하세요...", "memoryReadOnly": "읽기 전용", - "memorySaveFailed": "메모리를 저장하지 못했습니다", "memorySaved": "메모리가 저장되었습니다", + "memorySaveFailed": "메모리를 저장하지 못했습니다", "memoryTitle": "메모리", "memoryTooLong": "메모리 내용이 너무 깁니다", "messageResponseModeHint": "이 에이전트가 수신 메시지에 응답하는 시점입니다.", @@ -672,6 +669,7 @@ "noLogsForRun": "이 실행에 대한 로그가 없습니다", "noManager": "관리자 없음", "noMemoryFiles": "메모리 파일 없음", + "noneUsingBuiltIn": "없음 (기본 내장 사용)", "noOutboxMessages": "보낸 편지함에 메시지 없음", "noOutputCaptured": "캡처된 출력 없음", "noPausedEligible": "재개 가능한 일시정지된 에이전트 없음", @@ -684,11 +682,9 @@ "noSkillsInPackage": "패키지에 스킬 없음", "noTasksAssigned": "할당된 작업 없음", "noTokenUsageYet": "아직 토큰 사용 내역이 없습니다. 에이전트가 실행되면 토큰 합계가 여기에 표시됩니다.", - "noneUsingBuiltIn": "없음 (기본 내장 사용)", "notScheduled": "예약되지 않음", "notSelected": "선택되지 않음", "off": "끄기", - "onHeartbeat": "하트비트 시", "onboarding": { "applyDraftAgent": "에이전트 양식에 초안 적용", "applyDraftSettings": "설정 양식에 초안 적용", @@ -729,9 +725,9 @@ "updatedDraftReady": "업데이트된 초안이 검토 준비되었습니다", "yes": "예" }, + "onHeartbeat": "하트비트 시", "openDetails": "{{name}} 세부 정보 열기", "optional": "(선택 사항)", - "orPasteManifest": "또는 매니페스트 내용 붙여넣기", "orgChartCanvas": "조직도 캔버스", "orgChartCenter": "조직도 중앙 정렬", "orgChartEmployees": "{{name}} 직원", @@ -740,6 +736,7 @@ "orgChartView": "조직도 보기", "orgChartZoomIn": "조직도 확대", "orgChartZoomOut": "조직도 축소", + "orPasteManifest": "또는 매니페스트 내용 붙여넣기", "outbox": "보낸 편지함", "output": "출력", "outputTokens": "출력", @@ -750,13 +747,16 @@ "pauseAgentsFailed": "에이전트를 일시정지하지 못했습니다: {{error}}", "pauseAll": "모두 일시정지", "pauseAllAgents": "모든 에이전트 일시정지", + "pauseAllConfirm_other": "", "pauseAllTitle": "모든 에이전트 일시정지", - "pauseCountHint": "활성 에이전트 {{count}}개가 일시정지됩니다", "pauseCountHint_one": "활성/실행 중인 에이전트 {{count}}개 일시정지", "pauseCountHint_other": "활성/실행 중인 에이전트 {{count}}개 일시정지", + "pauseCountHint_one_other": "", + "pauseCountHint_other_other": "", "pausedPast": "일시정지됨", + "pausedSummary_other": "", "pendingApprovals": "승인 대기 중", - "pendingApprovalsCount": "{{count}}건 대기 중", + "pendingApprovalsCount_other": "", "performance": { "avgDuration": "평균 소요 시간", "noData": "아직 성능 데이터 없음", @@ -774,6 +774,7 @@ "preview": "미리보기", "promptSize": "프롬프트 크기", "promptSizeChart": "프롬프트 크기 차트", + "provideManifest": "", "ratings": { "addError": "평가를 추가하지 못했습니다: {{error}}", "addRating": "평가 추가", @@ -786,7 +787,7 @@ "categorySelect": "카테고리 선택...", "categorySpeed": "속도", "commentPlaceholder": "선택적 댓글...", - "count": "평가 {{count}}개", + "count_other": "", "deleteError": "평가를 삭제하지 못했습니다: {{error}}", "deleteRating": "평가 삭제", "deleteSuccess": "평가가 삭제되었습니다", @@ -794,14 +795,12 @@ "loadError": "평가를 불러오지 못했습니다: {{error}}", "loading": "평가 불러오는 중...", "noRatings": "아직 평가 없음", - "starCount": "별 {{count}}개", + "starCount_other": "", "submitRating": "평가 제출", "submitting": "제출 중...", "title": "사용자 평가", - "trendDeclining": "↓ 하락 중", - "trendImproving": "↑ 개선 중", - "trendInsufficient": "데이터 부족", - "trendStable": "→ 안정" + "count_one": "", + "starCount_one": "" }, "recentRuns": "최근 실행", "reflections": { @@ -818,18 +817,14 @@ "metricAvgDuration": "평균 소요 시간:", "metricErrors": "오류:", "metricFailed": "실패:", - "metricTasks": "작업:", "metrics": "지표", + "metricTasks": "작업:", "noReflections": "아직 성찰 없음", + "reflecting": "성찰 중...", "reflectNow": "지금 성찰", "reflectNowTitle": "수동 성찰 생성", - "reflecting": "성찰 중...", "sectionTitle": "성능, 성찰 및 평가", - "suggestedImprovements": "개선 제안", - "triggerManual": "수동", - "triggerPeriodic": "주기적", - "triggerPostTask": "작업 후", - "triggerUserRequested": "사용자 요청" + "suggestedImprovements": "개선 제안" }, "refresh": "새로 고침", "removeAvatar": "아바타 제거", @@ -842,19 +837,22 @@ "resetDayWeekly": "요일 (0=일요일)", "resetting": "초기화 중...", "result": "결과", - "resultCreated": "{{count}}개 생성됨", - "resultErrors": "{{count}}개 오류{{plural}}", - "resultSkipped": "{{count}}개 건너뜀 (이미 존재함)", + "resultCreated_other": "", + "resultErrors_other": "", + "resultSkipped_other": "", "resume": "재개", "resumeAction": "재개", "resumeAgentsFailed": "에이전트를 재개하지 못했습니다: {{error}}", "resumeAll": "모두 재개", "resumeAllAgents": "모든 에이전트 재개", + "resumeAllConfirm_other": "", "resumeAllTitle": "모든 에이전트 재개", - "resumeCountHint": "일시정지된 에이전트 {{count}}개가 재개됩니다", "resumeCountHint_one": "일시정지된 에이전트 {{count}}개 재개", "resumeCountHint_other": "일시정지된 에이전트 {{count}}개 재개", + "resumeCountHint_one_other": "", + "resumeCountHint_other_other": "", "resumedPast": "재개됨", + "resumedSummary_other": "", "retry": "재시도", "reviewConfiguration": "생성된 구성 검토", "reviewHint": "에이전트를 생성하기 전에 구성을 검토하세요.", @@ -868,20 +866,18 @@ "roleReviewer": "검토자", "roleScheduler": "스케줄러", "roleTriage": "트리아지", + "roleUpdated": "에이전트 역할이 {{role}}(으)로 업데이트되었습니다", "roleUpdateError": "역할 업데이트 실패: {{error}}", "roleUpdateFailed": "역할 업데이트 실패: {{error}}", "roleUpdateSuccess": "에이전트 역할이 {{role}}(으)로 업데이트되었습니다", - "roleUpdated": "에이전트 역할이 {{role}}(으)로 업데이트되었습니다", "runAriaLabel": "실행 {{id}}", "runDetailsFailed": "실행 세부 정보를 불러오지 못했습니다", "runMissedHeartbeat": "하트비트 누락", "runMissedHeartbeatHint": "에이전트가 예약된 하트비트를 놓치면 실행을 트리거합니다.", + "running": "실행 중", "runNow": "지금 실행", "runNowAria": "{{name}} 지금 실행", "runNowFor": "{{name}} 지금 실행", - "runStarted": "실행이 시작되었습니다", - "runStopped": "실행이 중지되었습니다", - "running": "실행 중", "runs": { "empty": "실행 기록 없음", "loading": "실행 목록 로드 중…", @@ -890,9 +886,11 @@ "stopMessage": "이 실행을 중지하시겠습니까?", "stopTitle": "실행 중지" }, - "runsCount": "{{count}}회 실행", + "runsCount_other": "", "runsSuccessRate": "성공률 {{rate}}%", + "runStarted": "실행이 시작되었습니다", "runsToday": "오늘 실행 횟수", + "runStopped": "실행이 중지되었습니다", "runtime": "런타임", "runtimeEmpty": "사용 가능한 플러그인 런타임 없음", "runtimeLabel": "런타임", @@ -925,29 +923,30 @@ "selectAllAgents": "모든 에이전트 선택", "selectAllSkills": "모든 스킬 선택", "selectAnAgent": "에이전트 선택", + "selectCompany": "", "selectDirectory": "디렉터리 선택", + "selected": "선택됨:", + "selectedAgentLabel_other": "", + "selectedSkillLabel_other": "", "selectMemoryFile": "메모리 파일 선택", "selectModel": "모델", "selectModelPlaceholder": "모델 선택…", "selectRuntime": "런타임 선택", "selectSkill": "스킬 {{name}} 선택", - "selected": "선택됨:", - "selectedAgentLabel": "에이전트 {{count}}개", - "selectedSkillLabel": "스킬 {{count}}개", "setHeartbeatAria": "{{name}}의 하트비트 간격 설정", - "settingsSaveFailed": "설정 저장 실패", "settingsSaved": "설정이 저장되었습니다", + "settingsSaveFailed": "설정 저장 실패", "setupModeAriaLabel": "에이전트 설정 모드", "showSystemAgents": "시스템 에이전트 표시", "skills": "스킬", "skillsDescription": "이 에이전트에서 사용할 수 있는 스킬을 관리합니다.", - "skillsErrors": "스킬 {{count}}개 오류{{pluralError}}", - "skillsFound": "스킬 {{count}}개 발견", + "skillsErrors_other": "", + "skillsFound_other": "", "skillsHint": "이 에이전트에 할당할 선택적 스킬", - "skillsImported": "스킬 {{count}}개 가져옴", + "skillsImported_other": "", "skillsNone": "할당된 스킬 없음", - "skillsSelected": "스킬 {{count}}개 선택됨", - "skillsSkipped": "스킬 {{count}}개 건너뜀 (이미 존재함)", + "skillsSelected_other": "", + "skillsSkipped_other": "", "skillsTitle": "스킬", "skipHeartbeatWhenIdle": "유휴 상태일 때 하트비트 건너뜀", "skipHeartbeatWhenIdleHint": "에이전트가 할 일이 없을 때 하트비트 실행을 방지합니다.", @@ -955,23 +954,23 @@ "soulEmptyPreview": "아직 소울이 없습니다 — 편집 모드로 전환하여 추가하세요.", "soulHint": "이 에이전트가 누구인지 설명하세요 — 성격, 어조, 가치관.", "soulPlaceholder": "이 에이전트의 소울을 설명하세요...", - "soulSaveFailed": "소울 저장 실패", "soulSaved": "소울이 저장되었습니다", + "soulSaveFailed": "소울 저장 실패", "soulTitle": "소울", "soulTooLong": "소울 내용이 너무 깁니다", "start": "시작", - "startOnboarding": "온보딩 시작", "starting": "시작 중...", + "startOnboarding": "온보딩 시작", "stateActive": "활성", "stateAll": "모든 상태", "stateError": "오류", "stateIdle": "유휴", "statePaused": "일시 정지", "stateRunning": "실행 중", + "stateUpdated": "에이전트 상태가 업데이트되었습니다", "stateUpdateError": "상태 업데이트 실패: {{error}}", "stateUpdateFailed": "에이전트 상태 업데이트 실패", "stateUpdateSuccess": "에이전트 상태가 {{state}}(으)로 업데이트되었습니다", - "stateUpdated": "에이전트 상태가 업데이트되었습니다", "status": "상태", "statusCount": "{{activeCount}}개 활성 · {{runningCount}}개 실행 중", "step": "단계 {{number}}{{total}}: {{name}}", @@ -1012,16 +1011,6 @@ "thinkingMinimal": "최소", "thinkingOff": "끄기", "throughput": "처리량", - "time": { - "daysAgo": "{{count}}일 전", - "hoursAgo": "{{count}}시간 전", - "inAMoment": "잠시 후", - "inDays": "{{count}}일 후", - "inHours": "{{count}}시간 후", - "inMinutes": "{{count}}분 후", - "justNow": "방금", - "minutesAgo": "{{count}}분 전" - }, "title": "에이전트", "titleLabel": "직함", "titlePlaceholder": "예) 시니어 엔지니어", @@ -1060,7 +1049,34 @@ "weekly": "주간", "workingOn": "작업 중", "zoomIn": "확대", - "zoomOut": "축소" + "zoomOut": "축소", + "activeAgents_one": "", + "agentsFound_one": "", + "bulkConfirmMessage_one": "", + "heartbeatClampedToMin_one": "", + "importingAgents_one": "", + "importingSkills_one": "", + "logEntries_one": "", + "pauseAllConfirm_one": "", + "pauseCountHint_one_one": "", + "pauseCountHint_other_one": "", + "pausedSummary_one": "", + "pendingApprovalsCount_one": "", + "resultCreated_one": "", + "resultErrors_one": "", + "resultSkipped_one": "", + "resumeAllConfirm_one": "", + "resumeCountHint_one_one": "", + "resumeCountHint_other_one": "", + "resumedSummary_one": "", + "runsCount_one": "", + "selectedAgentLabel_one": "", + "selectedSkillLabel_one": "", + "skillsErrors_one": "", + "skillsFound_one": "", + "skillsImported_one": "", + "skillsSelected_one": "", + "skillsSkipped_one": "" }, "app": { "backendError": { @@ -1070,11 +1086,12 @@ }, "approval": { "dismissBanner": "승인 알림 배너 닫기", - "needAttention": "{{count}}개의 승인 {{noun}}이 확인을 기다리고 있습니다", + "needAttention_other": "", "openMailbox": "메일함 열기", "requestPlural": "요청들", + "requests": "승인 요청", "requestSingular": "요청", - "requests": "승인 요청" + "needAttention_one": "" }, "auth": { "clearAndRetry": "토큰 초기화 후 재시도", @@ -1100,9 +1117,9 @@ "confirmMessage": "이 세션은 다른 탭에서 활성화되어 있습니다. 그래도 여시겠습니까?", "confirmTitle": "활성 세션 열기", "dismissButton": "닫기", - "pillLabel": "AI {{count}}", - "pillTitle": "백그라운드 AI 작업 {{count}}개", - "pillTitleWithInput": "백그라운드 AI 작업 {{count}}개 ({{needsInput}}개 입력 필요)", + "pillLabel_other": "", + "pillTitle_other": "", + "pillTitleWithInput_other": "", "popoverHeader": "백그라운드 작업", "status": { "activeElsewhere": "다른 탭에서 활성화됨", @@ -1116,17 +1133,29 @@ "planning": "계획", "sliceInterview": "슬라이스 인터뷰", "subtask": "하위 작업 분류" - } + }, + "pillLabel_one": "", + "pillTitle_one": "", + "pillTitleWithInput_one": "" }, "board": { "archived": "보관됨", "done": "완료", "inProgress": "진행 중", "inReview": "검토 중", + "rejection": { + "capacityExhausted": "", + "guardRejected": "", + "mergeBlocked": "", + "promoteRejected": "", + "unknownColumn": "", + "workflowMismatch": "" + }, "todo": "할 일", "triage": "트리아지" }, "branchGroup": { + "abandonGroup": "", "autoMergeEnabled": "자동 병합 사용", "collapseLabel": "브랜치 그룹 접기", "completionText": "{{total}}명 중 {{landed}}명 완료", @@ -1185,13 +1214,8 @@ "failedToCreateSession": "채팅 세션 생성 실패", "failedToDeleteConversation": "대화 삭제 실패", "failedToDeleteRoom": "방 삭제 실패", - "failedToGetResponse": "응답을 가져오지 못했습니다", "failedToSendRoomMessage": "방 메시지 전송 실패", "failureDetails": "실패 세부 정보", - "failureReferenceId": "ID", - "failureReferenceKind": "종류", - "failureReferenceLabel": "참조", - "failureReferenceMetaLabel": "레이블", "helpMessageContent": "사용 가능한 명령어:\n- `/new` 또는 `/clear` — 대화를 초기화하고 새로 시작\n- `/skill:{name}` — 특정 스킬 사용\n- `/help` — 이 도움말 표시", "jumpToLatest": "최신", "latest": "최신", @@ -1222,14 +1246,13 @@ "noRoomsYet": "아직 방이 없습니다.", "noSkillsAvailable": "사용 가능한 스킬 없음", "noSkillsFound": "스킬을 찾을 수 없습니다", - "openMailboxMessage": "메일함 메시지 열기", "openQuickChat": "빠른 채팅 열기", "queuedMessage": "대기 중: {{preview}}", "quickChatTitle": "빠른 채팅", - "relativeTimeDays": "{{count}}일 전", - "relativeTimeHours": "{{count}}시간 전", + "relativeTimeDays_other": "", + "relativeTimeHours_other": "", "relativeTimeJustNow": "방금", - "relativeTimeMinutes": "{{count}}분 전", + "relativeTimeMinutes_other": "", "removeAttachment": "{{name}} 첨부 파일 제거", "resizePanelBottom": "아래에서 패널 크기 조정", "resizePanelBottomLeft": "왼쪽 아래 모서리에서 패널 크기 조정", @@ -1242,7 +1265,7 @@ "resizeSidebar": "채팅 사이드바 크기 조정", "responseCopied": "응답이 복사되었습니다", "responseFailed": "응답 실패", - "roomMemberCount": "구성원 {{count}}명", + "roomMemberCount_other": "", "roomsGroupLabel": "방", "scopeDirect": "다이렉트", "scopeRooms": "방", @@ -1269,20 +1292,17 @@ "thinking": "생각 중", "thinkingLabel": "생각 중", "thinkingStatus": "생각 중…", - "toolCallArgsPrefix": "인수", - "toolCallResultPrefix": "결과", - "toolCallStatusCompleted": "완료됨", - "toolCallStatusError": "오류", - "toolCallStatusErrors": "오류들", - "toolCallStatusRunning": "실행 중", "toolCalls": "도구 호출", - "toolCallsCount": "도구 호출 {{count}}회", - "toolCallsHeader": "도구 호출", + "toolCallsCount_other": "", "typeMessage": "메시지를 입력하세요...", "unreadMessages": "읽지 않은 메시지", "untitledSession": "제목 없음", - "viewFailureDetails": "실패 세부 정보 보기", - "you": "나" + "you": "나", + "relativeTimeDays_one": "", + "relativeTimeHours_one": "", + "relativeTimeMinutes_one": "", + "roomMemberCount_one": "", + "toolCallsCount_one": "" }, "chatRooms": { "error": { @@ -1295,8 +1315,8 @@ "cli": { "installBody": "터미널 어디서든 Fusion을 사용할 수 있도록 {{fn}} 및 {{fusion}} 명령어를 설치하세요. 아래 버튼을 클릭하거나 명령어를 셸에 복사하세요.", "installButton": "npm으로 설치", - "installTitle": "Fusion CLI 설치", "installing": "설치 중…", + "installTitle": "Fusion CLI 설치", "openSettings": "설정 열기", "updateButton": "npm으로 업데이트", "updateTitle": "Fusion CLI 업데이트", @@ -1312,8 +1332,8 @@ "failedExit": "설치 실패 (종료 코드: {{code}})", "heading": "CLI 바이너리", "help": "전역 CLI를 설치하면 어느 터미널에서나 fn 및 fusion을 실행할 수 있습니다. 자동화 및 스크립트는 npx로도 작동하지만, 전역 설치가 더 빠르고 편리합니다.", - "installWithNpm": "npm으로 설치", "installing": "설치 중…", + "installWithNpm": "npm으로 설치", "notOnPath": "fn 또는 fusion이 PATH에서 발견되지 않았습니다.", "orCopyLabel": "또는 직접 복사하여 실행:", "refresh": "새로 고침", @@ -1329,9 +1349,9 @@ "actionsTitle": "열 작업", "archiveAllDoneAriaLabel": "완료된 모든 작업 보관", "archiveAllDoneTitle": "완료된 모든 작업 보관", - "archiveAllMessage": "완료된 작업 {{count}}개를 모두 보관하시겠습니까?", + "archiveAllMessage_other": "", "archiveAllTitle": "완료된 모든 작업 보관", - "archivedTasks": "{{count}}개 작업 보관됨", + "archivedTasks_other": "", "autoMerge": "자동 병합", "autoMergeDisabled": "자동 병합 비활성화됨", "autoMergeEnabled": "자동 병합 활성화됨", @@ -1342,26 +1362,28 @@ "expandArchivedTitle": "보관된 작업 펼치기", "failedToArchive": "작업 보관에 실패했습니다", "keepProgress": "진행 상황 유지", - "loadMore": "{{count}}개 더 보기 ({{remaining}}개 남음)", + "loadMore_other": "", "moveAllToTodo": "모두 할 일로 이동", - "moveAllToTodoMessage": "{{columnLabel}}의 작업 {{count}}개를 모두 할 일로 이동하시겠습니까?{{plural}}", + "moveAllToTodoMessage_other": "", "moveAllToTodoTitle": "모두 할 일로 이동", + "movedToPlanning_other": "", + "movedToTodo_other": "", "movePartialFailure": "{{total}}개 중 {{moved}}개 이동됨; {{failed}}개 실패", - "moveToTodoHint": "작업 {{count}}개를 할 일로 이동{{plural}}", + "moveToTodoHint_other": "", "moveToTodoPartialFailure": "{{total}}개 중 {{moved}}개를 할 일로 이동함; {{failed}}개 실패", - "movedToPlanning": "{{count}}개 작업을 재계획을 위해 계획 중으로 이동했습니다{{plural}}", - "movedToTodo": "{{count}}개 작업을 할 일로 이동했습니다{{plural}}", "newTask": "새 작업", "noManuallyPausableTasks": "수동으로 일시 중지 가능한 작업이 없습니다", "noTasks": "작업 없음", "noTasksInColumn": "이 열에 작업이 없습니다", - "pauseHint": "활성 미할당 작업 {{count}}개 일시 중지{{plural}}", + "pauseHint_other": "", "preserveProgressMessage": "이 작업에는 완료된 단계가 있습니다. 이동하기 전에 진행 상황을 유지하시겠습니까?", "preserveProgressMoveTodoMessage": "일부 작업에 완료된 단계가 있습니다. 할 일로 이동하기 전에 진행 상황을 유지하시겠습니까?", "preserveProgressTitle": "진행 상황을 유지하시겠습니까?", + "promote": "", + "promoting": "", "replanAll": "모두 재계획", - "replanAllHint": "작업 {{count}}개를 계획 중으로 이동{{plural}}", - "replanAllMessage": "할 일 작업 {{count}}개를 모두 재계획을 위해 계획 중으로 이동하시겠습니까?{{plural}}", + "replanAllHint_other": "", + "replanAllMessage_other": "", "replanAllTitle": "모든 작업 재계획", "resetProgress": "진행 상황 초기화", "resetProgressConfirm": "진행 상황 초기화", @@ -1369,10 +1391,22 @@ "resetProgressMoveTodoMessage": "할 일로 이동하기 전에 작업의 단계 진행 상황을 초기화하시겠습니까?", "resetProgressTitle": "진행 상황을 초기화하시겠습니까?", "stopAll": "모두 중지", - "stopAllMessage": "{{columnLabel}}의 작업 {{count}}개를 모두 중지하시겠습니까?{{plural}}", + "stopAllMessage_other": "", "stopAllTitle": "모든 작업 중지", "stopPartialFailure": "{{total}}개 중 {{paused}}개 중지됨; {{failed}}개 실패", - "stoppedTasks": "{{count}}개 작업 중지됨{{plural}}" + "stoppedTasks_other": "", + "archiveAllMessage_one": "", + "archivedTasks_one": "", + "loadMore_one": "", + "moveAllToTodoMessage_one": "", + "movedToPlanning_one": "", + "movedToTodo_one": "", + "moveToTodoHint_one": "", + "pauseHint_one": "", + "replanAllHint_one": "", + "replanAllMessage_one": "", + "stopAllMessage_one": "", + "stoppedTasks_one": "" }, "comments": { "addButton": "댓글 추가", @@ -1387,7 +1421,8 @@ "updatedSuccess": "댓글이 업데이트되었습니다" }, "commit": { - "filesChanged": "변경된 파일 ({{count}})" + "filesChanged_other": "", + "filesChanged_one": "" }, "commitDiff": { "error": "커밋 차이 로드 오류: {{error}}", @@ -1398,6 +1433,7 @@ "noSha": "사용 가능한 커밋 SHA가 없습니다." }, "common": { + "archive": "", "back": "뒤로", "cancel": "취소", "close": "닫기", @@ -1419,11 +1455,13 @@ "save": "저장", "saveAndTest": "저장 및 테스트", "saving": "저장 중...", + "skip": "", "somethingWentWrong": "이 뷰를 로드하는 중 오류가 발생했습니다.", "stop": "중지", "test": "테스트", "testing": "테스트 중…", "total": "전체", + "tryAgain": "", "unableToLoadData": "데이터를 불러올 수 없습니다", "unknown": "알 수 없음", "unsavedChanges": "저장되지 않은 변경 사항", @@ -1436,8 +1474,8 @@ "messagePlaceholder": "메시지를 입력하세요…", "newMessageTitle": "새 메시지", "noAgentsAvailable": "사용 가능한 에이전트 없음", - "replyTitle": "답장", "replyingToLabel": "답장 대상:", + "replyTitle": "답장", "selectAgent": "에이전트 선택…", "sendingButton": "전송 중…", "toLabel": "받는 사람:", @@ -1464,30 +1502,19 @@ "createRoom": { "create": "방 만들기", "creating": "만드는 중...", - "duplicate": "같은 이름의 방이 이미 존재합니다.", "failedCreate": "방 만들기에 실패했습니다.", "failedLoadAgents": "에이전트를 불러오지 못했습니다.", "loadingAgents": "에이전트 로드 중...", - "lowercase": "소문자만 사용하세요.", - "maxLength": "방 이름은 최대 80자까지 가능합니다.", "members": "멤버", "nameLabel": "방 이름", - "nameRequired": "방 이름은 필수입니다.", "noAgents": "이 프로젝트에 아직 에이전트가 없습니다.", - "noEdgeChars": "방 이름은 하이픈이나 밑줄로 시작하거나 끝날 수 없습니다.", "noMatch": "검색과 일치하는 에이전트가 없습니다.", "searchAgents": "에이전트 검색", "selectMember": "최소 한 명의 멤버를 선택하세요.", - "title": "방 만들기", - "validChars": "소문자, 숫자, 하이픈, 밑줄만 사용하세요." + "title": "방 만들기" }, "dashboard": { "initializingDashboard": "대시보드 초기화 중...", - "loaderSteps": { - "project": "프로젝트 선택", - "projects": "프로젝트 로드 중", - "tasks": "작업 가져오는 중" - }, "loadingMessage": "Fusion 대시보드 로드 중", "loadingProgress": "대시보드 로딩 진행 상황", "updatingMessage": "Fusion 대시보드 업데이트 중", @@ -1537,15 +1564,16 @@ "filterBySeverity": "심각도별 로그 필터", "info": "정보", "lines": "{{count}}줄", - "loadOlderLogs": "이전 로그 불러오기", + "lines_other": "", "loading": "로드 중...", "loadingConfig": "개발 서버 설정 로드 중...", "loadingLogs": "로그 로드 중…", "loadingOlderLogs": "이전 로그 로드 중…", + "loadOlderLogs": "이전 로그 불러오기", "logs": "로그", "lostConnection": "로그 스트림 연결이 끊겼습니다.", "manual": "수동", - "matchCount": "{{count}}개 일치", + "matchCount_other": "", "newLogs": "새 로그", "noLogsYet": "아직 로그가 없습니다. 개발 서버를 시작하면 출력이 표시됩니다.", "noMatchesSearch": "검색과 일치하는 로그 줄이 없습니다.", @@ -1593,7 +1621,9 @@ "started": "개발 서버가 시작되었습니다.", "stopped": "개발 서버가 중지되었습니다." }, - "warn": "경고" + "warn": "경고", + "lines_one": "", + "matchCount_one": "" }, "dirPicker": { "ariaLabel": "디렉터리 브라우저", @@ -1712,7 +1742,7 @@ "clearSearch": "검색 지우기", "collapse": "접기", "collapseContent": "내용 접기", - "docCount": "{{count}}개 문서{{plural}}", + "docCount_other": "", "documentsCreatedIn": "문서는 작업 상세 탭에서 만들어집니다.", "expand": "펼치기", "expandContent": "내용 펼치기", @@ -1732,7 +1762,7 @@ "plain": "일반 텍스트", "projectFiles": "프로젝트 파일", "projectFilesTab": "프로젝트 파일", - "resultCount": "{{count}}개 결과{{plural}}", + "resultCount_other": "", "retry": "다시 시도", "retryLoading": "문서 다시 불러오기", "searchProjectFiles": "프로젝트 마크다운 파일 검색…", @@ -1748,7 +1778,9 @@ "taskDocuments": "작업 문서", "taskDocumentsTab": "작업 문서", "title": "문서", - "untitled": "제목 없음" + "untitled": "제목 없음", + "docCount_one": "", + "resultCount_one": "" }, "droidCli": { "active": "활성", @@ -1811,28 +1843,33 @@ }, "executor": { "blocked": "차단됨", - "daysAgo": "{{count}}일 전", + "daysAgo_other": "", "escalated": "에스컬레이션됨", "escalatedSuffix": " (에스컬레이션됨)", "hideProjectDir": "프로젝트 디렉터리 숨기기", - "hoursAgo": "{{count}}시간 전", + "hoursAgo_other": "", "inReview": "검토 중", "justNow": "방금 전", "loading": "불러오는 중...", - "minutesAgo": "{{count}}분 전", + "minutesAgo_other": "", "noActivity": "활동 없음", - "overlapBottleneck": "{{status}} 중복 병목 {{blockerId}}: blockedBy를 통해 {{count}}개 todo 차단됨 (임계값 {{threshold}})", + "overlapBottleneck_other": "", "overlapQueue": "중복 대기열", "queued": "대기 중", "running": "실행 중", - "secondsAgo": "{{count}}초 전", + "secondsAgo_other": "", "showProjectDir": "프로젝트 디렉터리 보기", "stateIdle": "유휴", "statePaused": "일시 중지됨", "stateRunning": "실행 중", "status": "실행기 상태", "stuck": "중단됨", - "temporary": "임시" + "temporary": "임시", + "daysAgo_one": "", + "hoursAgo_one": "", + "minutesAgo_one": "", + "overlapBottleneck_one": "", + "secondsAgo_one": "" }, "fileBrowser": { "back": "파일 목록으로 돌아가기", @@ -1906,8 +1943,8 @@ "advancesHelpItem2": "reachable / subsumed / orphaned / superseded — 이미 처리되었습니다 (동등한 내용이 이미 반영되었거나, 원본 SHA가 사라졌거나, HEAD가 이미 재작성된 통합 끝점에 맞춰진 경우 포함).", "advancesHelpItem3": "pending + off / not run — 자동 동기화가 설정에서 비활성화되어 있습니다. 브랜치 ref는 이동했지만 작업 트리가 따라가지 않았습니다.", "advancesHelpItem4": "pending + stash-failed / would-conflict / 유사 — 자동 동기화를 시도했지만 조정할 수 없었습니다 (로컬 편집이 새 커밋과 충돌하는 경우가 일반적).", - "advancesNeedAction": "{{count}}개 조치 필요", - "aheadOfUpstream": "업스트림보다 {{count}}개 커밋 앞서 있음", + "advancesNeedAction_other": "", + "aheadOfUpstream_other": "", "aligned": "정렬됨", "apply": "적용", "applyStashKeep": "스태시 적용 (유지)", @@ -1923,7 +1960,7 @@ "backToIssuesList": "이슈 목록으로 돌아가기", "backToPullsList": "풀 리퀘스트 목록으로 돌아가기", "baseHead": "기준: HEAD", - "behindUpstream": "업스트림보다 {{count}}개 커밋 뒤처져 있음", + "behindUpstream_other": "", "branchLabel": "브랜치:", "cancel": "취소", "capturedAt": "캡처 시각:", @@ -1936,16 +1973,16 @@ "commentLast": "마지막:", "commit": "커밋", "commitMessagePlaceholder": "커밋 메시지...", - "commitStagedChanges": "스테이지된 변경 사항 커밋", "commitsOnBranch": "{{name}}의 커밋", - "commitsToPull": "{{count}}개 풀 필요", - "commitsToPush": "{{count}}개 푸시 필요", - "commitsToPushHeader": "푸시할 커밋 ({{count}})", + "commitStagedChanges": "스테이지된 변경 사항 커밋", + "commitsToPull_other": "", + "commitsToPush_other": "", + "commitsToPushHeader_other": "", "committedHash": "커밋됨: {{hash}}", + "conflictedCount_other": "", "conflictReclaimFailed": "충돌 복구 대기 등록 실패", "conflictReclaimQueued": "충돌 복구 대기 등록됨", "conflictReclaimUnavailable": "충돌 복구를 사용할 수 없습니다", - "conflictedCount": "{{count}}개 충돌", "conflictsButton": "충돌", "copiedButton": "복사됨", "copiedLabel": "{{label}} 복사됨", @@ -1962,9 +1999,9 @@ "couldNotLoadIssues": "이슈를 불러올 수 없습니다", "couldNotLoadPulls": "풀 리퀘스트를 불러올 수 없습니다", "create": "만들기", + "createdBranch": "브랜치 {{name}} 생성됨", "createPrButton": "PR 만들기", "createPrTitle": "이 작업의 PR 만들기", - "createdBranch": "브랜치 {{name}} 생성됨", "defaultBadge": "기본", "deleteBranch": "삭제", "deleteBranchMessage": "브랜치 \"{{name}}\"을(를) 삭제하시겠습니까?", @@ -1972,10 +2009,10 @@ "deletedBranch": "브랜치 {{name}} 삭제됨", "detectingRemotes": "감지 중…", "diffColon": "diff:", - "discardChangesMessage": "{{count}}개 파일의 변경 사항을 버리시겠습니까? 이 작업은 취소할 수 없습니다.", + "discardChangesMessage_other": "", "discardChangesTitle": "변경 사항 버리기", + "discardedFiles_other": "", "discardSelected": "선택 항목 버리기", - "discardedFiles": "{{count}}개 파일의 변경 사항 버려짐", "dismiss": "닫기", "dismissPrError": "PR 오류 닫기", "dropStash": "스태시 삭제", @@ -2011,9 +2048,9 @@ "fetch": "페치", "fetchCompleted": "페치 완료", "fetchFailed": "페치 실패", + "fetchingFromGitHub": "GitHub에서 최신 목록을 가져오는 중입니다.", "fetchLabel": "페치:", "fetchUrlLabel": "페치 URL", - "fetchingFromGitHub": "GitHub에서 최신 목록을 가져오는 중입니다.", "filterBranches": "브랜치 필터...", "filterByLabelsLabel": "레이블로 필터", "filterByLabelsPlaceholder": "필터: bug,enhancement…", @@ -2025,24 +2062,22 @@ "forceDeletedBranch": "브랜치 {{name}} 강제 삭제됨", "fullShaAbbrev": "전체", "ghAuthLoginHint": "PR 생성을 활성화하려면 {{code}}을(를) 실행하세요.", - "headAheadOfIntegration": "HEAD가 {{branch}}에 없는 {{count}}개 커밋을 가지고 있습니다", - "headAheadOfOriginIntegration": "HEAD가 origin/{{branch}}에 없는 {{count}}개 커밋을 가지고 있습니다", + "headAheadOfIntegration_other": "", + "headAheadOfOriginIntegration_other": "", "headVsIntegration": "HEAD vs {{branch}}", "headVsOriginIntegration": "HEAD vs origin/{{branch}}", "hide": "숨기기", "hideExplanation": "설명 숨기기", "import": "가져오기", + "imported": "가져옴", + "importedCount_other": "", "importFromGitHub": "GitHub에서 가져오기", "importSubtitle": "감지된 원격을 선택하고 열린 이슈 또는 풀 리퀘스트를 불러온 뒤 보드로 가져오세요.", "importTypeAriaLabel": "가져오기 유형", - "imported": "가져옴", - "importedCount": "{{count}}개 가져옴", - "integrationAheadOfHead": "{{branch}}가 HEAD에 없는 {{count}}개 커밋을 가지고 있습니다", - "issueCount": "{{count}}개 이슈", + "integrationAheadOfHead_other": "", + "issueCount_other": "", "load": "불러오기", "loadFromRepoAriaLabel": "저장소에서 {{tab}} 불러오기", - "loadMoreCommits": "커밋 더 불러오기", - "loadTabTitle": "{{tab}} 불러오기", "loading": "불러오는 중…", "loadingAriaLabel": "{{tab}} 불러오는 중", "loadingCommits": "커밋 불러오는 중...", @@ -2051,23 +2086,25 @@ "loadingPulls": "열린 풀 리퀘스트 불러오는 중…", "loadingStashDiff": "스태시 diff 불러오는 중…", "loadingTitle": "불러오는 중…", - "localAheadOfOriginIntegration": "로컬 {{branch}}가 origin/{{branch}}보다 {{count}}개 커밋 앞서 있습니다", - "localBehindOriginIntegration": "로컬 {{branch}}가 origin/{{branch}}보다 {{count}}개 커밋 뒤처져 있습니다", + "loadMoreCommits": "커밋 더 불러오기", + "loadTabTitle": "{{tab}} 불러오기", + "localAheadOfOriginIntegration_other": "", + "localBehindOriginIntegration_other": "", "localVsOrigin": "로컬 {{branch}} vs origin", "manualPrFlowHint": "이 작업에 대해 PR 우선 완료를 실행하려면 하단 작업을 사용하세요.", "mergeBadge": "병합", "mergeConflictDetected": "병합 충돌이 감지되었습니다. 브랜치를 해결/리베이스한 후 복구를 다시 시도하세요.", + "mergedTaskDone": "병합됨 — 작업이 완료로 이동됨", "mergeLabel": "병합", "mergePrButton": "풀 리퀘스트 병합", "mergeStrategyMerge": "merge", "mergeStrategyRebase": "rebase", "mergeStrategySquash": "squash", - "mergedTaskDone": "병합됨 — 작업이 완료로 이동됨", "mergingPrHint": "풀 리퀘스트 병합 중…", "mergingStatus": "병합 중…", "modalTitle": "Git 관리자", "modified": "수정됨", - "modifiedCount": "{{count}}개 수정됨", + "modifiedCount_other": "", "newBranchName": "새 브랜치 이름", "noAheadCommitsFound": "앞선 커밋을 찾을 수 없습니다 (먼저 페치가 필요할 수 있습니다)", "noBranchesFound": "브랜치를 찾을 수 없습니다", @@ -2081,9 +2118,7 @@ "noMatchingBranches": "일치하는 브랜치 없음", "noMatchingCommits": "일치하는 커밋 없음", "noOpenIssues": "열린 이슈가 없습니다", - "noOpenIssuesFound": "열린 이슈를 찾을 수 없습니다", "noOpenPulls": "열린 풀 리퀘스트가 없습니다", - "noOpenPullsFound": "열린 풀 리퀘스트를 찾을 수 없습니다", "noOriginTracking": "origin 추적 없음", "noPullSelected": "선택된 풀 리퀘스트 없음", "noPullSelectedHint": "목록에서 풀 리퀘스트를 선택하여 세부 정보를 확인하세요.", @@ -2097,14 +2132,14 @@ "noStagedChanges": "스테이지된 변경 사항 없음", "noStagedChangesToCommit": "커밋할 스테이지된 변경 사항 없음", "noStashes": "스태시 없음", - "noUnstagedChanges": "언스테이지된 변경 사항 없음", + "nothingLoadedInstructions": "저장소를 선택하고 불러오기를 클릭하여 가져오기 후보 검토를 시작하세요.", + "nothingLoadedYet": "아직 불러온 항목 없음", "notOnIntegrationBranch": "({{branch}}에 없음)", "notOnIntegrationBranchBtn": "통합 브랜치에 있지 않음 ({{branch}})", "notOnIntegrationBranchTitle": "현재 비통합 브랜치에 있습니다", - "nothingLoadedInstructions": "저장소를 선택하고 불러오기를 클릭하여 가져오기 후보 검토를 시작하세요.", - "nothingLoadedYet": "아직 불러온 항목 없음", + "noUnstagedChanges": "언스테이지된 변경 사항 없음", "openPullsFrom": "{{remote}}의 열린 풀 리퀘스트", - "originIntegrationAheadOfHead": "origin/{{branch}}가 HEAD에 없는 {{count}}개 커밋을 가지고 있습니다", + "originIntegrationAheadOfHead_other": "", "pop": "팝", "popStashTitle": "스태시 팝 (적용 후 삭제)", "prAuthUnavailable": "PR 인증 불가 — 'gh auth login'을 실행하세요", @@ -2116,34 +2151,34 @@ "summary": "{{passing}}개 통과, {{failing}}개 실패, {{pending}}개 대기 중", "viewDetails": "세부 정보 보기" }, - "prMergeFailed": "풀 리퀘스트 병합 실패", + "previewHeading": "미리보기", + "previewIssueMeta": "이슈 #{{number}}", + "previewPullMeta": "풀 리퀘스트 #{{number}}", "prMerged": "풀 리퀘스트 병합됨", + "prMergeFailed": "풀 리퀘스트 병합 실패", + "projectRootNotAvailable": "프로젝트 루트 경로를 사용할 수 없습니다", "prRefreshFailed": "PR 새로 고침 실패", "prStatusRefreshed": "PR 상태 새로 고쳐짐", "prUnlinkConfirm": "이 작업에서 PR #{{number}}의 연결을 해제하시겠습니까? PR은 닫히지 않습니다.", "prUnlinked": "PR #{{number}} 연결 해제됨", - "previewHeading": "미리보기", - "previewIssueMeta": "이슈 #{{number}}", - "previewPullMeta": "풀 리퀘스트 #{{number}}", - "projectRootNotAvailable": "프로젝트 루트 경로를 사용할 수 없습니다", "pull": "풀", "pullCompleted": "풀 완료", - "pullCount": "{{count}}개 풀 리퀘스트", + "pullCount_other": "", "pullFailed": "풀 실패", "pullOptions": "풀 옵션", "pullOptionsMenu": "풀 옵션 메뉴", "pullRebase": "Pull --rebase", "pullRebaseCompleted": "Pull --rebase 완료", "pullRequestHeading": "풀 리퀘스트", - "pullRequestsCount": "{{count}}개 풀 리퀘스트", + "pullRequestsCount_other": "", "push": "푸시", "pushCompleted": "푸시 완료", "pushFailed": "푸시 실패", "pushLabel": "푸시:", "pushUrlLabel": "푸시 URL", - "reCheckConflicts": "충돌 재확인", "recentCommitsOnRemote": "{{remote}}의 최근 커밋", "recentIntegrationAdvances": "최근 통합 브랜치 진행 내역", + "reCheckConflicts": "충돌 재확인", "refresh": "새로 고침", "refreshPrStatus": "PR 상태 새로 고침", "refreshToCheckMerge": "PR 상태를 새로 고쳐 병합 준비 여부를 확인하세요", @@ -2176,24 +2211,24 @@ "sectionStashes": "스태시", "sectionStatus": "상태", "sectionWorktrees": "작업 트리", + "selectedRemote": "선택된 원격", "selectFileToViewDiff": "파일을 선택하여 diff 보기", "selectIssueAriaLabel": "이슈 #{{number}} 선택", "selectPullAriaLabel": "풀 리퀘스트 #{{number}} 선택", "selectRemoteAriaLabel": "Git 원격 선택", "selectRemotePlaceholder": "원격 선택…", "selectRemoteToViewDetails": "세부 정보를 보려면 원격을 선택하세요", - "selectedRemote": "선택된 원격", "sidebarAriaLabel": "Git 관리자 섹션", "stageAll": "모두 스테이지", "stageAllAndCommit": "모두 스테이지 후 커밋", "stageAllAndCommitTitle": "전체 스테이징 및 커밋", - "stageCount": "스테이징 ({{count}})", + "stageCount_other": "", + "staged": "스테이징됨", + "stagedChanges_other": "", + "stagedCount_other": "", + "stagedFiles_other": "", "stageFile": "파일 스테이징", "stageSelected": "선택 항목 스테이징", - "staged": "스테이징됨", - "stagedChanges": "스테이징된 변경 사항 ({{count}})", - "stagedCount": "{{count}}개 스테이징됨", - "stagedFiles": "{{count}}개 파일 스테이징됨", "staleIndexWarning": "오래된 인덱스가 감지되었습니다. HEAD가 앞으로 이동했지만(주로 Fusion의 merger가 통합 브랜치 ref를 업데이트했기 때문) 인덱스는 이전 팁을 반영하고 있어 `git status`가 새 커밋을 \"스테이징된 변경 사항\"으로 반전하여 표시합니다. 설정에서 mergeAdvanceAutoSync를 활성화하면 merger가 자동으로 조정하거나, git reset --hard HEAD를 실행하여 수동으로 앞으로 이동할 수 있습니다.", "stash": "스태시", "stashApplied": "스태시 적용됨", @@ -2220,17 +2255,17 @@ "statusLabelWorkingTree": "작업 트리", "switchedToBranch": "{{name}}(으)로 전환됨", "sync": "동기화", + "synced": "동기화됨", + "syncedWithOrigin": "origin과 동기화됨 (pull --rebase + push)", + "syncedWorktreeToIntegrationTip": "워크트리를 로컬 통합 팁에 동기화됨", "syncFailed": "동기화 실패", + "syncing": "동기화 중…", "syncLocalTip": "로컬 팁 동기화", "syncLocalTipTitle": "작업 트리를 로컬 통합 팁에 동기화 (배너 Pull과 동일)", "syncOriginTitle": "origin에서 pull --rebase 후 현재 브랜치 push", "syncWithOriginFailed": "origin과 동기화 실패", "syncWorkingTree": "작업 트리 동기화", "syncWorkingTreeTitle": "통합 브랜치를 작업 트리로 pull (커밋되지 않은 편집을 자동 스태시 후 복원)", - "synced": "동기화됨", - "syncedWithOrigin": "origin과 동기화됨 (pull --rebase + push)", - "syncedWorktreeToIntegrationTip": "워크트리를 로컬 통합 팁에 동기화됨", - "syncing": "동기화 중…", "tabIssues": "이슈", "tabPullRequests": "Pull Request", "tip": "팁", @@ -2239,14 +2274,14 @@ "unlinkButton": "연결 해제", "unresolvedMergeConflicts": "해결되지 않은 병합 충돌", "unstageAll": "전체 스테이징 취소", - "unstageCount": "스테이징 취소 ({{count}})", + "unstageCount_other": "", + "unstaged": "스테이징 취소됨", + "unstagedChanges_other": "", + "unstagedFiles_other": "", "unstageFile": "파일 스테이징 취소", "unstageSelected": "선택 항목 스테이징 취소", - "unstaged": "스테이징 취소됨", - "unstagedChanges": "스테이징 취소된 변경 사항 ({{count}})", - "unstagedFiles": "{{count}}개 파일 스테이징 취소됨", "untracked": "추적되지 않음", - "untrackedCount": "{{count}}개 추적되지 않음", + "untrackedCount_other": "", "upToDate": "최신 상태", "view": "보기", "viewOnGithub": "GitHub에서 보기", @@ -2256,18 +2291,48 @@ "workingTreeModified": "수정됨", "worktreeBadgeBare": "bare", "worktreeBadgeMain": "main", - "worktreesInUse": "{{count}}개 사용 중", - "worktreesTotal": "총 {{count}}개" + "worktreesInUse_other": "", + "worktreesTotal_other": "", + "advancesNeedAction_one": "", + "aheadOfUpstream_one": "", + "behindUpstream_one": "", + "commitsToPull_one": "", + "commitsToPush_one": "", + "commitsToPushHeader_one": "", + "conflictedCount_one": "", + "discardChangesMessage_one": "", + "discardedFiles_one": "", + "headAheadOfIntegration_one": "", + "headAheadOfOriginIntegration_one": "", + "importedCount_one": "", + "integrationAheadOfHead_one": "", + "issueCount_one": "", + "localAheadOfOriginIntegration_one": "", + "localBehindOriginIntegration_one": "", + "modifiedCount_one": "", + "originIntegrationAheadOfHead_one": "", + "pullCount_one": "", + "pullRequestsCount_one": "", + "stageCount_one": "", + "stagedChanges_one": "", + "stagedCount_one": "", + "stagedFiles_one": "", + "unstageCount_one": "", + "unstagedChanges_one": "", + "unstagedFiles_one": "", + "untrackedCount_one": "", + "worktreesInUse_one": "", + "worktreesTotal_one": "" }, "goals": { - "activeCount": "활성 목표 {{count}}개", + "activeCount_other": "", "addGoal": "목표 추가", "archive": "보관", "capError": "목표는 5개까지만 활성화할 수 있습니다. 다른 목표를 활성화하려면 기존 활성 목표를 해결하세요.", "capWarning": "활성 목표 5개 한도에 가까워지고 있습니다. 활성 목표를 집중적으로 유지하세요.", "createError": "현재 목표를 생성할 수 없습니다. 다시 시도해 주세요.", - "draftWithAi": "AI로 초안 작성", "drafting": "초안 작성 중…", + "draftWithAi": "AI로 초안 작성", "emptyState": "목표가 없습니다. 전략적 결과 추적을 시작하려면 목표를 추가하세요.", "labelDescription": "설명", "labelTitle": "제목", @@ -2278,9 +2343,11 @@ "title": "목표", "titleRequired": "제목은 필수입니다.", "unarchive": "보관 해제", - "updateError": "현재 목표 상태를 업데이트할 수 없습니다. 다시 시도해 주세요." + "updateError": "현재 목표 상태를 업데이트할 수 없습니다. 다시 시도해 주세요.", + "activeCount_one": "" }, "groupTask": { + "abandonGroup": "", "ariaLabel": "브랜치 그룹 세부 정보", "autoMergeEnabled": "자동 병합 활성화됨", "completionText": "{{total}}개 중 {{landed}}개 완료", @@ -2290,12 +2357,15 @@ "mergeIntoMain": "그룹을 main에 병합", "openPR": "PR 열기", "openTask": "작업 열기", + "prClosed": "", + "prMerged": "", "sharedBranch": "공유 브랜치", "status": "상태", "title": "브랜치 그룹 {{id}}", "unavailable": "브랜치 그룹을 사용할 수 없습니다." }, "header": { + "activePlanningSessions_other": "", "addFirstScript": "첫 번째 스크립트 추가", "additionalHeaderActions": "추가 헤더 작업", "agentsView": "에이전트 보기", @@ -2321,7 +2391,7 @@ "localNode": "로컬", "mailbox": "메일함", "mailboxView": "메일함 보기", - "mailboxWithCount": "메일함 ({{count}})", + "mailboxWithCount_other": "", "manageProjects": "프로젝트 관리", "manageScripts": "스크립트 관리...", "memoryView": "메모리", @@ -2330,10 +2400,10 @@ "moreHeaderActions": "추가 헤더 작업", "moreViews": "더 많은 보기", "noBaseBranch": "베이스 브랜치 없음", + "nodes": "노드", "noScriptsAddOne": "스크립트 없음 — 추가하세요…", "noScriptsConfigured": "구성된 스크립트 없음", "noWorkingBranch": "작업 브랜치 없음", - "nodes": "노드", "openSearch": "검색 열기", "openTerminal": "터미널 열기", "pauseTriage": "트리아지 일시 중지", @@ -2343,7 +2413,7 @@ "reliabilityView": "안정성", "researchView": "리서치", "resumePlanningSession": "계획 세션 재개", - "resumePlanningSessionCount": "계획 세션 재개 ({{count}})", + "resumePlanningSessionCount_other": "", "resumeScheduling": "스케줄링 재개", "scripts": "스크립트", "scriptsSubmenu": "스크립트 하위 메뉴", @@ -2364,21 +2434,19 @@ "terminal": "터미널", "todosView": "할 일", "unreadChatResponse": "읽지 않은 채팅 응답", - "unreadMessages": "읽지 않은 메시지 {{count}}개", + "unreadMessages_other": "", "viewActivityLog": "활동 로그 보기", "viewProjects": "프로젝트 보기", "viewUsage": "사용량 보기", "workflowSteps": "워크플로 단계", - "workingBranch": "작업 브랜치" + "workingBranch": "작업 브랜치", + "activePlanningSessions_one": "", + "mailboxWithCount_one": "", + "resumePlanningSessionCount_one": "", + "unreadMessages_one": "" }, "health": { "activeTasks": "활성 작업", - "anomaly": { - "duplicateActiveId": "중복된 활성 작업 ID", - "idInBothStorages": "활성 및 보관 저장소 모두에 작업 ID 존재", - "sequenceOverlap": "할당자 다음 시퀀스가 기존 작업 ID와 겹침", - "unknownPrefix": "작업 행이 할당자 상태 외부의 접두사 사용" - }, "anomalyBody": "Fusion이 작업 ID가 재사용되거나 실시간 작업 레코드를 덮어쓸 수 있는 할당자 상태를 발견했습니다.", "anomalyDetected": "작업 ID 무결성 이상 감지됨", "completed": "완료됨", @@ -2436,26 +2504,22 @@ "collapse": "접기", "collapseDescription": "설명 접기", "collapseTaskOptions": "고급 작업 옵션 접기", - "connecting": "연결 중", "creating": "생성 중...", "custom": "사용자 정의", "deps": "의존성", "editingDescription": "설명 편집 중", "enableBrowserVerification": "브라우저 검증 워크플로 단계 활성화", "enterDescriptionFirst": "먼저 설명을 입력하세요.", - "error": "오류", "expand": "펼치기", "expandDescription": "설명 펼치기", "expandTaskOptions": "고급 작업 옵션 펼치기", "hintEnterEsc": "Enter로 생성 · Esc로 취소", "loadingAgents": "에이전트 불러오는 중...", - "model": "모델", + "model_other": "", "models": "모델", "noAgentsAvailable": "사용 가능한 에이전트 없음", - "noExistingTasks": "기존 작업 없음", "node": "노드", - "offline": "오프라인", - "online": "온라인", + "noExistingTasks": "기존 작업 없음", "openPlanningMode": "현재 설명으로 계획 모드 열기", "plan": "계획", "preset": "프리셋", @@ -2469,45 +2533,29 @@ "selectExecutionNode": "실행 노드 선택", "subtask": "하위 작업", "useDefault": "기본값 사용", - "whatNeedsToBeDone": "무엇을 해야 하나요?" + "whatNeedsToBeDone": "무엇을 해야 하나요?", + "model_one": "" }, "insights": { "allInsights": "모든 인사이트", "alreadyRunning": "인사이트 생성이 이미 실행 중입니다. 활성 실행을 표시합니다.", "alreadyRunningShort": "인사이트 생성이 이미 실행 중입니다.", - "archiveLabel": "이 인사이트 보관", - "archiveTitle": "이 인사이트 보관", "archived": "\"{{title}}\" 보관됨", "archivedMsg": "인사이트 보관됨: {{title}}", + "archiveLabel": "이 인사이트 보관", + "archiveTitle": "이 인사이트 보관", "archiving": "\"{{title}}\" 보관 중...", "backlogHealth": "백로그 상태", - "category": { - "architecture": "아키텍처", - "competitive_analysis": "경쟁 분석", - "dependency": "의존성", - "documentation": "문서화", - "features": "기능", - "other": "기타", - "performance": "성능", - "quality": "품질", - "reliability": "안정성", - "research": "리서치", - "security": "보안", - "testability": "테스트 가능성", - "trends": "트렌드", - "ux": "사용자 경험", - "workflow": "워크플로" - }, "configureModel": "인사이트 생성 모델 구성", "configureModelTitle": "모델 구성", "createTaskLabel": "이 인사이트에서 작업 생성", "createTaskTitle": "이 인사이트에서 작업 생성", "creatingTask": "\"{{title}}\"에서 작업 생성 중...", - "dismissLabel": "이 인사이트 무시", - "dismissTitle": "이 인사이트 무시", "dismissed": "\"{{title}}\" 무시됨", "dismissedMsg": "인사이트 무시됨: {{title}}", "dismissing": "\"{{title}}\" 무시 중...", + "dismissLabel": "이 인사이트 무시", + "dismissTitle": "이 인사이트 무시", "failedToArchive": "인사이트 보관에 실패했습니다.", "failedToCreateTask": "작업 생성에 실패했습니다.", "failedToDismiss": "인사이트 무시에 실패했습니다.", @@ -2516,6 +2564,7 @@ "failedToUnarchive": "인사이트 보관 해제에 실패했습니다.", "generateDescription": "인사이트를 생성하여 프로젝트에 대한 AI 기반 권장 사항을 받으세요.", "generateFirst": "첫 번째 인사이트 생성", + "generateInsights": "", "generateInsightsBtn": "인사이트 생성", "generating": "생성 중...", "generatingInsights": "인사이트 생성 중...", @@ -2531,18 +2580,19 @@ "runCompleted": "{{created}}개 생성됨, {{updated}}개 업데이트됨", "showAllInsights": "모든 인사이트 표시", "showArchived": "보관된 인사이트 표시", - "showArchivedLabel": "보관됨 표시 ({{count}})", + "showArchivedLabel_other": "", "showBacklogHealth": "백로그 상태 인사이트만 표시", "taskCreated": "\"{{title}}\"에서 작업 생성됨", "taskCreatedMsg": "작업 생성됨: {{title}}", "taskCreationUnavailable": "이 보기에서는 작업 생성을 사용할 수 없습니다.", "title": "인사이트", - "unarchiveLabel": "이 인사이트 보관 해제", - "unarchiveTitle": "이 인사이트 보관 해제", "unarchived": "\"{{title}}\" 보관 해제됨", "unarchivedMsg": "인사이트 보관 해제됨: {{title}}", + "unarchiveLabel": "이 인사이트 보관 해제", + "unarchiveTitle": "이 인사이트 보관 해제", "unarchiving": "\"{{title}}\" 보관 해제 중...", - "usePlanningDefault": "계획 기본값 사용" + "usePlanningDefault": "계획 기본값 사용", + "showArchivedLabel_one": "" }, "interview": { "addContextDirection": "추가 컨텍스트나 방향을 입력하세요...", @@ -2570,8 +2620,8 @@ "preparingQuestion": "다음 질문 준비 중...", "progressText": "질문 {{progress}} / 약 6", "reconnecting": "재연결 중…", - "refineScope": "AI로 {{label}} 범위 정제", "refinedScope": "정제된 범위", + "refineScope": "AI로 {{label}} 범위 정제", "sendToBackground": "백그라운드로 보내기", "sessionActiveAnotherTab": "다른 탭에서 세션이 활성화되어 있습니다.", "showThinking": "생각 표시", @@ -2586,6 +2636,10 @@ "verificationCriteria": "검증 기준", "yes": "예" }, + "lane": { + "collapse": "", + "expand": "" + }, "listView": { "apply": "적용", "applying": "적용 중...", @@ -2593,16 +2647,15 @@ "archiveSelectedTitle": "완료된 선택 작업 보관", "archiveUnavailable": "보관 작업을 사용할 수 없습니다.", "archiveViaButton": "작업은 보관 버튼을 통해서만 보관할 수 있습니다.", - "bulkArchiveDone": "완료됨 {{count}}개 보관", - "bulkArchiveMessage": "선택한 작업 {{count}}개를 보관하시겠습니까?", + "bulkArchiveDone_other": "", + "bulkArchiveMessage_other": "", "bulkArchiveNoTasks": "보관할 수 있는 선택된 작업이 없습니다 (완료된 작업만 가능)", "bulkArchiveSummary": "{{archived}}개 보관됨 · {{skipped}}개 건너뜀 · {{failed}}개 실패", "bulkArchiveTitle": "선택된 작업 보관", "bulkDeleteAll": "전체 삭제", "bulkDeleteArchiveSummary": "{{archived}}개 보관됨, {{deleted}}개 삭제됨, {{failed}}개 실패", - "bulkDeleteMessage": "선택한 작업 {{count}}개를 삭제하시겠습니까?", + "bulkDeleteMessage_other": "", "bulkDeleteNoTasks": "삭제할 수 있는 선택된 작업이 없습니다 (보관된 작업 제외)", - "bulkDeleteSummary": "{{deleted}}개 작업 삭제됨 · {{skipped}}개 보관됨 건너뜀 · {{failed}}개 실패", "bulkDeleteSummary_one": "{{count}}개 작업 삭제됨 · {{skipped}}개 보관됨 건너뜀 · {{failed}}개 실패", "bulkDeleteSummary_other": "{{count}}개 작업 삭제됨 · {{skipped}}개 보관됨 건너뜀 · {{failed}}개 실패", "bulkDeleteTitle": "선택된 작업 삭제", @@ -2616,7 +2669,7 @@ "bulkUnpauseSummary": "{{unpaused}}개 재개됨 · {{skipped}}개 건너뜀 · {{failed}}개 실패", "bulkUpdateFailed": "모델 업데이트에 실패했습니다", "bulkUpdateNoTasks": "업데이트할 유효한 작업이 없습니다 (보관된 작업은 수정할 수 없습니다)", - "bulkUpdateSuccess": "작업 {{count}}개를 업데이트했습니다", + "bulkUpdateSuccess_other": "", "cancelMove": "이동 취소", "clear": "지우기", "clearColumnFilter": "열 필터 지우기", @@ -2636,7 +2689,7 @@ "filterChip": "필터: {{column}}", "forceDelete": "강제 삭제", "forceDeleteTitle": "작업 강제 삭제", - "hidden": "{{count}}개 숨김", + "hidden_other": "", "hideDone": "완료 숨기기", "hideDoneTitle": "완료된 작업 숨기기", "keepProgress": "진행률 유지", @@ -2646,18 +2699,18 @@ "listControlsLabel": "목록 컨트롤", "newTask": "+ 새 작업", "noChange": "변경 없음", - "noTasks": "작업 없음", - "noTasksMatch": "필터에 맞는 작업이 없습니다", - "noTasksYet": "아직 작업이 없습니다", "nodeOverrideLabel": "노드 재정의", "nodeStatusConnecting": "연결 중", "nodeStatusError": "오류", "nodeStatusOffline": "오프라인", "nodeStatusOnline": "온라인", + "noTasks": "작업 없음", + "noTasksMatch": "필터에 맞는 작업이 없습니다", + "noTasksYet": "아직 작업이 없습니다", + "pausedByAgent": "에이전트에 의해 일시정지됨", "pauseSelected": "선택 항목 일시정지", "pauseSelectedTitle": "아직 일시정지되지 않은 선택된 모든 작업을 일시정지합니다", "pauseUnavailable": "일시정지 작업을 사용할 수 없습니다", - "pausedByAgent": "에이전트에 의해 일시정지됨", "preserveProgressMessage": "이 작업에 완료된 단계가 있습니다. 이동하기 전에 진행률을 유지하시겠습니까?", "preserveProgressTitle": "진행률을 유지하시겠습니까?", "resetProgress": "진행률 초기화", @@ -2666,9 +2719,9 @@ "resizeSidebar": "작업 목록 사이드바 크기 조정", "reviewerModel": "검토 모델", "selectAll": "표시된 모든 작업 선택", + "selectedCount_other": "", "selectTask": "{{taskId}} 선택", "selectTaskPrompt": "작업을 선택하여 세부 정보를 확인하세요", - "selectedCount": "{{count}}개 선택됨", "showAll": "모두 표시", "showAllTitle": "모든 작업 표시", "showDone": "완료 표시", @@ -2677,8 +2730,8 @@ "staleOnlyTitle": "오래된 작업만 표시", "stalePausedReview": "오래된 일시정지 검토", "stalePausedReviewTitle": "오래된 일시정지 검토 작업만 표시", - "stats": "전체 {{total}}개 중 {{count}}개 작업", - "statsInColumn": "{{column}}의 전체 {{total}}개 중 {{count}}개 작업", + "stats_other": "", + "statsInColumn_other": "", "statusMergingFix": "수정 사항 병합 중…", "stuck": "막힘", "taskCreationUnavailable": "작업 생성을 사용할 수 없습니다", @@ -2686,12 +2739,18 @@ "unpauseSelectedTitle": "현재 일시정지된 선택된 작업을 재개합니다", "unpauseUnavailable": "재개 작업을 사용할 수 없습니다", "useProjectDefault": "프로젝트 기본값 사용", - "viewOptions": "보기 옵션" + "viewOptions": "보기 옵션", + "bulkArchiveDone_one": "", + "bulkArchiveMessage_one": "", + "bulkDeleteMessage_one": "", + "bulkUpdateSuccess_one": "", + "hidden_one": "", + "selectedCount_one": "", + "stats_one": "", + "statsInColumn_one": "" }, "mailbox": { "agent": "에이전트", - "agentById": "에이전트: {{id}}", - "agentByName": "에이전트: {{name}}", "agents": "에이전트", "agentsTab": "에이전트", "ago": "전", @@ -2702,8 +2761,8 @@ "approvalDeny": "거부", "approvalRequested": "요청됨", "approvalRequester": "요청자", - "approvalTask": "작업", "approvals": "승인", + "approvalTask": "작업", "back": "뒤로", "backButton": "← 뒤로", "closeAriaLabel": "닫기", @@ -2732,8 +2791,8 @@ "markAllRead": "모두 읽음으로 표시", "markAllReadButton": "모두 읽음으로 표시", "markAllReadTitle": "모두 읽음으로 표시", + "markedAsRead_other": "", "markReadFailed": "메시지를 읽음으로 표시하는 데 실패했습니다", - "markedAsRead": "메시지 {{count}}개를 읽음으로 표시했습니다", "messageDeleted": "메시지가 삭제되었습니다", "messageSent": "메시지를 보냈습니다", "noAgentMessages": "에이전트 간 메시지가 없습니다", @@ -2753,15 +2812,15 @@ "refreshTitle": "새로고침", "reply": "답장", "replyButton": "답장", - "replyLoadFailed": "답장한 메시지를 불러오는 데 실패했습니다. 다시 시도하려면 클릭하세요.", "replyingTo": "답장 대상", "replyingToMessage": "메시지에 답장 중", + "replyLoadFailed": "답장한 메시지를 불러오는 데 실패했습니다. 다시 시도하려면 클릭하세요.", "selectMessageToRead": "읽을 메시지를 선택하세요", "system": "시스템", - "timeDaysAgo": "{{count}}일 전", - "timeHoursAgo": "{{count}}시간 전", + "timeDaysAgo_other": "", + "timeHoursAgo_other": "", "timeJustNow": "방금", - "timeMinsAgo": "{{count}}분 전", + "timeMinsAgo_other": "", "title": "메일함", "to": "받는 사람", "toLabel": "받는 사람:", @@ -2771,8 +2830,11 @@ "typeSystem": "시스템", "typeUserToAgent": "나 → 에이전트", "user": "사용자", - "userLabel": "사용자: {{id}}", - "you": "나" + "you": "나", + "markedAsRead_one": "", + "timeDaysAgo_one": "", + "timeHoursAgo_one": "", + "timeMinsAgo_one": "" }, "memory": { "auditChecksTitle": "감사 검사", @@ -2789,32 +2851,32 @@ "capReadable": "읽기 가능", "capWritable": "쓰기 가능", "categories": "카테고리", - "charCount": "{{count}}자", + "charCount_other": "", "compactFailed": "메모리 압축에 실패했습니다", - "compactSelectedFile": "선택한 파일 압축", "compacting": "압축 중…", "compactionThresholdHint": "메모리가 이 글자 수를 초과하면 압축됩니다", "compactionThresholdLabel": "압축 임계값 (글자 수)", + "compactSelectedFile": "선택한 파일 압축", "currentBackendTitle": "현재 백엔드", "description": "작업 메모리, 장기 인사이트, 엔진 상태", "disabledMessage": "메모리가 현재 비활성화되어 있습니다. 이 자동화를 편집하려면 설정에서 메모리 도구를 활성화하세요.", + "dreaming": "드림 처리 중…", "dreamNow": "지금 드림 처리", "dreamNowHint": "드림 처리를 지금 수동으로 트리거합니다.", "dreamProcessingComplete": "드림 처리가 완료되었습니다", "dreamProcessingFailed": "드림 처리 실행에 실패했습니다", - "dreaming": "드림 처리 중…", "dreamsEnabledHint": "일일 메모를 DREAMS.md로 변환하고 재사용 가능한 교훈을 MEMORY.md로 승격합니다.", "dreamsEnabledLabel": "일일 메모리에서 드림 처리", "dreamsScheduleHint": "드림 처리의 크론 표현식입니다.", "dreamsScheduleLabel": "드림 일정", - "editRaw": "원본 편집", "editorDefaultDescription": "선택한 메모리 파일을 편집합니다.", "editorLabel": "메모리 편집기", - "extractInsightsFailed": "인사이트 추출에 실패했습니다", - "extractNow": "지금 추출", + "editRaw": "원본 편집", "extracting": "추출 중…", + "extractInsightsFailed": "인사이트 추출에 실패했습니다", "extractionFailed": "실패", "extractionSuccess": "성공", + "extractNow": "지금 추출", "fileCompacted": "메모리 파일이 압축되었습니다", "fileLabel": "메모리 파일", "fileSummary": "{{size}}바이트 · {{updatedAt}} 업데이트됨", @@ -2824,13 +2886,13 @@ "healthIssues": "문제 발견됨", "healthStatusTitle": "상태", "healthWarning": "경고", - "insightCount": "{{count}}개의 인사이트", - "insightsExtracted": "인사이트 {{count}}개 추출됨", + "insightCount_other": "", + "insightsExtracted_other": "", "insightsMemoryLabel": "인사이트 메모리", "insightsSaved": "인사이트가 저장되었습니다", + "installing": "설치 중…", "installQmd": "qmd 설치", "installQmdFailed": "qmd 설치에 실패했습니다", - "installing": "설치 중…", "lastExtractionLabel": "마지막 추출", "lastUpdated": "마지막 업데이트", "layerDaily": "일일", @@ -2854,9 +2916,9 @@ "qmdAvailableOnPath": "qmd를 PATH에서 사용할 수 있습니다.", "qmdChecking": "확인 중", "qmdCheckingAvailability": "qmd 가용성 확인 중…", + "qmdInstalled": "설치됨", "qmdInstallSuccess": "qmd가 성공적으로 설치되었습니다", "qmdInstallUnavailable": "qmd 설치가 완료되었지만 qmd를 여전히 사용할 수 없습니다", - "qmdInstalled": "설치됨", "qmdIntegrationTitle": "QMD 통합", "qmdNotInstalled": "qmd가 설치되어 있지 않습니다. 검색에 로컬 파일이 사용됩니다. 인덱싱된 검색을 설치하세요:", "qmdPathUsed": "사용된 qmd 경로", @@ -2875,7 +2937,7 @@ "saveSettingsFailed": "메모리 설정 저장에 실패했습니다", "saving": "저장 중…", "searchPlaceholder": "qmd로 메모리 검색", - "sectionCount": "{{count}}개 섹션", + "sectionCount_other": "", "settingsNote": "참고: 백엔드 유형 변경은", "settingsNoteLink": "설정 → 메모리", "settingsNoteToast": "설정 → 메모리를 열어 백엔드 유형을 변경하세요", @@ -2884,15 +2946,20 @@ "tabEngines": "엔진", "tabInsights": "인사이트", "tabWorking": "작업 메모리", + "testing": "테스트 중…", "testMemorySearchTitle": "메모리 검색 테스트", - "testResultCount": "\"{{query}}\"에 대한 {{count}}개 결과", + "testResultCount_other": "", "testResultStatus": "qmd {{qmdStatus}} · {{fallbackStatus}}", "testRetrieval": "검색 테스트", "testSearchHint": "에이전트가 사용하는 동일한 qmd 기반 memory_search 경로를 실행합니다.", - "testing": "테스트 중…", "title": "메모리", "totalInsights": "총 인사이트", - "workingMemoryLabel": "작업 메모리" + "workingMemoryLabel": "작업 메모리", + "charCount_one": "", + "insightCount_one": "", + "insightsExtracted_one": "", + "sectionCount_one": "", + "testResultCount_one": "" }, "merge": { "advanced": "고급", @@ -2913,16 +2980,16 @@ "pr": "PR", "pulling": "가져오는 중…", "pushForceWithLease": "푸시 (force-with-lease)", - "pushHeading": "{{branch}}를 origin에 푸시 — {{count}}개 커밋{{plural}} 앞섬.", + "pushHeading_other": "", + "pushing": "푸시 중…", "pushSuccess": "origin/{{branch}} @ {{sha}}에 푸시되었습니다.", "pushToOrigin": "origin에 푸시", - "pushing": "푸시 중…", "recordedNoConfirm": "로컬 병합 확인 없이 기록됨", "shortstatTitle": "최종 커밋 요약; 모든 작업 커밋에 걸친 전체 랜딩 diff는 변경 탭을 참조하세요.", "smartPull": "스마트 풀", "status": "상태", "title": "병합 세부 정보", - "unknown": "알 수 없음" + "pushHeading_one": "" }, "mesh": { "ariaLabel": "노드 메시 토폴로지 시각화", @@ -2947,23 +3014,23 @@ "addAssertion": "어설션 추가", "addContext": "추가 컨텍스트나 방향을 입력하세요...", "addFeature": "기능 추가", + "additionalComments": "추가 의견 (선택 사항)", "addMilestone": "마일스톤 추가", "addSlice": "슬라이스 추가", - "additionalComments": "추가 의견 (선택 사항)", "aiThinking": "AI가 생각 중입니다...", "aiValidatedAtRuntime": "런타임에 AI 검증됨", "aiValidatedMissionGate": "AI 검증 미션 게이트", "allFeaturesLinked": "모든 기능이 이미 연결되어 있습니다", "approvePlan": "계획 승인", - "assertionCreateFailed": "어설션 생성에 실패했습니다", "assertionCreated": "어설션이 생성되었습니다", + "assertionCreateFailed": "어설션 생성에 실패했습니다", "assertionFieldsRequired": "제목과 어설션 텍스트는 필수 항목입니다", "assertionTextEditPlaceholder": "어설션 텍스트", "assertionTextPlaceholder": "어설션 텍스트 (완료 시 참이어야 하는 내용)", "assertionTitlePlaceholder": "어설션 제목", - "assertionUpdateFailed": "어설션 업데이트에 실패했습니다", "assertionUpdated": "어설션이 업데이트되었습니다", - "attemptRetries": "시도 {{attempt}} · {{count}}{{label}} 남음", + "assertionUpdateFailed": "어설션 업데이트에 실패했습니다", + "attemptRetries_other": "", "autopilotActivatingSlice": "슬라이스 활성화 중", "autopilotCompleting": "완료 중", "autopilotDescription": "켜져 있으면 Fusion이 작업이 완료될 때 자동으로 다음 슬라이스를 활성화하고 기능을 계획합니다.", @@ -2973,11 +3040,6 @@ "autopilotLabel": "자동 조종", "autopilotLastActivation": "마지막 활성화 {{time}}", "autopilotOff": "꺼짐", - "autopilotStateActivating": "슬라이스 활성화 중", - "autopilotStateCompleting": "완료 중", - "autopilotStateInactive": "꺼짐", - "autopilotStateUnknown": "알 수 없음", - "autopilotStateWatching": "감시 중", "autopilotUpdateFailed": "자동 조종 업데이트에 실패했습니다", "autopilotWatching": "자동 조종 감시 중", "autopilotWatchingSince": "{{time}}부터 감시 중", @@ -3009,20 +3071,20 @@ "confirmSlicePlaceholder": "이 슬라이스가 완료되었는지 확인하는 방법...", "contractAssertions": "계약 어설션 (AI 검증됨)", "createButton": "생성", - "createTask": "작업 생성", "created": "미션이 생성되었습니다", "createdFromInterview": "AI 인터뷰에서 미션이 생성되었습니다", + "createTask": "작업 생성", "creatingMission": "미션 생성 중...", "defaultInterviewTitle": "미션 인터뷰", "deleteAssertion": "어설션 삭제", "deleteButton": "삭제", "deleteConfirm": "이 {{type}}을(를) 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", + "deleted": "미션이 삭제되었습니다", "deleteFailed": "미션 삭제에 실패했습니다", "deleteFeature": "기능 삭제", "deleteMilestone": "마일스톤 삭제", "deleteMission": "미션 삭제", "deleteSlice": "슬라이스 삭제", - "deleted": "미션이 삭제되었습니다", "describeGoal": "구축하려는 것을 설명하세요. AI가 범위, 제약, 요구 사항을 파악하기 위해 인터뷰를 진행한 다음 마일스톤, 슬라이스, 기능이 포함된 구조화된 계획을 생성합니다.", "descriptionLabel": "미션 설명", "descriptionOptional": "설명 (선택 사항)", @@ -3047,23 +3109,23 @@ "failedLoadModels": "모델 불러오기에 실패했습니다", "featureCreated": "기능이 생성되었습니다", "featureCriteriaAwaitingSync": "어서션 동기화 대기 중인 기능 기준", - "featureDeleteFailed": "기능 삭제에 실패했습니다", "featureDeleted": "기능이 삭제되었습니다", - "featureLinkFailed": "기능 연결에 실패했습니다", - "featureLinkTaskFailed": "기능과 작업 연결에 실패했습니다", + "featureDeleteFailed": "기능 삭제에 실패했습니다", "featureLinkedToAssertion": "기능이 어서션에 연결되었습니다", "featureLinkedToTask": "기능이 작업에 연결되었습니다", + "featureLinkFailed": "기능 연결에 실패했습니다", + "featureLinkTaskFailed": "기능과 작업 연결에 실패했습니다", "featureSaveFailed": "기능 저장에 실패했습니다", + "featuresCount_other": "", "featureTitlePlaceholder": "기능 제목", "featureTitleRequired": "기능 제목을 입력해야 합니다", - "featureTriageFailed": "기능 분류에 실패했습니다", "featureTriaged": "기능이 분류되었습니다 — 작업이 생성되었습니다", - "featureUnlinkFailed": "기능 연결 해제에 실패했습니다", - "featureUnlinkFromAssertionFailed": "기능 연결 해제에 실패했습니다", + "featureTriageFailed": "기능 분류에 실패했습니다", "featureUnlinkedFromAssertion": "기능이 어서션에서 연결 해제되었습니다", "featureUnlinkedFromTask": "기능이 작업에서 연결 해제되었습니다", + "featureUnlinkFailed": "기능 연결 해제에 실패했습니다", + "featureUnlinkFromAssertionFailed": "기능 연결 해제에 실패했습니다", "featureUpdated": "기능이 업데이트되었습니다", - "featuresCount": "{{count}}개 기능", "filterAll": "모든 이벤트", "filterAutopilot": "자동 조종 이벤트", "filterErrors": "오류 및 경고", @@ -3074,9 +3136,6 @@ "generatedFixFeatures": "생성된 수정 기능:", "generatedFixFeaturesTitle": "생성된 수정 기능", "generatedFromFeature": "기능에서 생성됨: {{id}}", - "helperTextActive": "중지하면 연결된 작업이 일시 중지되고 미션이 차단됨으로 표시됩니다.", - "helperTextBlocked": "재개하면 미션이 다시 활성화되고 실행이 계속됩니다.", - "helperTextPlanning": "시작하면 첫 번째 슬라이스가 활성화되어 작업을 시작할 수 있습니다.", "hideDetails": "세부 정보 숨기기", "hideMetadata": "메타데이터 숨기기", "hideThinking": "사고 과정 숨기기", @@ -3090,42 +3149,36 @@ "interviewErrored": "인터뷰에서 오류가 발생했습니다. 이 목록 항목에서 재시도하세요.", "interviewGenerating": "인터뷰 컨텍스트에서 미션 계층 구조를 생성하고 있습니다.", "interviewInProgress": "인터뷰 진행 중", - "interviewStatusAwaitingInput": "입력 대기 중", - "interviewStatusComplete": "계획 준비 완료", - "interviewStatusError": "재시도 필요", - "interviewStatusGenerating": "계획 생성 중", - "interviewStatusNeedsRetry": "재시도 필요", - "interviewStatusPlanReady": "계획 준비 완료", "interviewWaiting": "인터뷰가 다음 응답을 기다리고 있습니다.", "lastValidatorStatus": "마지막 {{status}}", "linkAFeature": "기능 연결", "linkButton": "연결", - "linkFeatureButton": "기능 연결", - "linkFeatureToTask": "작업에 기능 연결:", - "linkToTask": "작업에 연결", - "linkedCount": "{{count}}개 연결됨", - "linkedFeaturesCount": "{{count}}개 연결된 기능", + "linkedCount_other": "", + "linkedFeaturesCount_other": "", "linkedFeaturesLabel": "연결된 기능", "linkedGoals": "연결된 목표", "linkedGoalsTitle": "연결된 목표", + "linkFeatureButton": "기능 연결", + "linkFeatureToTask": "작업에 기능 연결:", + "linkToTask": "작업에 연결", "loadActivityFailed": "미션 활동 불러오기에 실패했습니다", "loadDetailFailed": "미션 세부 정보 불러오기에 실패했습니다", "loadFailed": "미션 불러오기에 실패했습니다", - "loadMore": "더 보기", "loadingActivity": "미션 활동 불러오는 중...", "loadingMissionDetails": "미션 세부 정보 불러오는 중...", "loadingMissions": "미션 불러오는 중...", "loadingModels": "모델 불러오는 중…", + "loadMore": "더 보기", "loopState": "루프 상태: {{state}}", "milestoneCreated": "마일스톤이 생성되었습니다", - "milestoneDeleteFailed": "마일스톤 삭제에 실패했습니다", "milestoneDeleted": "마일스톤이 삭제되었습니다", + "milestoneDeleteFailed": "마일스톤 삭제에 실패했습니다", "milestoneDescriptionPlaceholder": "마일스톤 설명...", "milestoneSaveFailed": "마일스톤 저장에 실패했습니다", + "milestonesCount_other": "", "milestoneTitlePlaceholder": "마일스톤 제목", "milestoneTitleRequired": "마일스톤 제목을 입력해야 합니다", "milestoneUpdated": "마일스톤이 업데이트되었습니다", - "milestonesCount": "{{count}}개 마일스톤", "missionHealthAriaLabel": "미션 상태: {{state}}", "missionInterviewInProgressDesc": "미션 인터뷰가 아직 진행 중입니다. 이 미션을 열어 계획을 계속하세요.", "missionList": "미션 목록", @@ -3143,44 +3196,41 @@ "noMilestonesYet": "아직 마일스톤이 없습니다. 시작하려면 하나 추가하세요.", "noMissionsYetBody": "미션은 마일스톤, 슬라이스, 기능을 하나의 계획으로 묶는 대규모 이니셔티브입니다. 미션을 계획하여 목표를 처음부터 끝까지 분해하고 에이전트가 자동 조종 방식으로 작업할 수 있게 하세요.", "noMissionsYetTitle": "아직 미션이 없습니다", + "none": "없음", "noSlicesYet": "아직 슬라이스가 없습니다", "noValidationRunsYet": "아직 검증 실행이 없습니다.", - "none": "없음", "openMissionAriaLabel": "미션 {{title}} 열기", "orSelect": "또는 선택:", "planMilestone": "마일스톤 계획", "planNewMission": "새 미션 계획", + "planningModel": "계획 모델", "planReady": "미션 계획 준비 완료", "planSlice": "슬라이스 계획", "planStateNeedsUpdate": "업데이트 필요", "planStateNotPlanned": "계획되지 않음", "planStatePlanned": "계획됨", "planTitle": "AI로 미션 계획", - "planningModel": "계획 모델", "prepareQuestion": "다음 질문 준비 중...", - "progressText": "질문 {{count}}/6", + "progressText_other": "", "reconnecting": "재연결 중…", - "relativeTimeDays": "{{count}}일 전", - "relativeTimeHours": "{{count}}시간 전", + "relativeTimeDays_other": "", + "relativeTimeHours_other": "", "relativeTimeJustNow": "방금 전", - "relativeTimeMinutes": "{{count}}분 전", + "relativeTimeMinutes_other": "", "removeFeature": "기능 제거", "removeMilestone": "마일스톤 제거", "removeSlice": "슬라이스 제거", "resizeSidebar": "미션 사이드바 크기 조정", + "resumed": "미션이 재개되었습니다", "resumeFailed": "미션 재개에 실패했습니다", "resumeInterviewAriaLabel": "인터뷰 {{title}} 재개", "resumeMission": "미션 재개", - "resumed": "미션이 재개되었습니다", "retries": "재시도", "retry": "재시도", "retryBudgetTitle": "구현 시도 횟수 및 남은 재시도 예산", "retrying": "재시도 중...", "roadmapLabel": "로드맵", "run": "실행:", - "runHelperActive": "중지하면 연결된 작업이 일시 중지되고 미션이 차단됨으로 표시됩니다.", - "runHelperBlocked": "재개하면 미션이 다시 활성화되고 실행이 계속됩니다.", - "runHelperPlanning": "시작하면 첫 번째 슬라이스가 활성화되어 작업을 시작할 수 있습니다.", "runSettings": "미션 실행 설정", "runSettingsTitle": "미션 실행 설정", "saveButton": "저장", @@ -3194,25 +3244,25 @@ "showMetadata": "메타데이터 표시", "showThinking": "사고 과정 표시", "showValidationRounds": "검증 라운드 표시", - "sliceActivateFailed": "슬라이스 활성화에 실패했습니다", "sliceActivated": "슬라이스가 활성화되었습니다", + "sliceActivateFailed": "슬라이스 활성화에 실패했습니다", "sliceCreated": "슬라이스가 생성되었습니다", - "sliceDeleteFailed": "슬라이스 삭제에 실패했습니다", "sliceDeleted": "슬라이스가 삭제되었습니다", + "sliceDeleteFailed": "슬라이스 삭제에 실패했습니다", "sliceSaveFailed": "슬라이스 저장에 실패했습니다", + "slicesCount_other": "", "sliceTitlePlaceholder": "슬라이스 제목", "sliceTitleRequired": "슬라이스 제목을 입력해야 합니다", + "sliceTriaged_other": "", "sliceTriageFailed": "슬라이스 기능 분류에 실패했습니다", - "sliceTriaged": "{{count}}개 기능이 분류되었습니다", "sliceUpdated": "슬라이스가 업데이트되었습니다", "sliceVerification": "슬라이스 검증", - "slicesCount": "{{count}}개 슬라이스", "source": "출처:", + "started": "미션이 시작되었습니다 — 첫 번째 슬라이스가 활성화됨", "startFailed": "미션 시작에 실패했습니다", "startInterview": "인터뷰 시작", "startMission": "미션 시작", "startOver": "처음부터 다시", - "started": "미션이 시작되었습니다 — 첫 번째 슬라이스가 활성화됨", "statusActive": "활성", "statusArchived": "보관됨", "statusBlocked": "차단됨", @@ -3227,9 +3277,9 @@ "statusTriaged": "분류됨", "stopFailed": "미션 중지에 실패했습니다", "stopMission": "미션 중지", - "stopped": "미션이 중지되었습니다 ({{count}}개 작업 일시 중지됨)", + "stopped_other": "", "summaryStats": "{{milestones}}개 마일스톤, {{features}}개 기능. 승인 전에 검토 및 편집하세요.", - "tabActivity": "활동 ({{count}})", + "tabActivity_other": "", "tabStructure": "구조", "takeControl": "제어권 가져오기", "takingControl": "제어권 가져오는 중...", @@ -3237,7 +3287,7 @@ "targetBranchPlaceholder": "예: main", "taskIdPlaceholder": "작업 ID (예: FN-001)", "taskIdRequired": "작업 ID를 입력해야 합니다", - "tasksFailed": "{{count}}개 실패", + "tasksFailed_other": "", "title": "미션", "titleLabel": "미션 제목", "titleRequired": "미션 제목을 입력해야 합니다", @@ -3247,25 +3297,41 @@ "triageCreateTask": "분류 — 작업 생성", "tryExample": "예시 사용해 보기:", "typeAnswer": "여기에 답변을 입력하세요...", + "unlinkedBadge": "연결 해제됨", "unlinkFeature": "기능 연결 해제", "unlinkTask": "작업 연결 해제", - "unlinkedBadge": "연결 해제됨", "untitled": "제목 없음", "updateButton": "업데이트", "updated": "미션이 업데이트되었습니다", "validateFeature": "기능 검증", - "validationRoundsCount": "{{count}}라운드", - "validationRoundsLabel": "검증 라운드 ({{count}})", + "validationRoundsCount_other": "", + "validationRoundsLabel_other": "", "validationRuns": "검증 실행", "validationState": "검증 상태", "validationStateNotStarted": "시작되지 않음", "validationTelemetry": "검증 원격 측정", - "validationTriggerFailed": "검증 트리거에 실패했습니다", "validationTriggered": "검증이 트리거되었습니다", + "validationTriggerFailed": "검증 트리거에 실패했습니다", "verification": "검증:", "verificationCriteria": "검증 기준", "viewMissionFailures": "미션 실패 보기", - "whatToBuild": "무엇을 만들고 싶으신가요?" + "whatToBuild": "무엇을 만들고 싶으신가요?", + "attemptRetries_one": "", + "featuresCount_one": "", + "linkedCount_one": "", + "linkedFeaturesCount_one": "", + "milestonesCount_one": "", + "progressText_one": "", + "relativeTimeDays_one": "", + "relativeTimeHours_one": "", + "relativeTimeMinutes_one": "", + "slicesCount_one": "", + "sliceTriaged_one": "", + "stopped_one": "", + "tabActivity_one": "", + "tasksFailed_one": "", + "validationRoundsCount_one": "", + "validationRoundsLabel_one": "" }, "modalManager": { "createdFromPlanning": "계획 모드에서 {{id}}를 생성했습니다", @@ -3276,26 +3342,12 @@ "noChange": "변경 없음", "selectPlaceholder": "모델 선택…" }, - "modelSelection": { - "choose": "이 작업에 사용할 모델을 선택하세요. 선택하지 않으면 기본 모델이 사용됩니다.", - "custom": "사용자 지정", - "executorModel": "실행자 모델", - "executorPlaceholder": "실행자 모델 선택…", - "loading": "모델 불러오는 중…", - "noModels": "사용 가능한 모델이 없습니다. 모델 선택을 활성화하려면 설정에서 인증을 구성하세요.", - "preset": "프리셋", - "reviewerModel": "검토자 모델", - "reviewerPlaceholder": "검토자 모델 선택…", - "title": "모델 선택", - "useDefault": "기본값 사용", - "usingDefault": "기본값 사용 중" - }, "models": { "addProviderToFavoritesAriaLabel": "{{provider}}를 즐겨찾기에 추가", "addToFavorites": "즐겨찾기에 추가", "addToFavoritesAriaLabel": "{{name}}을(를) 즐겨찾기에 추가", "clearFilter": "필터 지우기", - "count": "{{count}}개 모델", + "count_other": "", "descriptions": { "executor": "이 작업을 구현하는 데 사용되는 AI 모델입니다.", "override": "이 작업에 사용되는 AI 모델을 재정의합니다. 지정하지 않으면 프로젝트 또는 전역 기본값이 사용됩니다.", @@ -3320,8 +3372,6 @@ "thinkingLevel": "사고 수준" }, "messages": { - "modelSetTo": "{{label}} 모델이 {{provider}}/{{modelId}}로 설정되었습니다", - "modelSetToDefault": "{{label}} 모델이 기본값으로 설정되었습니다", "thinkingLevelSet": "사고 수준이 {{level}}로 설정되었습니다", "thinkingLevelSetDefault": "사고 수준이 기본값({{level}})으로 설정되었습니다", "upToDate": "모델 설정이 최신 상태입니다.", @@ -3348,15 +3398,25 @@ "loading": "사용 가능한 모델 불러오는 중…", "usingDefault": "기본값 사용 중" }, - "targetLabels": { - "executor": "실행자", - "planning": "계획", - "validator": "검토자" - }, "titles": { "configuration": "모델 구성" }, - "useDefault": "기본값 사용" + "useDefault": "기본값 사용", + "count_one": "" + }, + "modelSelection": { + "choose": "이 작업에 사용할 모델을 선택하세요. 선택하지 않으면 기본 모델이 사용됩니다.", + "custom": "사용자 지정", + "executorModel": "실행자 모델", + "executorPlaceholder": "실행자 모델 선택…", + "loading": "모델 불러오는 중…", + "noModels": "사용 가능한 모델이 없습니다. 모델 선택을 활성화하려면 설정에서 인증을 구성하세요.", + "preset": "프리셋", + "reviewerModel": "검토자 모델", + "reviewerPlaceholder": "검토자 모델 선택…", + "title": "모델 선택", + "useDefault": "기본값 사용", + "usingDefault": "기본값 사용 중" }, "nav": { "activityLog": "활동 로그", @@ -3380,8 +3440,8 @@ "missions": "미션", "more": "더 보기", "moreSheetTitle": "탐색", - "noScriptsAddOne": "스크립트 없음 — 추가하세요…", "nodes": "노드", + "noScriptsAddOne": "스크립트 없음 — 추가하세요…", "planning": "계획", "primaryNavAriaLabel": "기본 탐색", "projects": "프로젝트", @@ -3417,27 +3477,11 @@ "noAvailableTasks": "사용 가능한 작업 없음", "searchTasks": "작업 검색…", "selectAgent": "에이전트 선택", - "selectedCount": "{{count}}개 선택됨", + "selectedCount_other": "", "taskCreated": "{{taskId}} 생성됨", "title": "새 작업", - "unsavedChanges": "저장되지 않은 변경 사항이 있습니다. 버리시겠습니까?" - }, - "nodeStatus": { - "connecting": "연결 중", - "error": "오류", - "local": "로컬", - "offline": "오프라인", - "online": "온라인", - "unknown": "알 수 없음" - }, - "nodeSync": { - "error": { - "authSyncFailed": "인증 동기화에 실패했습니다", - "failedToFetchStatus": "동기화 상태 가져오기에 실패했습니다", - "pullFailed": "설정 가져오기에 실패했습니다", - "pushFailed": "설정 내보내기에 실패했습니다", - "someRequestsFailed": "일부 동기화 상태 요청이 실패했습니다" - } + "unsavedChanges": "저장되지 않은 변경 사항이 있습니다. 버리시겠습니까?", + "selectedCount_one": "" }, "nodes": { "actions": { @@ -3481,22 +3525,16 @@ "addDockerNode": "Docker 노드 추가", "addDockerNodeTitle": "관리형 Docker 노드 추가", "addFirstNode": "첫 번째 노드 추가", + "adding": "추가 중...", "addMountButton": "마운트 추가", "addNode": "노드 추가", "addVariableButton": "변수 추가", - "adding": "추가 중...", "apiKey": "API 키", "apiKeyMode": "API 키 모드", "apiKeyNotConfigured": "구성되지 않음", "apiKeyPlaceholder": "노드 API 키 입력", "attachProjects": "기존 프로젝트 연결", "attachProjectsHint": "이 노드에서 실행할 기존 프로젝트를 선택하고, 각 프로젝트의 노드 전용 절대 경로를 입력하세요.", - "auth": { - "differ": "인증 자격 증명이 다릅니다", - "differProviders": "인증 자격 증명이 다릅니다: {{providers}}", - "match": "인증 자격 증명이 일치합니다", - "notSynced": "인증이 동기화되지 않았습니다" - }, "authSync": { "differ": "자격 증명 불일치", "label": "인증 동기화: {{status}}", @@ -3517,9 +3555,9 @@ "containerLogs": "컨테이너 로그", "description": "연결 세부 정보와 동시 실행 설정을 입력하여 기존 Fusion 노드를 등록하세요.", "discoverBeforeAdding": "이 노드를 추가하기 전에 원격 프로젝트를 검색하세요.", - "discoverRemoteProjects": "원격 프로젝트 검색", - "discoveredCount": "원격 프로젝트 {{count}}개{{plural}} 검색됨.", + "discoveredCount_other": "", "discovering": "검색 중...", + "discoverRemoteProjects": "원격 프로젝트 검색", "discoveryFailed": "원격 프로젝트 검색에 실패했습니다", "dismissError": "오류 닫기", "docker": "Docker", @@ -3553,8 +3591,8 @@ "dockerPidsLimit": "PID 제한", "dockerPort": "포트", "dockerResourceDefault": "기본값", - "dockerResourceSizing": "리소스 크기", "dockerResources": "리소스", + "dockerResourceSizing": "리소스 크기", "dockerRetainOnDelete": "삭제 시 보존", "dockerStatusUnknown": "알 수 없음", "dockerTlsCaCert": "TLS CA 인증서 경로", @@ -3567,11 +3605,11 @@ "editButton": "편집", "errorFetching": "노드 가져오기에 실패했습니다", "errorPersistMappings": "프로젝트 매핑 저장에 실패했습니다", - "errorUnregisterAfterMappingFailure": "매핑 실패 후 노드 등록 해제에 실패했습니다", "errors": { "connectFailed": "연결에 실패했습니다", "connectToNode": "노드 연결에 실패했습니다" }, + "errorUnregisterAfterMappingFailure": "매핑 실패 후 노드 등록 해제에 실패했습니다", "failedCreateDocker": "Docker 노드 생성에 실패했습니다", "failedRefresh": "노드 새로고침에 실패했습니다", "failedRemove": "노드 제거에 실패했습니다", @@ -3582,10 +3620,6 @@ "fieldCreated": "생성일", "fieldMaxConcurrent": "최대 동시 실행", "fieldName": "이름", - "fieldStatus": "상태", - "fieldType": "유형", - "fieldUpdated": "수정일", - "fieldUrl": "URL", "fields": { "authKey": "인증 키", "host": "호스트 / IP 주소", @@ -3594,6 +3628,10 @@ "port": "포트", "url": "URL" }, + "fieldStatus": "상태", + "fieldType": "유형", + "fieldUpdated": "수정일", + "fieldUrl": "URL", "heading": "노드", "healthCheckButton": "상태 확인", "healthCheckComplete": "노드 상태 확인이 완료되었습니다", @@ -3623,6 +3661,7 @@ "namePlaceholder": "빌드 머신", "nameRequired": "이름은 필수입니다", "no": "아니요", + "nodeLabel": "{{name}} ({{type}}) — {{status}}", "noLogsAvailable": "사용 가능한 로그가 없습니다", "noMatch": "일치하는 원격 이름이 없습니다. 이 경로를 직접 입력하세요.", "noProjects": "현재 등록된 프로젝트가 없습니다.", @@ -3630,7 +3669,6 @@ "noProjectsDiscovered": "원격 노드에서 검색된 프로젝트가 없습니다.", "noProjectsRunning": "이 노드에서 실행 중인 프로젝트가 없습니다.", "noRegistered": "아직 등록된 노드가 없습니다.", - "nodeLabel": "{{name}} ({{type}}) — {{status}}", "offline": "오프라인", "online": "온라인", "pathDiscovered": "원격 권한 경로 검색됨: {{path}}", @@ -3642,22 +3680,22 @@ "optional": "선택 사항" }, "provideManually": "키를 직접 입력", + "pulling": "가져오는 중...", "pullSettings": "설정 가져오기", "pullSettingsFailed": "설정 가져오기에 실패했습니다", "pullSettingsSuccess": "설정을 성공적으로 가져왔습니다", - "pulling": "가져오는 중...", + "pushing": "내보내는 중...", "pushSettings": "설정 내보내기", "pushSettingsFailed": "설정 내보내기에 실패했습니다", "pushSettingsSuccess": "설정을 성공적으로 내보냈습니다", - "pushing": "내보내는 중...", "reachableUrl": "연결 가능한 URL / 호스트명", "readOnly": "읽기 전용", "refresh": "새로고침", - "refreshStatus": "상태 새로고침", "refreshing": "새로고침 중...", - "registerFailed": "노드 등록에 실패했습니다", + "refreshStatus": "상태 새로고침", "registered": "노드 \"{{name}}\"이(가) 등록되었습니다", - "registeredCount": "{{count}}개 등록됨", + "registeredCount_other": "", + "registerFailed": "노드 등록에 실패했습니다", "remote": "원격", "removeButton": "제거", "removed": "노드가 제거되었습니다", @@ -3674,18 +3712,6 @@ "sectionSettingsSync": "설정 동기화", "sectionSyncHistory": "동기화 기록", "startButton": "시작", - "status": { - "connecting": "연결 중", - "creating": "생성 중", - "deleting": "삭제 중", - "error": "오류", - "exited": "종료됨", - "offline": "오프라인", - "online": "온라인", - "recreating": "재생성 중", - "running": "실행 중", - "stopped": "중지됨" - }, "statusConnecting": "연결 중", "statusError": "오류", "statusOffline": "오프라인", @@ -3699,10 +3725,10 @@ "syncAuthFailed": "인증 동기화에 실패했습니다", "syncAuthSuccess": "인증 자격 증명이 성공적으로 동기화되었습니다", "syncDifferences": "차이점:", - "syncLastSync": "마지막 동기화:", - "syncNeverSynced": "동기화된 적 없음", "synced": "동기화됨", "syncing": "동기화 중...", + "syncLastSync": "마지막 동기화:", + "syncNeverSynced": "동기화된 적 없음", "total": "전체", "type": { "local": "로컬", @@ -3720,7 +3746,21 @@ "portRange": "포트는 1에서 65535 사이여야 합니다" }, "viewLogsButton": "로그 보기", - "yes": "예" + "yes": "예", + "discoveredCount_one": "", + "registeredCount_one": "" + }, + "nodeStatus": { + "local": "로컬" + }, + "nodeSync": { + "error": { + "authSyncFailed": "인증 동기화에 실패했습니다", + "failedToFetchStatus": "동기화 상태 가져오기에 실패했습니다", + "pullFailed": "설정 가져오기에 실패했습니다", + "pushFailed": "설정 내보내기에 실패했습니다", + "someRequestsFailed": "일부 동기화 상태 요청이 실패했습니다" + } }, "onboarding": { "authToken": "인증 토큰 (선택 사항)", @@ -3736,8 +3776,8 @@ "remoteServer": "원격 서버", "resumeOnboarding": "온보딩 재개", "saving": "저장 중…", - "scanQr": "QR 스캔", "scanning": "스캔 중…", + "scanQr": "QR 스캔", "serverUrl": "서버 URL", "serverUrlPlaceholder": "https://your-fusion-host", "stepContinue": "단계에 있습니다. 이전에 중단한 곳에서 계속하여 대시보드 설정을 완료하세요.", @@ -3797,10 +3837,10 @@ "companyHelp": "Paperclip 회사를 선택하세요.", "companyIdRequired": "Paperclip API 키를 발급하려면 회사 ID가 필요합니다.", "companyLabel": "회사", - "connectToPopulate": "연결하여 자동 입력", "connected": "연결되었습니다.", "connectedAsAgent": "{{agentName}}{{companyInfo}}(으)로 연결되었습니다.", "connectionModeAriaLabel": "Paperclip 연결 모드", + "connectToPopulate": "연결하여 자동 입력", "description": "Paperclip 회사에서 Paperclip 에이전트(직원)를 구동합니다. 각 프롬프트는 작업 형태의 요청을 전달하며, 거버넌스, 예산, 승인은 Paperclip이 처리합니다. 턴당 수 초에서 수 분의 지연이 발생할 수 있습니다.", "docsLink": "Paperclip 문서", "githubLink": "GitHub", @@ -3808,16 +3848,6 @@ "goalIdLabel": "목표 ID (선택 사항)", "mintButton": "paperclipai로 API 키 발급", "mintFailed": "발급 실패: {{reason}}. CLI가 인증되지 않은 경우 먼저 `paperclipai onboard`를 실행하세요.", - "mode": { - "issue-per-prompt": "프롬프트별 이슈", - "rolling-issue": "롤링 이슈 (기본값)", - "wakeup-only": "웨이크업 전용 (고급)" - }, - "modeHelp": { - "issue-per-prompt": "각 프롬프트마다 새 최상위 Paperclip 이슈가 생성됩니다. 가장 명시적이지만 보드가 복잡해질 수 있습니다.", - "rolling-issue": "Fusion 세션당 Paperclip 이슈 하나를 생성하며, 이후 프롬프트는 댓글로 추가됩니다. 채팅 경험에 가장 가깝습니다.", - "wakeup-only": "이슈 부작용 없이 프롬프트가 웨이크업 페이로드로만 전달됩니다. 에이전트의 프롬프트 템플릿이 페이로드 기반 웨이크업을 처리할 수 있어야 합니다." - }, "modeLabel": "대화 모드", "name": "Paperclip", "noAgentsDiscovered": "검색된 에이전트 없음", @@ -3860,13 +3890,13 @@ "filterSkills": "스킬:", "filterThemes": "테마:", "installFailed": "패키지 설치에 실패했습니다: {{error}}", - "installSuccess": "패키지가 성공적으로 설치되었습니다", "installing": "설치 중…", + "installSuccess": "패키지가 성공적으로 설치되었습니다", "loadExtensionsFailed": "확장 프로그램 로드에 실패했습니다: {{error}}", - "loadSettingsFailed": "Pi 설정 로드에 실패했습니다: {{error}}", "loading": "Pi 설정 로드 중…", "loadingExtensions": "확장 프로그램 로드 중…", "loadingFailed": "Pi 설정 로드에 실패했습니다.", + "loadSettingsFailed": "Pi 설정 로드에 실패했습니다: {{error}}", "noExtensions": "검색된 확장 프로그램이 없습니다.", "noPackages": "구성된 패키지가 없습니다.", "noPackagesHelp": "시작하려면 위에서 패키지 소스를 추가하세요.", @@ -3876,8 +3906,8 @@ "refreshExtensions": "확장 프로그램 새로고침", "reinstallButton": "Fusion 스킬 재설치", "reinstallFailed": "Fusion 스킬 재설치에 실패했습니다: {{error}}", - "reinstallSuccess": "Fusion 스킬이 성공적으로 재설치되었습니다", "reinstalling": "Fusion 재설치 중…", + "reinstallSuccess": "Fusion 스킬이 성공적으로 재설치되었습니다", "removeFailed": "패키지 제거에 실패했습니다: {{error}}", "removePackage": "패키지 제거", "removePackageLabel": "패키지 {{label}} 제거", @@ -3893,9 +3923,9 @@ "updateSettingsFailed": "설정 업데이트에 실패했습니다: {{error}}" }, "planning": { - "addSubtask": "하위 작업 추가", "additionalComments": "추가 의견 (선택 사항)", "additionalCommentsPlaceholder": "추가 맥락이나 방향을 입력하세요...", + "addSubtask": "하위 작업 추가", "advancedSettings": "고급 계획 설정", "aiThinking": "AI가 생각 중...", "archiveSession": "세션 보관", @@ -3910,10 +3940,10 @@ "branchNameRequired": "이 브랜치 전략에는 브랜치 이름이 필요합니다.", "branchProjectDefault": "프로젝트/기본 브랜치 사용", "branchStrategy": "브랜치 전략", - "breakIntoTasks": "작업으로 분할", - "breakIntoTasksTitle": "계획을 의존성이 있는 여러 작업으로 분할", "breakdownSubheading": "계획에서 생성된 하위 작업을 검토하고 편집하세요. 생성하기 전에 제목, 설명, 크기, 우선순위, 의존성을 조정하세요.", "breakingDown": "분할 중...", + "breakIntoTasks": "작업으로 분할", + "breakIntoTasksTitle": "계획을 의존성이 있는 여러 작업으로 분할", "collapse": "접기", "continue": "계속", "createSingleTask": "단일 작업 생성", @@ -3977,11 +4007,11 @@ "questionsLabel": "질문 수", "reconnecting": "재연결 중…", "refineFurther": "추가 다듬기", - "relativeTimeDays": "{{count}}일 전", - "relativeTimeHours": "{{count}}시간 전", + "relativeTimeDays_other": "", + "relativeTimeHours_other": "", "relativeTimeJustNow": "방금 전", - "relativeTimeMinutes": "{{count}}분 전", - "relativeTimeWeeks": "{{count}}주 전", + "relativeTimeMinutes_other": "", + "relativeTimeWeeks_other": "", "remove": "제거", "retryFailed": "재시도 실패. 다시 시도해 주세요.", "retrying": "재시도 중...", @@ -4020,34 +4050,21 @@ "untitledSession": "제목 없는 세션", "usingDefault": "기본값 사용", "whatToBuild": "무엇을 만들고 싶으신가요?", - "whatToBuildPlaceholder": "예: 로그인, 회원가입, 비밀번호 재설정이 포함된 사용자 인증 시스템 구축..." + "whatToBuildPlaceholder": "예: 로그인, 회원가입, 비밀번호 재설정이 포함된 사용자 인증 시스템 구축...", + "relativeTimeDays_one": "", + "relativeTimeHours_one": "", + "relativeTimeMinutes_one": "", + "relativeTimeWeeks_one": "" }, "plugins": { "addItem": "항목 추가", - "agentBrowser": { - "groupBrowser": "브라우저", - "groupGeneral": "일반", - "groupPromptContributions": "프롬프트 기여", - "groupSkills": "스킬", - "labelAllowedDomains": "허용된 도메인", - "labelCommandTimeoutMs": "명령 타임아웃 (ms)", - "labelEnabled": "에이전트 브라우저 활성화", - "labelHeadlessMode": "헤드리스 모드", - "labelInstallChannel": "설치 채널", - "labelPromptExecutorSystem": "실행기 시스템 프롬프트", - "labelPromptExecutorTask": "실행기 작업 프롬프트", - "labelPromptHeartbeat": "하트비트 프롬프트", - "labelPromptReviewer": "검토자 프롬프트", - "labelPromptTriage": "분류 프롬프트", - "labelSkillExposure": "스킬 노출" - }, "aiScanDisabled": "로드 시 AI 스캔 비활성화됨", "aiScanEnabled": "로드 시 AI 스캔 활성화됨", "aiScanHint": "이 기능을 켜면 구성만 업데이트됩니다. 지금 바로 실행하려면 재스캔 및 다시 로드를 사용하세요.", "author": "작성자:", "backToList": "플러그인 목록으로 돌아가기", - "builtinInstallFailed": "{{name}} 설치 실패: {{error}}", "builtinInstalledGlobally": "{{name}} 전역 설치됨", + "builtinInstallFailed": "{{name}} 설치 실패: {{error}}", "builtinMetadataOnly": "기본 제공 메타데이터만", "builtinNoPackage": "{{name}}은(는) 기본 제공이며 아직 설치 가능한 패키지가 없습니다", "builtinPluginRecommendations": "기본 제공 플러그인 추천", @@ -4057,34 +4074,34 @@ "checkingSetup": "설정 확인 중...", "componentUnavailable": "플러그인 컴포넌트를 사용할 수 없습니다", "couldNotResolve": "대시보드가 정적 호스트 레지스트리에서 이 플러그인 서피스를 확인할 수 없습니다.", + "disabledForProject": "이 프로젝트에서 {{name}} 비활성화됨", "disableInProject": "프로젝트에서 비활성화", "disablePlugin": "{{name}} 비활성화", "disablePluginFailed": "플러그인 비활성화 실패: {{error}}", - "disabledForProject": "이 프로젝트에서 {{name}} 비활성화됨", "droidOnboardingTip": "팁: Droid CLI를 활성화하면 API 키 추가 없이 Factory AI 구독을 재사용할 수 있습니다.", "droidRecommendDesc": "Fusion에서 로컬 Droid CLI 세션을 AI 공급자로 사용하세요.", "droidRecommendTitle": "Droid CLI 활성화", "enableAiScanBeforeLoad": "로드/다시 로드 전 AI 스캔 활성화", "enableAiSecurityScan": "로드 시 AI 보안 스캔 활성화", + "enabledForProject": "이 프로젝트에서 {{name}} 활성화됨", "enableFailed": "{{name}} 활성화 실패: {{error}}", "enableInProject": "프로젝트에서 활성화", "enablePlugin": "{{name}} 활성화", "enablePluginFailed": "플러그인 활성화 실패: {{error}}", - "enabledForProject": "이 프로젝트에서 {{name}} 활성화됨", "experimental": "실험적", - "findings": "발견 사항 ({{count}})", + "findings_other": "", "homepage": "홈페이지:", "install": "설치", + "installedGlobally": "플러그인이 전역 설치되었습니다", + "installedPlugins": "설치된 플러그인", "installFailed": "플러그인 설치 실패: {{error}}", "installHint": "플러그인 패키지 루트(manifest.json 포함) 또는 빌드된 dist 디렉터리를 탐색하세요.", + "installing": "설치 중...", "installNamed": "{{name}} 설치", "installPathPlaceholder": "플러그인 디렉터리 또는 dist 폴더의 절대 경로", "installPathRequired": "플러그인 경로를 입력하세요", "installPluginGlobally": "플러그인 전역 설치", "installSetup": "설정 설치", - "installedGlobally": "플러그인이 전역 설치되었습니다", - "installedPlugins": "설치된 플러그인", - "installing": "설치 중...", "loadFailed": "플러그인 로드 실패: {{error}}", "loading": "로드 중...", "loadingPlugins": "플러그인 로드 중...", @@ -4098,8 +4115,8 @@ "refresh": "새로 고침", "refreshPluginList": "플러그인 목록 새로 고침", "reload": "다시 로드", - "reloadFailed": "플러그인 다시 로드 실패: {{error}}", "reloaded": "{{name}} 다시 로드됨", + "reloadFailed": "플러그인 다시 로드 실패: {{error}}", "reloading": "다시 로드 중...", "removeItem": "항목 제거", "rescanAndReload": "재스캔 및 다시 로드", @@ -4109,11 +4126,11 @@ "saveSettingsFailed": "설정 저장 실패: {{error}}", "securityScan": "보안 스캔", "selectOption": "선택...", - "settingUp": "설정 중...", "settings": "설정", "settingsSaved": "설정 저장됨", - "setupInstallFailed": "{{name}} 설정 설치 실패: {{error}}", + "settingUp": "설정 중...", "setupInstalled": "{{name}} 설정 설치됨", + "setupInstallFailed": "{{name}} 설정 설치 실패: {{error}}", "setupReady": "설정 준비됨", "setupRequired": "설정 필요", "startPluginToCheckSetup": "설정 확인을 위해 플러그인을 시작하세요", @@ -4121,14 +4138,15 @@ "statusInstalled": "설치됨", "statusNotInstalled": "설치되지 않음", "uninstallConfirm": "\"{{name}}\"을(를) 전역(모든 프로젝트)으로 제거하시겠습니까?", + "uninstalledGlobally": "{{name}} 전역 제거됨", "uninstallFailed": "플러그인 제거 실패: {{error}}", "uninstallGlobally": "전역 제거", "uninstallGloballyTitle": "전역 제거", "uninstallTitle": "플러그인 전역 제거", - "uninstalledGlobally": "{{name}} 전역 제거됨", "unknownError": "알 수 없는 오류", "updateFailed": "플러그인 업데이트 실패: {{error}}", - "version": "버전:" + "version": "버전:", + "findings_one": "" }, "pr": { "authFail": "gh auth login을 실행한 후 다시 시도하세요.", @@ -4151,7 +4169,6 @@ "createPr": "PR 생성", "createTitle": "풀 리퀘스트 생성", "dismissError": "PR 오류 닫기", - "loadingMetadata": "PR 메타데이터 로드 중…", "noConflicts": "병합 충돌이 감지되지 않았습니다.", "preflightChecks": "사전 확인", "previewTitle": "Diff 및 커밋 미리보기", @@ -4176,22 +4193,26 @@ "confirm": "확인", "confirmRemove": "제거 확인", "confirmRemoveProject": "프로젝트 제거 확인", - "daysAgo": "{{count}}일 전", - "hoursAgo": "{{count}}시간 전", + "daysAgo_other": "", + "hoursAgo_other": "", "justNow": "방금 전", "lastActivity": "마지막 활동:", - "minutesAgo": "{{count}}분 전", - "moreItems": "+{{count}}개 더", + "minutesAgo_other": "", + "moreItems_other": "", "never": "없음", - "noHealthData": "사용 가능한 상태 데이터가 없습니다", "nodeAvailability": "프로젝트 노드 가용성", + "noHealthData": "사용 가능한 상태 데이터가 없습니다", "open": "열기", "openProject": "프로젝트 열기", "pause": "일시 중지", "pauseProject": "프로젝트 일시 중지", "removeProject": "프로젝트 제거", "resume": "재개", - "resumeProject": "프로젝트 재개" + "resumeProject": "프로젝트 재개", + "daysAgo_one": "", + "hoursAgo_one": "", + "minutesAgo_one": "", + "moreItems_one": "" }, "projectDetection": { "editName": "이름 편집", @@ -4199,20 +4220,13 @@ "emptyHint": "다른 기본 경로를 시도하거나 프로젝트를 수동으로 추가하세요", "noDbWarning": "fn 데이터베이스를 찾을 수 없습니다 - 초기화됩니다", "registerAll": "모두 등록", - "registerSelected": "선택 항목 등록 ({{count}})", "registering": "등록 중...", - "selectAll": "모두 선택 ({{count}})", - "selectedCount": "{{count}}개 선택됨" - }, - "projectSelector": { - "allProjects": "모든 프로젝트", - "ariaLabel": "프로젝트 선택", - "clearSearch": "검색 지우기", - "noResults": "검색 결과와 일치하는 프로젝트가 없습니다", - "recent": "최근", - "searchPlaceholder": "프로젝트 검색...", - "selectProject": "프로젝트 선택", - "viewAll": "모든 프로젝트 보기" + "registerSelected_other": "", + "selectAll_other": "", + "selectedCount_other": "", + "registerSelected_one": "", + "selectAll_one": "", + "selectedCount_one": "" }, "projects": { "actions": { @@ -4237,9 +4251,9 @@ "filterByNode": "노드로 필터", "filterErrored": "오류", "filterPaused": "일시 중지됨", + "nodesLabel": "노드", "noMatch": "현재 필터와 일치하는 프로젝트가 없습니다", "noProjectsFound": "프로젝트를 찾을 수 없습니다", - "nodesLabel": "노드", "setup": { "success": "프로젝트 {{name}} 등록 완료" }, @@ -4254,15 +4268,26 @@ "title": "프로젝트", "totalLabel": "전체" }, + "projectSelector": { + "allProjects": "모든 프로젝트", + "ariaLabel": "프로젝트 선택", + "clearSearch": "검색 지우기", + "noResults": "검색 결과와 일치하는 프로젝트가 없습니다", + "recent": "최근", + "searchPlaceholder": "프로젝트 검색...", + "selectProject": "프로젝트 선택", + "viewAll": "모든 프로젝트 보기" + }, "providers": { "actions": { "addModel": "+ 모델 추가", + "detecting": "감지 중…", "detectModels": "모델 감지", "detectModelsTitle": "공급자의 /models 엔드포인트를 호출하여 사용 가능한 모델을 검색합니다", - "detecting": "감지 중…", - "removeModel": "모델 제거", + "removeModel_other": "", "save": "공급자 저장", - "saving": "저장 중..." + "saving": "저장 중...", + "removeModel_one": "" }, "addCustom": "사용자 정의 공급자 추가", "apiKeyLabel": "API 키", @@ -4280,9 +4305,9 @@ "noModels": "모델을 찾을 수 없습니다. 공급자에 API 키가 필요할 수 있습니다.", "urlRequired": "모델을 감지하려면 기본 URL이 필요합니다." }, + "detecting": "감지 중…", "detectModels": "모델 감지", "detectTitle": "공급자의 /models 엔드포인트에서 모델 자동 감지", - "detecting": "감지 중…", "editLabel": "{{name}} 편집", "failedDelete": "공급자 삭제 실패.", "failedDetect": "모델 감지 실패", @@ -4297,6 +4322,7 @@ "maxTokens": "최대 토큰", "modelId": "모델 ID", "modelName": "표시 이름", + "modelNameLabel": "", "models": "모델", "name": "표시 이름", "reasoning": "추론" @@ -4360,8 +4386,10 @@ "p95": "P95", "p95Raw": "P95 원시값: {{value}} ms", "reason": "사유: {{reason}}", - "sampleCount": "샘플 수: {{count}}", - "samples": "샘플: {{count}}" + "sampleCount_other": "", + "samples_other": "", + "sampleCount_one": "", + "samples_one": "" }, "failureRate": "실패율: {{rate}}", "heading": "안정성", @@ -4370,12 +4398,14 @@ "insufficientData": "데이터 부족 — {{reason}}", "mergeAttempts": { "heading": "병합 시도", - "histogramTotal": "히스토그램 합계: {{count}}", + "histogramTotal_other": "", "max": "최대", "mean": "평균", "moreStats": "추가 통계", "reason": "사유: {{reason}}", - "tasksCounted": "집계된 작업: {{count}}" + "tasksCounted_other": "", + "histogramTotal_one": "", + "tasksCounted_one": "" }, "reason": "사유: {{reason}}", "resetBaseline": "기준선 초기화: {{date}}", @@ -4413,11 +4443,11 @@ "enrichTaskButton": "작업 보강", "enrichTaskTitle": "기존 작업 보강", "enterTaskId": "작업 ID 입력", + "exportedFile": "{{filename}} 내보냄", "exportFailed": "내보내기 실패", "exportHtml": "HTML 내보내기", "exportJson": "JSON 내보내기", "exportMd": "MD 내보내기", - "exportedFile": "{{filename}} 내보냄", "findingLabel": "발견 항목:", "loadingRuns": "리서치 실행 불러오는 중…", "loadingTasks": "작업 불러오는 중…", @@ -4434,11 +4464,6 @@ "priorityLow": "낮음", "priorityNormal": "보통", "priorityUrgent": "긴급", - "providerGitHub": "GitHub", - "providerLlmSynthesis": "LLM 합성", - "providerLocalDocs": "로컬 문서", - "providerPageFetch": "페이지 가져오기", - "providerWebSearch": "웹 검색", "providersLabel": "제공자", "queryLabel": "쿼리", "runCancelled": "실행 취소됨", @@ -4461,7 +4486,7 @@ "viewLabel": "리서치 보기" }, "routine": { - "andMore": "…외 {{count}}개", + "andMore_other": "", "delete": "삭제", "deleteMessage": "루틴 {{name}}을(를) 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.", "deleteName": "{{name}} 삭제", @@ -4474,11 +4499,14 @@ "enableName": "{{name}} 활성화", "resultFailed": "실패", "resultSuccess": "성공", - "runHistory": "실행 기록 ({{count}})", + "runHistory_other": "", "runNameNow": "{{name}} 지금 실행", - "runNow": "지금 실행", "running": "실행 중…", - "stepCount": "{{count}}개 단계" + "runNow": "지금 실행", + "stepCount_other": "", + "andMore_one": "", + "runHistory_one": "", + "stepCount_one": "" }, "routing": { "cannotChangeWhileActive": "작업이 활성화된 동안에는 노드 재정의를 변경할 수 없습니다.", @@ -4494,11 +4522,6 @@ "overrideSection": "노드 재정의", "overrideSetTo": "재정의 설정:", "overrideUpdated": "노드 재정의가 업데이트되었습니다", - "policyLabel": { - "block": "실행 차단", - "fallback": "로컬로 대체", - "notConfigured": "미구성" - }, "selectLabel": "실행 노드 선택", "source": { "noRouting": "라우팅 없음", @@ -4532,11 +4555,11 @@ "advancedMode": "다단계", "advancedModeHelp": "여러 단계를 순차적으로 실행 (명령어 및 AI 프롬프트)", "aiPromptType": "AI 프롬프트", - "andMore": "…외 {{count}}개", + "andMore_other": "", "apiEndpointHint": "이 루틴을 트리거하는 API 엔드포인트 경로", "apiEndpointLabel": "API 엔드포인트", "apiEndpointPlaceholder": "/api/routine/my-routine", - "automationCount": "{{count}}개 자동화{{plural}}", + "automationCount_other": "", "cancelButton": "취소", "catchUpPolicyHint": "예약된 실행이 누락되었을 때 처리 방법", "catchUpPolicyLabel": "누락 실행 정책", @@ -4591,10 +4614,10 @@ "editTitle": "스케줄 편집", "emptyStateDescription": "스케줄, 웹훅, API 또는 수동 트리거로 자동화를 만드세요.", "enable": "활성화", - "enableName": "{{name}} 활성화", "enabledHelp": "비활성화되면 스케줄이 자동으로 실행되지 않습니다", "enabledHint": "비활성화되면 루틴이 자동으로 실행되지 않습니다", "enabledLabel": "활성화됨", + "enableName": "{{name}} 활성화", "errorApiEndpointRequired": "API 엔드포인트는 필수입니다", "errorCommandRequired": "명령어는 필수입니다", "errorCronInvalid": "잘못된 cron 형식 — 5개의 필드가 필요합니다 (예: '0 */6 * * *')", @@ -4607,9 +4630,9 @@ "errorStepCommandRequired": "단계 {{n}}: 명령어는 필수입니다", "errorStepNameRequired": "단계 {{n}}: 이름은 필수입니다", "errorStepPromptRequired": "단계 {{n}}: 프롬프트는 필수입니다", - "errorStepTaskDescRequired": "단계 {{n}}: 작업 설명은 필수입니다", "errorStepsEditing": "루틴을 저장하기 전에 모든 단계 편집을 저장하거나 취소하세요", "errorStepsRequired": "최소 하나의 단계가 필요합니다", + "errorStepTaskDescRequired": "단계 {{n}}: 작업 설명은 필수입니다", "errorTaskDescriptionRequired": "작업 설명은 필수입니다", "errorTimeoutMin": "타임아웃은 최소 1초(1000ms) 이상이어야 합니다", "errorWebhookPathRequired": "웹훅 경로는 필수입니다", @@ -4626,13 +4649,13 @@ "frequencyLabel": "빈도", "global": "전체", "globalScope": "전체", - "globalScopeTitle": "전체 범위", "globalScoped": "이 스케줄은 전체 범위에서 생성됩니다.", + "globalScopeTitle": "전체 범위", "loadRoutinesError": "루틴 불러오기 실패", "manualTriggerInfo": "이 루틴은 대시보드 또는 API를 통해 수동으로 트리거됩니다.", "modeAriaLabel": "실행 모드", - "modeLabel": "실행 모드", "model": "모델", + "modeLabel": "실행 모드", "modelConsistency": "모델 제공자와 모델 ID 모두 설정하거나 모두 비워야 합니다", "modelDropdownLabel": "모델", "modelHelp": "이 프롬프트의 AI 모델. 선택하지 않으면 기본값을 사용합니다.", @@ -4656,9 +4679,9 @@ "project": "프로젝트", "projectRequired": "프로젝트 전용 항목에는 활성 프로젝트가 필요합니다.", "projectScope": "프로젝트", + "projectScoped": "이 스케줄은 현재 프로젝트 범위에서 생성됩니다.", "projectScopeDisabled": "프로젝트를 선택하여 프로젝트 범위 활성화", "projectScopeTitle": "프로젝트 범위", - "projectScoped": "이 스케줄은 현재 프로젝트 범위에서 생성됩니다.", "prompt": "프롬프트", "promptHelp": "실행할 AI 프롬프트. 작업에 대한 명확한 지침을 제공하세요.", "promptHint": "실행할 AI 프롬프트.", @@ -4675,10 +4698,10 @@ "routineSuccess": "\"{{name}}\" 성공적으로 완료됨", "routineUpdated": "루틴이 업데이트되었습니다", "runError": "루틴 실행에 실패했습니다", - "runHistory": "실행 기록 ({{count}})", + "runHistory_other": "", "runNameNow": "{{name}} 지금 실행", - "runNow": "지금 실행", "running": "실행 중…", + "runNow": "지금 실행", "saveChanges": "변경사항 저장", "saveStep": "단계 저장", "saving": "저장 중…", @@ -4695,15 +4718,15 @@ "simpleMode": "단순", "simpleModeHelp": "단일 셸 명령어 또는 AI 프롬프트 실행", "stepCommandRequired": "단계 {{index}}: 명령어는 필수입니다", - "stepCount": "{{count}}개 단계", + "stepCount_other": "", "stepName": "단계 이름", "stepNamePlaceholder": "예: 테스트 실행", "stepNameRequired": "단계 {{index}}: 이름은 필수입니다", "stepPromptRequired": "단계 {{index}}: 프롬프트는 필수입니다", - "stepType": "단계 유형", "steps": "단계", "stepsEditing": "스케줄을 저장하기 전에 모든 단계 편집을 저장하거나 취소하세요", "stepsRequired": "최소 하나의 단계가 필요합니다", + "stepType": "단계 유형", "targetColumn": "대상 열", "targetColumnHelp": "새 작업이 생성될 열", "targetColumnLabel": "대상 열", @@ -4755,7 +4778,11 @@ "webhookPathPlaceholder": "/trigger/my-routine", "webhookSecretHint": "서명 검증용 HMAC 시크릿. 미인증 웹훅은 비워두세요.", "webhookSecretLabel": "Webhook 시크릿 (선택 사항)", - "webhookSecretPlaceholder": "선택 사항 — 미인증 웹훅은 비워두세요" + "webhookSecretPlaceholder": "선택 사항 — 미인증 웹훅은 비워두세요", + "andMore_one": "", + "automationCount_one": "", + "runHistory_one": "", + "stepCount_one": "" }, "scriptsModal": { "addScript": "스크립트 추가", @@ -4780,14 +4807,15 @@ "saving": "저장 중...", "scriptAlreadyExists": "같은 이름의 스크립트가 이미 존재합니다", "scriptCommandRequired": "스크립트 명령이 필요합니다", - "scriptCount": "스크립트 {{count}}개", + "scriptCount_other": "", "scriptCreated": "스크립트가 생성되었습니다", "scriptDeleted": "스크립트가 삭제되었습니다", "scriptName": "스크립트 이름", "scriptNamePlaceholder": "예: build, test, lint", "scriptNameRequired": "스크립트 이름이 필요합니다", "scriptUpdated": "스크립트가 업데이트되었습니다", - "title": "스크립트" + "title": "스크립트", + "scriptCount_one": "" }, "secrets": { "accessPolicyAuto": "auto", @@ -4852,20 +4880,17 @@ "failed": "실패", "headerAwaitingAndErrorPlural": "AI 세션 {{awaitingCount}}개가 입력을 기다리고 있으며, {{errorCount}}개가 실패했습니다", "headerAwaitingAndErrorSingular": "AI 세션 {{awaitingCount}}개가 입력을 기다리고 있으며, {{errorCount}}개가 실패했습니다", - "headerAwaitingPlural": "AI 세션 {{count}}개가 입력을 기다리고 있습니다", - "headerAwaitingSingular": "AI 세션 {{count}}개가 입력을 기다리고 있습니다", - "headerErrorPlural": "AI 세션 {{count}}개가 실패했습니다", - "headerErrorSingular": "AI 세션 {{count}}개가 실패했습니다", + "headerAwaitingPlural_other": "", + "headerAwaitingSingular_other": "", + "headerErrorPlural_other": "", + "headerErrorSingular_other": "", "regionLabel": "입력 대기 중이거나 실패한 AI 세션", "resume": "재개", "retry": "재시도", - "typeLabel": { - "milestoneInterview": "마일스톤 인터뷰", - "missionInterview": "미션 인터뷰", - "planning": "계획", - "sliceInterview": "슬라이스 인터뷰", - "subtask": "하위 작업 분해" - } + "headerAwaitingPlural_one": "", + "headerAwaitingSingular_one": "", + "headerErrorPlural_one": "", + "headerErrorSingular_one": "" }, "settings": { "actions": { @@ -4876,7 +4901,6 @@ "language": "언어", "languageAuto": "자동", "languageAutoHint": "브라우저 언어를 따릅니다", - "languageHint": "{{brand}} 인터페이스의 언어를 선택하세요.", "title": "모양" }, "auth": { @@ -4930,8 +4954,8 @@ "general": { "learnMore": "더 알아보기", "settingsSaved": "설정이 저장되었습니다", - "upToDate": "최신 버전입니다 ✓", - "updateAvailablePrefix": "v{{version}} 사용 가능" + "updateAvailablePrefix": "v{{version}} 사용 가능", + "upToDate": "최신 버전입니다 ✓" }, "header": { "discord": "Discord" @@ -4941,8 +4965,8 @@ "exportBtn": "내보내기", "exportTitle": "JSON 파일로 설정 내보내기", "importBtn": "가져오기", - "importTitle": "설정 가져오기", "importing": "가져오는 중…", + "importTitle": "설정 가져오기", "loadingFile": "불러오는 중…", "reviewPrompt": "가져올 설정을 검토하세요:" }, @@ -4951,17 +4975,17 @@ "keepRemote": "원격 유지", "loading": "불러오는 중…", "memory": { - "compactSelectedFile": "선택한 파일 압축", "compacting": "압축 중…", + "compactSelectedFile": "선택한 파일 압축", "dreamCompleted": "드림 처리가 완료되었습니다", "dreamNow": "지금 드림", - "installQmd": "qmd 설치", "installing": "설치 중…", + "installQmd": "qmd 설치", "memoryCompacted": "메모리 파일이 압축되었습니다", "memorySaved": "메모리가 저장되었습니다", "saveMemory": "메모리 저장", - "testRetrieval": "검색 테스트", - "testing": "테스트 중…" + "testing": "테스트 중…", + "testRetrieval": "검색 테스트" }, "mergeManually": "수동으로 병합", "mobileNav": { @@ -4976,45 +5000,14 @@ "savePreset": "프리셋 저장" }, "nav": { - "accountHeader": "계정", - "agentPermissions": "에이전트 권한", - "appearance": "외관", "aria": { "global": "전역 설정", "project": "프로젝트 설정" }, - "authentication": "인증", - "backups": "백업", - "commands": "명령", - "experimental": "실험적 기능", - "globalGeneral": "일반", - "globalHeader": "전역", - "globalModels": "모델", - "hermesRuntime": "Hermes", - "memory": "메모리", - "merge": "병합", - "nodeRouting": "노드 라우팅", - "nodeSync": "노드 동기화", - "notifications": "알림", - "openclawRuntime": "OpenClaw", - "paperclipRuntime": "Paperclip", - "plugins": "플러그인", - "projectGeneral": "프로젝트 일반", - "projectHeader": "프로젝트", - "projectModels": "프로젝트 모델", - "prompts": "프롬프트", - "remote": "원격 접근", - "researchGlobal": "연구 기본값", - "researchProject": "연구", - "runtimesHeader": "런타임", - "scheduledEvals": "예약된 평가", - "scheduling": "스케줄링", - "secrets": "시크릿", "tooltip": { "global": "모든 프로젝트에서 공유됨", "project": "이 프로젝트에만 적용됨" - }, - "worktrees": "Worktrees" + } }, "notifications": { "sending": "전송 중…", @@ -5028,10 +5021,10 @@ "restarting": "재시작 중…", "shortLivedTokenGenerated": "단기 토큰이 생성되었습니다", "startFresh": "새로 시작", - "startTunnel": "터널 시작", "starting": "시작 중…", - "stopTunnel": "터널 중지", + "startTunnel": "터널 시작", "stopping": "중지 중…", + "stopTunnel": "터널 중지", "tunnelRestarted": "원격 터널이 재시작되었습니다", "tunnelStarted": "원격 터널이 시작되었습니다", "tunnelStopped": "원격 터널이 중지되었습니다", @@ -5039,8 +5032,8 @@ }, "resolveAllLocal": "모두 해결: 로컬 유지", "resolveAllRemote": "모두 해결: 원격 유지", - "resolveFailed": "충돌 해결에 실패했습니다", "resolvedSuccess": "설정 충돌이 성공적으로 해결되었습니다", + "resolveFailed": "충돌 해결에 실패했습니다", "resolving": "해결 중...", "scheduling": { "selectCurrentDir": "현재 디렉터리 선택", @@ -5068,46 +5061,11 @@ "aiSetupDescription": "Fusion은 AI 모델을 사용하여 코드를 계획, 작성 및 검토합니다. 아래에서 AI 공급자를 연결하여 시작하세요 — 호스팅 서비스를 사용하거나 API 키를 입력할 수 있습니다.", "allProvidersShown": "현재 사용 가능한 모든 공급자가 이미 위에 표시되어 있습니다.", "allSet": "모두 완료!", - "apiKeyFormatError": "{{providerName}} 키는 다음 형식을 따라야 합니다: {{hint}} (예: {{example}})", "apiKeyFormatHint": "형식: {{hint}}", "apiKeyHint": "키: {{keyHint}}", - "apiKeyLabel": { - "fallback": "API 키", - "kimiCoding": "Kimi API 키", - "minimax": "MiniMax API 키", - "ollama": "Ollama 엔드포인트", - "openai": "OpenAI API 키", - "openrouter": "OpenRouter API 키", - "zai": "Zhipu AI API 키" - }, - "apiKeyPlaceholder": { - "fallback": "API 키 입력", - "kimiCoding": "Kimi API 키를 입력하세요", - "minimax": "MiniMax API 키를 입력하세요", - "zai": "Zhipu AI API 키를 입력하세요" - }, "apiKeyRemoved": "API 키가 제거되었습니다", - "apiKeyRequired": "API 키가 필요합니다", "apiKeySaved": "✓ API 키가 저장되었습니다", "apiKeySavedToast": "API 키가 저장되었습니다", - "apiKeySetup": { - "fallback": "이 공급자의 API 키를 입력하세요.", - "kimiCoding": "Moonshot 플랫폼 계정 설정에서 API 키를 생성하세요.", - "minimax": "MiniMax 플랫폼 개발자 콘솔에서 API 키를 생성하세요.", - "ollama": "Ollama 엔드포인트 URL을 입력하세요 (예: http://localhost:11434).", - "openai": "OpenAI 대시보드의 API 키 메뉴에서 API 키를 생성하세요.", - "openrouter": "OpenRouter 계정 키 관리 페이지에서 API 키를 생성하세요.", - "zai": "Zhipu AI 오픈 플랫폼 계정 설정에서 API 키를 생성하세요." - }, - "apiKeyUsage": { - "fallback": "Fusion이 이 공급자에 요청을 인증하는 데 사용됩니다", - "kimiCoding": "작업 실행 및 계획에서 Kimi/Moonshot AI 모델에 사용됩니다", - "minimax": "작업 실행에서 MiniMax 모델에 사용됩니다", - "ollama": "로컬 Ollama 인스턴스에 연결합니다", - "openai": "작업 실행 및 계획에서 GPT 모델에 사용됩니다", - "openrouter": "단일 키를 통해 여러 AI 모델 공급자로 라우팅합니다", - "zai": "작업 실행에서 GLM 모델에 사용됩니다" - }, "ariaDismissRecommendations": "추천 닫기", "ariaSetupRecommendations": "설정 추천", "authCodeAlreadySubmitted": "이미 제출된 인증 코드입니다. 로그인을 기다리는 중…", @@ -5158,13 +5116,13 @@ "connectAiProvider": "AI 공급자 연결", "connectAiProviderDesc": "AI 에이전트의 작업 계획 및 코드 생성을 활성화하려면 AI 공급자를 연결하세요", "connectAnyway": "그래도 연결", + "connectedProviders": "연결된 공급자", "connectGitHub": "GitHub 연결", "connectGitHubAnytime": "준비가 되지 않았다면 괜찮습니다 — 설정 → 인증에서 언제든지 GitHub를 연결할 수 있습니다.", "connectGitHubButton": "GitHub 연결", "connectGitHubDesc": "GitHub를 연결하여 이슈를 가져오고 풀 리퀘스트를 추적하세요", "connectOauthOptional": "OAuth 연결 (선택 사항)", "connectRemoteServer": "원격 Fusion 서버 연결", - "connectedProviders": "연결된 공급자", "continueToLogin": "로그인 계속", "continueWithGhCli": "gh CLI 인증으로 계속 →", "continueWithoutGitHub": "GitHub 없이 계속 →", @@ -5229,10 +5187,10 @@ "githubSkipped": "GitHub를 건너뛰었습니다. 설정 → 인증에서 언제든지 연결할 수 있습니다.", "goBackToStep": "{{label}}(으)로 돌아가기", "goToDashboard": "대시보드로 이동", - "howDoIChooseModel": "모델을 어떻게 선택하나요?", - "howDoIChooseModelBody": "모델마다 속도, 성능, 비용이 다릅니다. 일반적으로 연결된 공급자의 최신 모델이 좋은 기본값입니다. 나중에 설정에서 언제든지 변경할 수 있습니다.", "howDoesLoginWork": "로그인은 어떻게 작동하나요?", "howDoesLoginWorkBody": "로그인을 클릭하면 새 탭에서 공급자 웹사이트가 열리고 로그인합니다. Fusion을 승인하면 이 페이지가 자동으로 연결을 감지합니다. 자격 증명은 Fusion에 저장되지 않습니다.", + "howDoIChooseModel": "모델을 어떻게 선택하나요?", + "howDoIChooseModelBody": "모델마다 속도, 성능, 비용이 다릅니다. 일반적으로 연결된 공급자의 최신 모델이 좋은 기본값입니다. 나중에 설정에서 언제든지 변경할 수 있습니다.", "importFromGitHub": "GitHub에서 가져오기", "importFromGitHubSubtitle": "GitHub 이슈를 여기서 추적할 수 있는 작업으로 전환하세요", "inProcess": "In-Process", @@ -5294,25 +5252,9 @@ "projectRequired": "첫 번째 작업 동작을 사용하려면 프로젝트가 필요합니다.", "projectSelected": "프로젝트 선택됨 — 작업 생성 및 가져오기를 사용할 수 있습니다.", "projectSetupDescription": "작업을 생성하거나 가져오기 전에 첫 번째 프로젝트를 선택하세요. 기존 로컬 디렉터리를 등록하거나, 설정 마법사를 통해 GitHub 저장소 URL을 클론할 수 있습니다.", - "providerDesc": { - "anthropic": "Claude 모델 — 추론, 분석, 코드에 강점", - "fallback": "AI 제공자 — 연결하여 AI 모델 사용 시작", - "gemini": "Gemini 모델 — 멀티모달 및 강력한 추론", - "google": "Gemini 모델 — 멀티모달 및 강력한 추론", - "kimi": "Kimi by Moonshot AI — 긴 컨텍스트 처리 능력", - "kimiCoding": "Kimi by Moonshot AI — 긴 컨텍스트 처리 능력", - "minimax": "MiniMax 모델 — 대용량 사용에 비용 효율적", - "moonshot": "Kimi by Moonshot AI — 긴 컨텍스트 처리 능력", - "ollama": "로컬 머신에서 오픈소스 모델 실행", - "openai": "GPT 모델 — 다양한 작업에 범용적", - "openaiCodex": "Codex models by OpenAI — 코딩 작업에 최적화", - "openrouter": "OpenRouter — 여러 AI 제공자로 요청 라우팅", - "zai": "GLM models by Zhipu AI — 강력한 다국어 지원" - }, "providersConnectedSummary": "✓ {{total}}개 제공자 중 {{connected}}개 연결됨", - "providersSkippedSummary": "{{count}}개 제공자 건너뜀", - "providersSkippedSummary_one": "{{count}}개 제공자 건너뜀", - "providersSkippedSummary_other": "{{count}}개 제공자 건너뜀", + "providersSkippedSummary_one_other": "", + "providersSkippedSummary_other_other": "", "quickStartProviders": "빠른 시작 제공자", "readinessAiProviderConnected": "{{name}} 연결됨 — AI 에이전트가 작업을 처리할 수 있습니다", "readinessAiProviderLabel": "AI 제공자", @@ -5331,8 +5273,8 @@ "readinessSummaryHeader": "설정 요약", "recommended": "권장", "recommendedNextSteps": "권장 다음 단계", - "registerProject": "프로젝트 등록", "registering": "등록 중...", + "registerProject": "프로젝트 등록", "remoteServerNote": "대시보드 핸드오프를 완료하려면 네이티브 셸에 활성 원격 프로필이 필요합니다.", "remoteServerProfileSaved": "원격 서버 프로필 저장됨", "removeKey": "키 제거", @@ -5346,9 +5288,9 @@ "retry": "재시도", "reviewStep": "{{label}} 검토", "runtimeNode": "런타임 노드", + "savedProfileButFailedToActivate": "프로필을 저장했지만 활성화에 실패했습니다", "saveKey": "저장", "saveRemoteServer": "원격 서버 저장", - "savedProfileButFailedToActivate": "프로필을 저장했지만 활성화에 실패했습니다", "saving": "저장 중…", "savingKey": "저장 중…", "savingRemoteServer": "저장 중…", @@ -5361,9 +5303,9 @@ "setToken": "토큰 설정", "setTokenContinue": "토큰 설정 및 계속", "setUpAi": "AI 설정", - "setUpProject": "프로젝트 설정", "setupComplete": "설정 완료! 보드로 이동하여 첫 번째 작업을 만들거나, 대시보드를 탐색해 보세요.", "setupMode": "설정 모드", + "setUpProject": "프로젝트 설정", "setupWizardHint": "설정 마법사에서 기존 디렉터리를 선택하거나 GitHub 클론 URL을 붙여넣으세요.", "skip": "건너뛰기", "skipForNow": "지금은 건너뛰기", @@ -5418,7 +5360,9 @@ "withoutGitHub1": "작업 수동 생성", "withoutGitHub2": "AI 에이전트를 위한 작업 설명", "withoutGitHub3": "보드에서 진행 상황 추적", - "withoutGitHubHeading": "GitHub 없이 (지금 사용 가능):" + "withoutGitHubHeading": "GitHub 없이 (지금 사용 가능):", + "providersSkippedSummary_one_one": "", + "providersSkippedSummary_other_one": "" }, "shell": { "activePill": "활성", @@ -5449,21 +5393,21 @@ "catalogUnavailable": "카탈로그를 일시적으로 사용할 수 없습니다. 나중에 다시 시도해 주세요.", "closeDetail": "스킬 상세 닫기", "closeView": "스킬 보기 닫기", - "disableSkill": "{{name}} 비활성화", "disabled": "스킬 비활성화됨", + "disableSkill": "{{name}} 비활성화", "discovered": "검색됨", - "discoveredCount": "{{count}}개 스킬 검색됨", + "discoveredCount_other": "", "discoveredSection": "검색된 스킬", - "enableSkill": "{{name}} 활성화", "enabled": "스킬 활성화됨", + "enableSkill": "{{name}} 활성화", "filesLabel": "파일", "install": "설치", "installError": "스킬 설치 실패", "installFailed": "{{name}} 설치 실패: {{message}}", - "installSkill": "{{name}} 설치", - "installSuccess": "{{name}} 설치됨", "installing": "설치 중…", "installsCount": "{{count}}회 설치", + "installSkill": "{{name}} 설치", + "installSuccess": "{{name}} 설치됨", "loadCatalogError": "카탈로그 로드 실패", "loadContentError": "스킬 콘텐츠 로드 실패", "loadDiscoveredError": "검색된 스킬 로드 실패", @@ -5483,7 +5427,8 @@ "title": "스킬", "toggleError": "스킬 전환 실패", "toggleFailed": "스킬 전환 실패: {{message}}", - "viewDetails": "{{name}} 상세 보기" + "viewDetails": "{{name}} 상세 보기", + "discoveredCount_one": "" }, "specEditor": { "edit": "편집", @@ -5491,8 +5436,8 @@ "feedbackPlaceholder": "예: '오류 처리에 대한 세부 사항 추가', '더 작은 단계로 분할', 'API 엔드포인트 테스트 포함'...", "keyboardHint": "Ctrl+Enter (또는 Cmd+Enter)를 눌러 저장", "placeholder": "Markdown으로 작업 명세를 입력하세요...", - "requestRevision": "AI 수정 요청", "requesting": "요청 중…", + "requestRevision": "AI 수정 요청", "revisionHelp": "AI가 이 명세를 개선할 수 있도록 피드백을 제공하세요. 작업은 재계획을 위해 계획 단계로 이동합니다.", "revisionTitle": "AI에게 수정 요청", "saving": "저장 중…", @@ -5514,15 +5459,17 @@ "dropTitle": "고아 스태시를 삭제하시겠습니까?", "failedToLoadDiff": "diff 로드 실패", "failedToLoadOrphans": "고아 항목 로드 실패", - "fileCount": "{{count}}개 파일", + "fileCount_other": "", "inspectDiff": "diff 검사", "loadingDiff": "diff 로드 중…", "noDiffOutput": "사용 가능한 diff 출력이 없습니다.", "noOrphans": "고아 merger 자동 스태시가 없습니다.", - "orphanCount": "{{count}}개 고아", + "orphanCount_other": "", "shaLabel": "SHA", "title": "스태시 복구", - "unknownSource": "알 수 없는 출처" + "unknownSource": "알 수 없는 출처", + "fileCount_one": "", + "orphanCount_one": "" }, "stepType": { "aiPrompt": "AI 프롬프트", @@ -5592,7 +5539,7 @@ "untitled": "제목 없음" }, "syncLog": { - "entryCount": "{{count}}개 항목", + "entryCount_other": "", "filterAll": "전체", "filterAllNodes": "모든 노드", "filterDirection": "방향:", @@ -5603,7 +5550,8 @@ "noHistory": "사용 가능한 동기화 기록이 없습니다", "resultConflict": "충돌", "resultError": "오류", - "resultSuccess": "성공" + "resultSuccess": "성공", + "entryCount_one": "" }, "systemStats": { "agentActive": "활성", @@ -5624,11 +5572,11 @@ "errorLoadVitestSettings": "vitest 설정 로드 실패", "errorSaveVitestSettings": "vitest 설정 저장 실패", "footerRefreshFailed": "최신 새로 고침 실패: {{error}}", + "killedProcesses_other": "", "killThresholdInputAriaLabel": "종료 임계값 (%)", "killThresholdLabel": "종료 임계값 (%)", "killThresholdSliderAriaLabel": "종료 임계값 슬라이더 (%)", "killVitest": "Vitest 프로세스 종료", - "killedProcesses": "{{count}}개 프로세스 종료됨", "lastAutoKill": "마지막 자동 종료: {{time}}", "loading": "시스템 통계 로드 중…", "notYet": "아직 없음", @@ -5667,23 +5615,19 @@ "title": "시스템 통계", "updatedAt": "{{time}} 업데이트됨", "vitestProcesses": "Vitest 프로세스", - "waitingFirstUpdate": "첫 업데이트 대기 중" + "waitingFirstUpdate": "첫 업데이트 대기 중", + "killedProcesses_one": "" }, "taskChanges": { "attributionFailed": "착륙된 파일 집합에 외부 커밋이 포함될 수 있습니다 (기여 정보 없음).", "disableWordWrap": "자동 줄 바꿈 해제", - "emptyWorktreeHint": "라이브 worktree diff가 비어 있습니다. 실행 중 캡처된 마지막 파일 경로를 표시합니다 — 패치를 사용할 수 없습니다.", "enableWordWrap": "자동 줄 바꿈 활성화", "error": "변경 사항 로드 오류: {{error}}", - "executionFilesHint": "실행 중 worktree에서 캡처된 파일입니다. main에 실제로 반영된 파일과 다를 수 있습니다. 이 작업에서는 계보 기반 diff를 사용할 수 없습니다.", "expandDiff": "전체 화면 diff 보기로 확장", "expandDiffView": "diff 보기 확장", - "fileCount": "{{count}}개 파일{{plural}} 변경됨.", - "filesChangedHeading": "변경된 파일 ({{count}})", - "landedFilesHint": "병합 커밋 메타데이터에서 캡처된 파일입니다. 이 작업에서는 계보 기반 diff를 사용할 수 없습니다.", + "filesChangedHeading_other": "", "loadError": "작업 변경 사항을 불러오지 못했습니다", "loading": "변경 사항 로드 중...", - "merged": "{{date}} 병합됨", "mergedAt": "{{date}} 병합됨", "nextFile": "다음 파일", "noExecutionModifications": "에이전트가 실행 중 파일을 수정하지 않았습니다.", @@ -5694,15 +5638,28 @@ "noWorktree": "이 작업에 사용 가능한 worktree가 없습니다.", "noWorktreeHint": "작업이 진행 중일 때 변경 사항이 표시됩니다.", "previousFile": "이전 파일", - "statusUnknown": "상태 알 수 없음", "summaryHint": "최종 커밋 요약: {{files}}개 파일{{plural}} 변경, +{{additions}} 추가, -{{deletions}} 삭제. 전체 작업 계보가 아닌 기록된 병합/스쿼시 커밋만 포함됩니다.", "toggleWordWrap": "자동 줄 바꿈 전환", - "unavailable": "상세 파일 변경 사항을 사용할 수 없습니다." + "unavailable": "상세 파일 변경 사항을 사용할 수 없습니다.", + "filesChangedHeading_one": "" }, "taskDetail": { "actions": { "menuBtn": "작업" }, + "agent": { + "assignBtn": "에이전트 할당", + "assignedUpdated": "할당된 에이전트가 업데이트되었습니다", + "assignFailed": "에이전트 할당 실패: {{error}}", + "label": "에이전트", + "loadFailed": "에이전트 로드 실패: {{error}}", + "loadingAgents": "에이전트 로드 중...", + "noAgents": "사용 가능한 에이전트 없음", + "unassigned": "에이전트 할당이 해제되었습니다", + "unassignFailed": "에이전트 할당 해제 실패: {{error}}", + "unassignTitle": "에이전트 할당 해제" + }, + "agentLink": "에이전트 {{id}}", "ageStaleness": { "active": "활성", "age": "경과 시간", @@ -5713,24 +5670,11 @@ "title": "작업 경과 오래됨", "warning": "경고" }, - "agent": { - "assignBtn": "에이전트 할당", - "assignFailed": "에이전트 할당 실패: {{error}}", - "assignedUpdated": "할당된 에이전트가 업데이트되었습니다", - "label": "에이전트", - "loadFailed": "에이전트 로드 실패: {{error}}", - "loadingAgents": "에이전트 로드 중...", - "noAgents": "사용 가능한 에이전트 없음", - "unassignFailed": "에이전트 할당 해제 실패: {{error}}", - "unassignTitle": "에이전트 할당 해제", - "unassigned": "에이전트 할당이 해제되었습니다" - }, - "agentLink": "에이전트 {{id}}", "attachments": { "attachBtn": "스크린샷 첨부", "attached": "스크린샷이 첨부되었습니다", - "deleteTitle": "첨부 파일 삭제", "deleted": "첨부 파일이 삭제되었습니다", + "deleteTitle": "첨부 파일 삭제", "heading": "첨부 파일", "none": "(첨부 파일 없음)", "uploading": "업로드 중…" @@ -5749,9 +5693,11 @@ "reattachBtn": "브랜치 재연결", "reattached": "{{id}}의 브랜치가 재연결되었습니다 ({{branch}})", "reattachedResult": "{{branch}} 재연결됨 ({{base}} 기준 {{count}}개 커밋 앞).", + "reattachedResult_other": "", "reattaching": "재연결 중…", "skipped": "{{id}}의 브랜치 재연결을 건너뜀: {{reason}}", - "skippedResult": "재연결 건너뜀: {{reason}}" + "skippedResult": "재연결 건너뜀: {{reason}}", + "reattachedResult_one": "" }, "cacheBreakdown": "(읽기 {{read}} / 쓰기 {{write}} / 입력 {{input}})", "cacheHitRatio": "캐시 적중률:", @@ -5764,21 +5710,21 @@ "actionLeft": "유지됨", "allowRecreation": "나중에 재생성 허용 (운영자 잠금 해제)", "allowRecreationDesc": "에이전트가 --force-resurrect 없이 이 작업 ID를 재생성할 수 있습니다. 이 작업을 삭제 표시로 유지하려면 선택 해제하세요.", + "archivedAfterUnlink": "계보 참조 연결 해제 후 {{id}} 보관됨", "archiveInstead": "보관으로 대체", "archiveUnlinkPrompt": "이 참조를 먼저 연결 해제하고 보관하시겠습니까?", - "archivedAfterUnlink": "계보 참조 연결 해제 후 {{id}} 보관됨", "ariaLabel": "작업 삭제", "btn": "삭제", "closeIssue": "이슈 닫기", "confirm": "삭제", + "deletedAfterRemovingDeps": "의존성 참조 제거 후 {{id}} 삭제됨", + "deletedAfterUnlinkLineage": "계보 참조 연결 해제 후 {{id}} 삭제됨", + "deletedToast": "{{id}} 삭제됨{{suffix}}", "deleteIssue": "이슈 삭제", "deleteLinkedIssueMessage": "GitHub에서 {{issueRef}}를 삭제하시겠습니까, 아니면 변경하지 않고 유지하시겠습니까?", "deleteLinkedIssueTitle": "연결된 GitHub 이슈 삭제", "deleteUnlinkDepsPrompt": "이 의존성 참조를 먼저 제거하고 삭제하시겠습니까?", "deleteUnlinkLineagePrompt": "이 참조를 먼저 연결 해제하고 삭제하시겠습니까?", - "deletedAfterRemovingDeps": "의존성 참조 제거 후 {{id}} 삭제됨", - "deletedAfterUnlinkLineage": "계보 참조 연결 해제 후 {{id}} 삭제됨", - "deletedToast": "{{id}} 삭제됨{{suffix}}", "forceDeleteTitle": "강제 작업 삭제", "issueSuffix": "및 이슈 {{ref}} {{action}}", "leaveUnchanged": "변경하지 않음", @@ -5816,8 +5762,8 @@ "autosaveHint": "편집 시 변경 사항이 자동 저장됩니다", "autosaving": "자동 저장 중…", "nodeOverrideLocked": "작업이 활성/진행 중일 때는 실행 노드 재정의가 잠겨 있습니다.", - "saveFailed": "저장 실패", "saved": "저장됨", + "saveFailed": "저장 실패", "saving": "저장 중…", "sourceExternalIdPlaceholder": "이슈 식별자", "sourceIssueHint": "모든 필드를 비워 두면 소스 이슈 메타데이터가 삭제됩니다.", @@ -5892,7 +5838,8 @@ "activityHeading": "활동", "agentLog": "에이전트 로그", "noActivity": "(활동 없음)", - "truncated": "최근 {{count}}개의 활동 항목을 표시합니다." + "truncated_other": "", + "truncated_one": "" }, "longestTimingEvent": "가장 긴 타이밍 이벤트", "longestWorkflowStep": "가장 긴 워크플로 단계", @@ -5908,8 +5855,8 @@ "backToInProgress": "진행 중으로 돌아가기", "cancelMove": "이동 취소", "keepProgress": "진행 상태 유지", - "moveTo": "{{column}}으로 이동", "movedTo": "{{column}}으로 이동됨", + "moveTo": "{{column}}으로 이동", "preserveProgressMessage": "이 작업에 완료된 단계가 있습니다. 이동 전 진행 상태를 유지하시겠습니까?", "preserveProgressTitle": "진행 상태 유지?", "resetProgress": "진행 상태 초기화", @@ -5920,9 +5867,9 @@ "actions": "보관을 선택하면 이 작업이 보관 상태로 이동하고, 유지를 선택하면 이 작업을 계속합니다.", "archiveBtn": "보관", "archiveConfirm": "보관", + "archived": "{{id}} 보관됨", "archiveMessage": "{{id}}를 {{duplicateOf}}의 중복으로 보관하시겠습니까?", "archiveTitle": "근사 중복 작업 보관", - "archived": "{{id}} 보관됨", "copy": "이 작업은 다음의 근사 중복으로 보입니다", "headline": "잠재적 중복 감지됨", "keepBtn": "유지", @@ -5941,8 +5888,8 @@ "noSteps": "단계 없음", "noTimedEvents": "아직 기록된 타이밍 이벤트가 없습니다.", "noTokenUsage": "이 작업에 대해 아직 기록된 토큰 사용량이 없습니다.", - "noWorkflowStepTimings": "완료된 워크플로 단계 타이밍이 아직 없습니다.", "notSet": "설정되지 않음", + "noWorkflowStepTimings": "완료된 워크플로 단계 타이밍이 아직 없습니다.", "outputTokens": "출력", "pause": { "pauseBtn": "일시 정지", @@ -5959,9 +5906,9 @@ "rebuildMessage": "이 작업의 계획을 다시 빌드하시겠습니까? 작업이 재계획을 위해 계획 단계로 이동합니다.", "rebuildTitle": "계획 재빌드", "rejectBtn": "계획 거부", + "rejected": "계획 거부됨 — {{id}}이(가) 재계획을 위해 Planning으로 반환됨", "rejectMessage": "이 계획을 거부하시겠습니까? 명세가 삭제되고 재생성됩니다.", "rejectTitle": "계획 거부", - "rejected": "계획 거부됨 — {{id}}이(가) 재계획을 위해 Planning으로 반환됨", "replanning": "{{id}} 재계획 중…" }, "pr": { @@ -5983,31 +5930,17 @@ "progress": { "heading": "진행 상태", "noSteps": "(정의된 단계 없음)", - "stepCount": "{{total}}단계 중 {{count}}단계" + "stepCount_other": "", + "stepCount_one": "" }, "provenance": { - "agent": "에이전트", - "api": "API", - "automation": "자동화", - "chatSession": "채팅 세션", - "cli": "CLI", "createdBy": "작성자", - "createdVia": "생성 경로", - "dashboard": "대시보드", - "duplicate": "복제", - "githubImport": "GitHub 가져오기", - "openIssue": "이슈 열기", - "quickChat": "빠른 채팅", - "recovery": "복구", - "refinement": "개선", - "research": "조사", - "scheduledTask": "예약 작업", - "workflowStep": "워크플로 단계" + "createdVia": "생성 경로" }, "recoveryState": "복구 상태", "refine": { "btn": "개선", - "charCount": "{{count}}/2000자", + "charCount_other": "", "createBtn": "개선 작업 생성", "creating": "생성 중...", "feedbackRequired": "개선이 필요한 내용을 설명하는 피드백을 입력하세요", @@ -6015,7 +5948,8 @@ "help": "개선하거나 향상시킬 내용을 설명하세요...", "modalTitle": "개선", "placeholder": "피드백을 입력하세요...", - "taskCreated": "개선 작업 생성됨: {{id}}" + "taskCreated": "개선 작업 생성됨: {{id}}", + "charCount_one": "" }, "reset": { "btn": "초기화", @@ -6082,8 +6016,8 @@ "loading": "명세 로드 중…", "noPrompt": "(프롬프트 없음)", "placeholder": "Markdown으로 작업 명세를 입력하세요...", - "requestRevisionBtn": "AI 수정 요청", "requesting": "요청 중…", + "requestRevisionBtn": "AI 수정 요청", "revisionColumnError": "수정 요청 불가: 작업이 'triage', 'todo', 'in-progress' 또는 'in-review' 컬럼에 있어야 합니다.", "revisionRequested": "AI 수정 요청됨. 작업이 계획 단계로 이동되었습니다.", "saving": "저장 중…", @@ -6129,8 +6063,8 @@ "wallClockSinceFirst": "첫 번째 실행 이후 경과 시간", "workflow": { "loadFailed": "워크플로 결과 로드 실패: {{error}}", - "stepsUpdateFailed": "워크플로 단계 업데이트 실패: {{error}}", - "stepsUpdated": "워크플로 단계 업데이트됨" + "stepsUpdated": "워크플로 단계 업데이트됨", + "stepsUpdateFailed": "워크플로 단계 업데이트 실패: {{error}}" }, "workflowRuntime": "워크플로 런타임", "workflowTimedSteps": "워크플로 시간 측정 단계", @@ -6181,8 +6115,8 @@ "taskForm": { "addDependencies": "의존성 추가", "attachHint": "이미지를 붙여넣거나 끌어다 놓을 수도 있습니다", - "attachScreenshot": "스크린샷 첨부", "attachmentsLabel": "첨부 파일", + "attachScreenshot": "스크린샷 첨부", "autoMergeDefault": "기본값 (프로젝트 설정 따르기)", "autoMergeDisabled": "비활성화됨", "autoMergeEnabled": "활성화됨", @@ -6205,7 +6139,7 @@ "branchStrategyLabel": "브랜치 전략", "collapseDescription": "설명 접기", "dependenciesLabel": "의존성", - "dependenciesSelected": "{{count}}개 선택됨", + "dependenciesSelected_other": "", "descriptionLabel": "설명", "descriptionPlaceholder": "무엇을 해야 하나요?", "descriptionRefinedToast": "AI로 설명이 다듬어졌습니다", @@ -6228,17 +6162,11 @@ "moveDown": "아래로 이동", "moveUp": "위로 이동", "noAvailableTasks": "사용 가능한 작업이 없습니다", - "noModelsAvailable": "사용 가능한 모델이 없습니다. 설정에서 인증을 구성해 주세요.", "nodeDefaultOption": "프로젝트 기본값 / 로컬 사용", "nodeOverrideHint": "작업 재정의는 프로젝트 기본 노드 라우팅보다 우선합니다.", "nodeOverrideLabel": "실행 노드 재정의", - "nodeStatusConnecting": "연결 중", - "nodeStatusError": "오류", - "nodeStatusOffline": "오프라인", - "nodeStatusOnline": "온라인", + "noModelsAvailable": "사용 가능한 모델이 없습니다. 설정에서 인증을 구성해 주세요.", "overridePreset": "재정의", - "phasePostMerge": "병합 후", - "phasePreMerge": "병합 전", "planButton": "계획", "planningLabel": "계획 수립", "planningModelLabel": "계획 모델", @@ -6246,10 +6174,6 @@ "presetLabel": "프리셋", "presetUseDefault": "기본값 사용", "priorityLabel": "우선순위", - "priority_high": "높음", - "priority_low": "낮음", - "priority_normal": "보통", - "priority_urgent": "긴급", "refineAddDetailsDesc": "구현 세부 사항과 맥락 추가", "refineAddDetailsTitle": "세부 사항 추가", "refineButton": "다듬기", @@ -6264,13 +6188,13 @@ "removeImage": "이미지 제거", "removeStep": "제거", "reviewDefault": "기본값 (자동 — 트리아지가 결정)", + "reviewerLabel": "검토자", + "reviewerModelLabel": "검토자 모델", "reviewLabel": "검토", "reviewLevel0": "0 — 없음", "reviewLevel1": "1 — 계획만", "reviewLevel2": "2 — 계획 및 코드", "reviewLevel3": "3 — 전체", - "reviewerLabel": "검토자", - "reviewerModelLabel": "검토자 모델", "searchTasksPlaceholder": "작업 검색…", "sharedBranchPlaceholder": "예: clionboarding", "sharedFeatureBranchLabel": "공유 피처 브랜치", @@ -6288,7 +6212,8 @@ "usingPreset": "프리셋 사용 중: {{name}}", "workflowStepsDescription": "작업 구현 완료 후 실행할 단계 선택", "workflowStepsLabel": "워크플로우 단계", - "workingBranchLabel": "작업 브랜치" + "workingBranchLabel": "작업 브랜치", + "dependenciesSelected_one": "" }, "taskHandlers": { "githubImported": "GitHub에서 {{id}}를 가져왔습니다" @@ -6297,96 +6222,91 @@ "autoMergeOff": "자동 병합 끔", "autoMergeOn": "자동 병합 켬", "autoMergePreferenceUpdated": "작업별 자동 병합 기본 설정이 업데이트되었습니다", - "completed": "완료됨", "completedAtSep": " · 완료: {{timestamp}}", "createPr": "PR 생성", "effective": "적용: {{label}}", "effectiveFrozen": "적용: {{label}} — 검토 진입 시 고정됨", - "error": "오류", "errorSep": " · 오류: {{message}}", "followDefault": "기본값 따르기", - "lastRefreshed": "마지막 갱신", "loadError": "검토 데이터를 불러오지 못했습니다.", "loadingData": "검토 데이터 불러오는 중…", "markdown": "Markdown", - "never": "없음", "noCapturedFeedback": "아직 캡처된 검토 피드백이 없습니다.", "noFeedbackDirect": "아직 검토자 피드백이 없습니다 — 이 작업은 직접 모드에서 검토자 에이전트 피드백을 생성하지 않았습니다.", "noReviewItems": "아직 검토 항목이 없습니다.", "perTaskAutoMerge": "작업별 자동 병합", "plain": "일반 텍스트", - "prSummaryLine": "{{decision}} · {{count}}개의 검토 항목", + "prSummaryLine_other": "", "queueing": "대기열에 추가 중…", "refresh": "새로 고침", "refreshDataFailed": "검토 데이터 새로 고침에 실패했습니다.", - "refreshFailed": "새로 고침 실패", - "refreshSourceBackground": "백그라운드", - "refreshSourceInitialLoad": "초기 로드", - "refreshSourceManual": "수동", - "refreshStatusLine": "{{status}} · 마지막 갱신: {{timestamp}} · {{source}}", "refreshed": "검토가 새로 고침되었습니다", + "refreshFailed": "새로 고침 실패", "refreshing": "새로 고침 중…", + "refreshStatusLine": "{{status}} · 마지막 갱신: {{timestamp}} · {{source}}", "requestRevision": "재검토 요청", - "reviewerSummaryLine": "{{reviewer}} · {{count}}개의 검토 항목", + "reviewerSummaryLine_other": "", "revisionQueueFailed": "개정 대기열 추가에 실패했습니다", "revisionStarted": "선택한 검토 피드백을 기반으로 동일 작업 AI 개정이 시작되었습니다", - "selected": "선택됨", "selectedAt": "선택됨: {{timestamp}}", "showMarkdown": "서식 있는 Markdown 표시", "showRawText": "원시 텍스트 표시", - "started": "시작됨", "startedAtSep": " · 시작: {{timestamp}}", + "updateFailed": "{{taskId}} 업데이트에 실패했습니다: {{error}}", "upToDate": "최신 상태", - "updateFailed": "{{taskId}} 업데이트에 실패했습니다: {{error}}" + "prSummaryLine_one": "", + "reviewerSummaryLine_one": "" }, "tasks": { "addTaskPlaceholder": "작업 추가...", "agent": "에이전트", "agentLabel": "에이전트", "archive": "보관", + "archived": "{{taskId}}을(를) 보관했습니다", + "archivedUnlinked": "계보 참조 해제 후 {{taskId}}을(를) 보관했습니다", "archiveFailed": "{{taskId}} 보관에 실패했습니다: {{error}}", "archiveLineageConflict": "{{taskId}}에는 소스 부모로 참조하는 계보 자식({{children}})이 있습니다.\n\n이 참조를 먼저 해제하고 보관하시겠습니까?", "archiveTask": "작업 보관", - "archived": "{{taskId}}을(를) 보관했습니다", - "archivedUnlinked": "계보 참조 해제 후 {{taskId}}을(를) 보관했습니다", "assignedTo": "{{name}}에게 할당됨", "attach": "첨부", - "attachCount": "첨부 ({{count}})", - "attachFileFailed": "{{fileName}} 첨부에 실패했습니다: {{error}}", + "attachCount_other": "", "attachedFile": "{{taskId}}에 {{fileName}}을(를) 첨부했습니다", + "attachFileFailed": "{{fileName}} 첨부에 실패했습니다: {{error}}", "awaitingApproval": "승인 대기 중", "baseBranch": "베이스", "blockedByTooltip": "{{taskId}}에 의해 차단됨 (파일 충돌)", "branch": "브랜치", "branchMetadata": "브랜치 메타데이터", + "branchProgress": "", + "branchProgressTitle": "", "cancelMove": "이동 취소", "clearSelection": "선택 해제", "closeIssue": "이슈 닫기", "collapse": "접기", + "createdByAgent": "에이전트가 생성함", + "createdByAgentNamed": "에이전트가 생성함: {{name}}", + "createdPr": "PR #{{number}} 생성됨", "createFailed": "작업 생성에 실패했습니다", "createPr": "PR 생성", "createPrAriaLabel": "풀 리퀘스트 생성", "createPrTitle": "이 작업에 대한 PR 생성", "createTaskTitle": "작업 생성", - "createdByAgent": "에이전트가 생성함", - "createdByAgentNamed": "에이전트가 생성함: {{name}}", - "createdPr": "PR #{{number}} 생성됨", "creating": "생성 중...", "decisionOnly": "의사결정만", "decisionOnlyTitle": "의사결정 전용 작업", "deleteConfirm": "{{taskId}}을(를) 삭제하시겠습니까?", + "deleted": "{{taskId}}{{suffix}}을(를) 삭제했습니다", + "deletedRemovedDeps": "의존성 참조 제거 후 {{taskId}}을(를) 삭제했습니다", + "deletedUnlinked": "계보 참조 해제 후 {{taskId}}을(를) 삭제했습니다", "deleteFailed": "{{taskId}} 삭제에 실패했습니다: {{error}}", "deleteIssue": "이슈 삭제", "deleteLinkedIssueMessage": "GitHub의 {{issueLabel}}을(를) 삭제하거나 그대로 두시겠습니까?", "deleteLinkedIssueTitle": "연결된 GitHub 이슈 삭제", "deleteTask": "작업 삭제", "deleteTitle": "작업 삭제", - "deleted": "{{taskId}}{{suffix}}을(를) 삭제했습니다", - "deletedRemovedDeps": "의존성 참조 제거 후 {{taskId}}을(를) 삭제했습니다", - "deletedUnlinked": "계보 참조 해제 후 {{taskId}}을(를) 삭제했습니다", "dependencyConflict": "{{taskId}}은(는) {{dependentList}}의 의존성입니다.\n\n이 의존성 참조를 먼저 제거하고 삭제하시겠습니까?", "deps": "의존성", - "depsCount": "의존성 {{count}}개", + "depsCount_other": "", "descriptionPlaceholder": "작업 설명", "descriptionRefined": "AI로 설명이 다듬어졌습니다", "doneNoMerge": "완료 (병합 없음)", @@ -6406,11 +6326,11 @@ "fanoutEscalated": "에스컬레이션된 중복", "fanoutEscalationSuffix": " · 차단 열에서 {{minutes}}분 후 에스컬레이션됨", "fanoutHighFanoutSuffix": " (중복 병목 임계값: {{threshold}})", - "fanoutStale": "{{count}}개 오래됨", - "fanoutTooltip": "{{count}}개의 활성 작업 차단 중; 중복 차단 대기열: {{queueCount}}개 할 일{{highFanout}}{{escalation}}", + "fanoutStale_other": "", + "fanoutTooltip_other": "", "fast": "빠름", "fastMode": "빠른 모드", - "filesChanged": "{{count}}개 파일 변경됨", + "filesChanged_other": "", "forceDeleteTitle": "작업 강제 삭제", "githubTrackingDefaultOff": "끔", "githubTrackingDefaultOn": "켬", @@ -6438,23 +6358,24 @@ "loadAgentsFailed": "에이전트 불러오기에 실패했습니다: {{msg}}", "loadAgentsFailedGeneric": "에이전트 불러오기에 실패했습니다", "loadDependencyFailed": "의존성 {{depId}} 불러오기에 실패했습니다", - "loadModelsFailed": "모델 불러오기에 실패했습니다", "loadingAgents": "에이전트 불러오는 중...", + "loadModelsFailed": "모델 불러오기에 실패했습니다", "missionBadgeTitle": "미션: {{name}}", "modelExecutor": "실행기", "modelPlan": "계획", "modelReviewer": "검토자", "models": "모델", - "modelsCount": "모델 {{count}}개", + "modelsCount_other": "", "moreOptions": "추가 옵션", "move": "이동", + "moved": "{{taskId}}을(를) {{column}}으로 이동했습니다", "moveFailed": "{{taskId}} 이동에 실패했습니다: {{error}}", "moveTask": "작업 이동", - "moved": "{{taskId}}을(를) {{column}}으로 이동했습니다", "nearDuplicateTitle": "{{id}}의 잠재적 유사 중복", + "needsInput": "", "noAgentsAvailable": "사용 가능한 에이전트가 없습니다", - "noExistingTasks": "기존 작업이 없습니다", "node": "노드", + "noExistingTasks": "기존 작업이 없습니다", "openRetryBreakdown": "재시도 내역 열기", "paused": "일시 중지됨", "pausedByAgent": "에이전트에 의해 일시 중지됨", @@ -6482,7 +6403,7 @@ "resetProgress": "진행 상황 초기화", "resetProgressMessage": "이 작업을 이동하기 전에 모든 단계 진행 상황을 초기화하시겠습니까?", "resetProgressTitle": "진행 상황을 초기화하시겠습니까?", - "retriesAriaLabel": "재시도 {{count}}회", + "retriesAriaLabel_other": "", "retry": "재시도", "retryFailed": "{{taskId}} 재시도에 실패했습니다: {{error}}", "retrying": "재시도 중…", @@ -6498,22 +6419,30 @@ "showSteps": "단계 표시", "stalled": "중단됨", "statusMergingFix": "수정 사항 병합 중…", - "stepCount": "{{count}}개 단계", + "stepCount_other": "", "stuck": "막힘", "subtask": "하위 작업", "subtaskButtonTitle": "AI 생성 하위 작업으로 분해", "toggleFastMode": "빠른 실행 모드 전환", "unarchive": "보관 해제", + "unarchived": "{{taskId}}의 보관을 해제했습니다", "unarchiveFailed": "{{taskId}} 보관 해제에 실패했습니다: {{error}}", "unarchiveTask": "작업 보관 해제", - "unarchived": "{{taskId}}의 보관을 해제했습니다", - "updateFailed": "{{taskId}} 업데이트에 실패했습니다: {{error}}", "updated": "{{taskId}}이(가) 업데이트되었습니다", + "updateFailed": "{{taskId}} 업데이트에 실패했습니다: {{error}}", "uploadFailed": "업로드에 실패했습니다: {{files}}", "usingDefault": "기본값 사용 중", "viewDependency": "클릭하여 {{depId}} 보기", "workflow": "워크플로우", - "workflowCheck": "워크플로우 확인" + "workflowCheck": "워크플로우 확인", + "attachCount_one": "", + "depsCount_one": "", + "fanoutStale_one": "", + "fanoutTooltip_one": "", + "filesChanged_one": "", + "modelsCount_one": "", + "retriesAriaLabel_one": "", + "stepCount_one": "" }, "terminal": { "clear": "지우기", @@ -6539,37 +6468,14 @@ "statusReconnecting": "재연결 중..." }, "theme": { - "colorTheme": { - "default": "기본값" - }, + "colorTheme": "", "colorThemeLabel": "색상 테마", "currentTheme": "현재 테마", - "dark": "어두움", - "darkMode": "다크 모드", - "fontSize": { - "Default": "기본값", - "Large": "크게", - "Largest": "가장 크게", - "Small": "작게" - }, + "fontSize": "", "fontSizeLabel": "대시보드 글꼴 크기", - "light": "밝음", - "lightMode": "라이트 모드", "modeLabel": "테마 모드", "resetButton": "기본값으로 재설정", - "resetLabel": "기본 테마로 초기화", - "system": "시스템", - "systemMode": "시스템 모드" - }, - "time": { - "daysAgo": "{{n}}일 전", - "hoursAgo": "{{n}}시간 전", - "inAMoment": "잠시 후", - "inDays": "{{n}}일 후", - "inHours": "{{n}}시간 후", - "inMinutes": "{{n}}분 후", - "justNow": "방금", - "minutesAgo": "{{n}}분 전" + "resetLabel": "기본 테마로 초기화" }, "todo": { "addItemPlaceholder": "할 일 항목 추가", @@ -6593,6 +6499,7 @@ "failedDeleteItemToast": "할 일 항목 삭제에 실패했습니다", "failedDeleteList": "목록 삭제 실패", "failedDeleteListToast": "할 일 목록 삭제에 실패했습니다", + "failedLoadLists": "", "failedRenameList": "목록 이름 바꾸기 실패", "failedRenameListToast": "할 일 목록 이름 바꾸기에 실패했습니다", "failedReorderItems": "항목 순서 변경 실패", @@ -6653,19 +6560,20 @@ "resetsInDaysHours": "{{days}}일 {{hours}}시간 후 초기화", "resetsInHours": "{{hours}}시간 후 초기화", "resetsInMinutes": "{{mins}}분 후 초기화", - "showHidden": "숨김 항목 표시 ({{count}})", + "showHidden_other": "", "statusError": "오류", "statusNotConfigured": "구성되지 않음", "title": "사용량", "viewModeLabel": "사용량 보기 모드", "viewModeRemaining": "남은 양", - "viewModeUsed": "사용량" + "viewModeUsed": "사용량", + "showHidden_one": "" }, "workflow": { "add": "추가", + "adding": "추가 중...", "addTemplate": "템플릿 추가", "addWorkflowStep": "워크플로 단계 추가", - "adding": "추가 중...", "advisoryExplanation": "권고 워크플로 단계에서 비차단 개선 사항이 감지되었습니다:", "agentPromptLabel": "에이전트 프롬프트", "agentPromptPlaceholder": "비워두면 AI 개선을 사용합니다", @@ -6719,6 +6627,7 @@ "gateModeAdvisoryHint": "실패는 권고 사항으로 기록되며 병합을 차단하지 않습니다.", "gateModeGate": "게이트", "gateModeGateHint": "실패 시 병합을 차단하고 수정을 요청합니다.", + "graphEditor": "", "hideOutput": "출력 숨기기", "loadingBuiltInTemplates": "기본 제공 템플릿 불러오는 중...", "loadingResults": "워크플로 결과 불러오는 중…", @@ -6727,12 +6636,12 @@ "modalAriaLabel": "워크플로 단계", "modalTitle": "워크플로 단계", "modeAiPrompt": "AI 프롬프트", - "modeScript": "스크립트 실행", "modelHintCustom": "{{provider}}/{{modelId}} 사용 중", "modelHintDefault": "전역 기본 모델 사용 중", "modelOverrideDropdownLabel": "이 워크플로 단계의 모델 재정의", "modelOverrideLabel": "모델 재정의", "modelOverridePlaceholder": "모델 재정의 선택…", + "modeScript": "스크립트 실행", "moveDown": "아래로 이동", "moveUp": "위로 이동", "needsReview": "후속 검토가 필요합니다.", @@ -6749,8 +6658,6 @@ "phasePreMergeHint": "병합 전에 실행됩니다 — 실패 시 병합을 차단할 수 있습니다", "plain": "일반 텍스트", "polishNotes": "노트 다듬기", - "postMerge": "병합 후", - "preMerge": "병합 전", "promptRefined": "AI로 프롬프트 개선됨", "refineWithAi": "AI로 개선", "refineWithAiAriaLabel": "AI로 프롬프트 개선", @@ -6761,31 +6668,87 @@ "selectStepsDescription": "작업 구현 완료 후 실행할 단계를 선택하세요", "showOutput": "출력 표시", "started": "시작됨:", - "statusAdvisory": "권고 실패", - "statusFailed": "실패", - "statusPassed": "통과", - "statusRunning": "실행 중…", - "statusSkipped": "건너뜀", - "stepCount": "{{count}}개 단계{{count_one::count_other:}}", + "stepCount_other": "", "stepCreated": "워크플로 단계 생성됨", "stepDefinitionNotFound": "단계 정의를 찾을 수 없습니다.", "stepDeleted": "워크플로 단계 삭제됨", - "stepUpdated": "워크플로 단계 업데이트됨", "steps": "워크플로 단계", "stepsExplanation": "병합 전 단계는 구현 후, 병합 전에 실행됩니다. 병합 후 단계는 병합 성공 후 실행됩니다.", - "summaryAdvisory": "{{count}}개 권고", - "summaryFailed": "{{count}}개 실패", - "summaryPassed": "{{count}}개 통과", - "summaryRunning": "{{count}}개 실행 중", + "stepUpdated": "워크플로 단계 업데이트됨", + "summaryAdvisory_other": "", + "summaryFailed_other": "", + "summaryPassed_other": "", + "summaryRunning_other": "", "summarySeparator": " · ", - "summarySkipped": "{{count}}개 건너뜀", + "summarySkipped_other": "", + "summaryStepCount_other": "", "switchToMarkdown": "Markdown으로 전환", "switchToPlain": "일반 텍스트로 전환", - "tabMySteps": "내 워크플로 단계 ({{count}})", - "tabTemplates": "템플릿 ({{count}})", + "tabMySteps_other": "", + "tabTemplates_other": "", "templateAdded": "{{name}} 워크플로 단계 추가됨", "useDefault": "기본값 사용", - "waitingForOutput": "에이전트 출력 대기 중…" + "stepCount_one": "", + "summaryAdvisory_one": "", + "summaryFailed_one": "", + "summaryPassed_one": "", + "summaryRunning_one": "", + "summarySkipped_one": "", + "summaryStepCount_one": "", + "tabMySteps_one": "", + "tabTemplates_one": "" + }, + "workflowColumns": { + "add": "", + "compositionBlocked": "", + "empty": "", + "moveDown": "", + "moveUp": "", + "nameLabel": "", + "newColumnName": "", + "nodeUnplaced": "", + "readOnlyHint": "", + "remove": "", + "title": "", + "traits": "", + "traitsLoadFailed": "", + "unplacedCount_other": "", + "unplacedCount_one": "" + }, + "workflowNodes": { + "advisory": "", + "failureCollect": "", + "failureFailFast": "", + "failurePolicy": "", + "gateBlocks": "", + "gateMode": "", + "joinAll": "", + "joinAny": "", + "joinMode": "", + "joinQuorum": "", + "mergeBoundaryNote": "", + "quorumN": "", + "releaseCapacity": "", + "releaseCondition": "", + "releaseDependency": "", + "releaseExternal": "", + "releaseManual": "", + "releaseTimer": "", + "splitNote": "" + }, + "workflows": { + "duplicateToCustomize": "", + "readOnlyBuiltin": "", + "saved": "", + "savedNotCompilable": "", + "saveFailed": "", + "selectOrCreate": "" + }, + "workflowSelector": { + "switchActiveMessage": "", + "switchActiveTitle": "", + "switchCancel": "", + "switchConfirm": "" }, "workspace": { "projectRoot": "프로젝트 루트", diff --git a/packages/i18n/locales/ko/cli.json b/packages/i18n/locales/ko/cli.json index ca9f3bf866..6249b54533 100644 --- a/packages/i18n/locales/ko/cli.json +++ b/packages/i18n/locales/ko/cli.json @@ -20,11 +20,11 @@ "agentRunId": "ID:", "agentRunLogsBackHint": "[Esc/q] 실행 목록으로 돌아가기", "agentRunLogsTitle": "실행 로그 ({{index}})", + "agentsFooterHints": "[s] 시작 [x] 중지 [D] 삭제 [r] 새로고침 [Tab] 포커스 ↑↓ 선택", + "agentsListTitle_other": "", + "agentsNoAgents": "에이전트가 없습니다.", "agentStarted": "에이전트 시작됨", "agentStopped": "에이전트 중지됨", - "agentsFooterHints": "[s] 시작 [x] 중지 [D] 삭제 [r] 새로고침 [Tab] 포커스 ↑↓ 선택", - "agentsListTitle": "에이전트 ({{count}})", - "agentsNoAgents": "에이전트가 없습니다.", "boardCreateTaskHints": "Enter로 생성 · Esc로 취소", "boardCreateTaskNoProject": "선택된 프로젝트 없음", "boardCreateTaskTitleEmpty": "제목을 입력해야 합니다", @@ -33,6 +33,7 @@ "boardNewTaskProject": "프로젝트: {{name}}", "boardNewTaskTitle": "새 작업", "boardNewTaskTitleLabel": "제목", + "boardOtherReadOnlyHint": "", "copiedSuccess": "✓ 복사됨!", "copyFailed": "✗ 복사 실패", "expandedLogHeader": "항목 {{index}}/{{total}} · [Enter/Esc] 닫기 · [c] 복사", @@ -43,26 +44,26 @@ "filesEmpty": "(비어 있음)", "filesEmptyFile": "(빈 파일)", "filesFooterHints": "[Tab] 창 전환 [↑↓/jk] 이동 [Enter] 열기 [←/→] 접기/펼치기 [.] 숨김 [w] 줄 바꿈 [p] 프로젝트 [r] 새로고침", - "filesMoreLines": "… {{count}}줄 더 있음", + "filesMoreLines_other": "", "filesSelectProject": "프로젝트 선택", "filesSelectToPreview": "미리볼 파일을 선택하세요", "filesTooLarge": "{{size}} — [미리보기에 너무 큼]", "filesUnableToRead": "파일을 읽을 수 없습니다", - "gitFetchFailed": "Fetch 실패: {{output}}", "gitFetched": "Fetch 완료", + "gitFetchFailed": "Fetch 실패: {{output}}", "gitFetching": "Fetch 중…", "gitFooterHints": "[r] 새로고침 {{push}}[F] fetch [↑↓] 행 [←→] 상태▸브랜치{{worktrees}}▸커밋▸변경사항 [p] 프로젝트 [Esc/s] 뒤로", "gitNoCommits": "커밋 없음", "gitNoProject": "프로젝트 없음", "gitPushDismissHint": "[Esc] 닫기", "gitPushFailed": "Push 실패", + "gitPushingToOrigin": "origin/{{branch}}에 Push 중", "gitPushModalAhead": "앞서 있음", "gitPushModalBranch": "브랜치:", "gitPushModalCommits": "Push할 커밋 (오래된 순→최신 순):", "gitPushModalHints": "[Enter] push [Esc] 취소", "gitPushModalTitle": "원격 저장소에 Push", "gitPushSuccessful": "Push 성공", - "gitPushingToOrigin": "origin/{{branch}}에 Push 중", "gitRefreshing": "새로고침 중", "gitWorkingTreeClean": "작업 트리가 깨끗합니다", "headerHelpQuitHint": "[?] 도움말 [q] 종료", @@ -117,14 +118,13 @@ "projectSelectorChangeHint": "[p] 변경", "projectSelectorLabel": "프로젝트:", "projectSelectorNavHints": "↑↓ 탐색 · Enter 선택 · Esc 취소", - "projectSelectorNoProjects": "(등록된 프로젝트 없음)", "projectSelectorNone": "(없음)", + "projectSelectorNoProjects": "(등록된 프로젝트 없음)", "projectSelectorPickTitle": "프로젝트 선택", "qrCloseHint": "[Esc] 닫기", "qrGenerating": "QR 생성 중…", "qrNoTunnelRunning": "실행 중인 원격 터널이 없습니다. 설정(g)에서 시작하세요.", "qrOverlayTitle": "원격 접속 — QR 스캔하여 연결", - "quit": "종료", "readyIn": "{{secs}}초 후 준비", "runLogNone": "이 실행에 캡처된 로그가 없습니다.", "runLogResult": "결과:", @@ -137,16 +137,6 @@ "runStatusFailed": "실패", "runStatusTerminated": "종료됨", "runStatusUnknown": "알 수 없음", - "settingAutoMerge": "자동 병합", - "settingEnginePaused": "엔진 일시 정지", - "settingGlobalPause": "전역 일시 정지", - "settingMaxConcurrent": "최대 동시 실행", - "settingMaxWorktrees": "최대 워크트리", - "settingMergeStrategy": "병합 전략", - "settingPollIntervalMs": "폴링 간격 (ms)", - "settingRemoteActiveProvider": "원격 공급자", - "settingRemoteShortLivedEnabled": "단기 토큰", - "settingRemoteShortLivedTtlMs": "단기 TTL (ms)", "settingsActivatedProvider": "활성화된 공급자: {{provider}}", "settingsAdjust1": "[+/-] 1씩 조정", "settingsAdjust5000ms": "[+/-] 5000ms씩 조정", @@ -161,7 +151,7 @@ "settingsFooterHints": "[Tab] 패널 전환 ↑↓ 설정 선택 [Space] 불리언 토글 [+/-] 숫자 조정 [←/→] 열거형 순환 [C/V/X/P/L/U/K/R] 원격 작업", "settingsInteractivePanelTitle": "설정", "settingsLoadingSettings": "설정 불러오는 중…", - "settingsMoreModels": "… 및 {{count}}개 더", + "settingsMoreModels_other": "", "settingsPanelTitle": "설정", "settingsPersistentTokenRegenerated": "영구 토큰이 재생성됨", "settingsQrFetched": "QR 페이로드 가져옴", @@ -228,6 +218,9 @@ "utilitiesKillVitest": "Vitest 프로세스 종료", "utilitiesPanelTitle": "유틸리티", "utilitiesRefreshStats": "통계 새로고침", - "utilitiesToggleEnginePause": "엔진 일시 정지 전환" + "utilitiesToggleEnginePause": "엔진 일시 정지 전환", + "agentsListTitle_one": "", + "filesMoreLines_one": "", + "settingsMoreModels_one": "" } } diff --git a/packages/i18n/locales/ko/common.json b/packages/i18n/locales/ko/common.json index 657ad48816..2cb648e1f7 100644 --- a/packages/i18n/locales/ko/common.json +++ b/packages/i18n/locales/ko/common.json @@ -4,8 +4,65 @@ "close": "닫기", "save": "저장" }, + "agents": { + "ratings": { + "trendDeclining": "", + "trendImproving": "", + "trendInsufficient": "", + "trendStable": "" + }, + "reflections": { + "triggerManual": "", + "triggerPeriodic": "", + "triggerPostTask": "", + "triggerUserRequested": "" + }, + "time": { + "daysAgo_one": "", + "daysAgo_other": "", + "hoursAgo_one": "", + "hoursAgo_other": "", + "inAMoment": "", + "inDays_one": "", + "inDays_other": "", + "inHours_one": "", + "inHours_other": "", + "inMinutes_one": "", + "inMinutes_other": "", + "justNow": "", + "minutesAgo_one": "", + "minutesAgo_other": "" + } + }, "archive": "보관", + "board": { + "rejection": { + "capacityExhausted": "", + "guardRejected": "", + "mergeBlocked": "", + "unknownColumn": "", + "workflowMismatch": "" + } + }, "cancel": "취소", + "chat": { + "failedToGetResponse": "", + "failureReferenceId": "", + "failureReferenceKind": "", + "failureReferenceLabel": "", + "failureReferenceMetaLabel": "", + "openMailboxMessage": "", + "toolCallArgsPrefix": "", + "toolCallResultPrefix": "", + "toolCallStatusCompleted": "", + "toolCallStatusError": "", + "toolCallStatusErrors": "", + "toolCallStatusRunning": "", + "toolCallsCount_one": "", + "toolCallsCount_other": "", + "toolCallsHeader": "", + "viewFailureDetails": "" + }, "close": "닫기", "columns": { "archived": "보관됨", @@ -16,8 +73,162 @@ "triage": "계획" }, "delete": "삭제", + "health": { + "anomaly": { + "duplicateActiveId": "", + "idInBothStorages": "", + "sequenceOverlap": "", + "unknownPrefix": "" + } + }, + "inline": { + "connecting": "", + "error": "", + "offline": "", + "online": "" + }, + "merge": { + "unknown": "" + }, + "missions": { + "autopilotStateActivating": "", + "autopilotStateCompleting": "", + "autopilotStateInactive": "", + "autopilotStateUnknown": "", + "autopilotStateWatching": "", + "interviewStatusAwaitingInput": "", + "interviewStatusComplete": "", + "interviewStatusError": "", + "interviewStatusGenerating": "", + "runHelperActive": "", + "runHelperBlocked": "", + "runHelperPlanning": "" + }, + "models": { + "messages": { + "modelSetTo": "", + "modelSetToDefault": "" + } + }, + "nodeStatus": { + "connecting": "", + "error": "", + "offline": "", + "online": "", + "unknown": "" + }, + "nodes": { + "auth": { + "differ": "", + "differProviders": "", + "match": "", + "notSynced": "" + }, + "status": { + "connecting": "", + "creating": "", + "deleting": "", + "error": "", + "exited": "", + "offline": "", + "online": "", + "recreating": "", + "running": "", + "stopped": "" + } + }, "refresh": "새로고침", + "research": { + "providerGitHub": "", + "providerLlmSynthesis": "", + "providerLocalDocs": "", + "providerPageFetch": "", + "providerWebSearch": "" + }, "retry": "재시도", + "routing": { + "policyLabel": { + "block": "", + "fallback": "", + "notConfigured": "" + } + }, + "setup": { + "apiKeyFormatError": "", + "apiKeyLabel": { + "fallback": "", + "kimiCoding": "", + "minimax": "", + "ollama": "", + "openai": "", + "openrouter": "", + "zai": "" + }, + "apiKeyPlaceholder": { + "fallback": "", + "kimiCoding": "", + "minimax": "", + "zai": "" + }, + "apiKeyRequired": "", + "apiKeySetup": { + "fallback": "", + "kimiCoding": "", + "minimax": "", + "ollama": "", + "openai": "", + "openrouter": "", + "zai": "" + }, + "apiKeyUsage": { + "fallback": "", + "kimiCoding": "", + "minimax": "", + "ollama": "", + "openai": "", + "openrouter": "", + "zai": "" + }, + "providerDesc": { + "anthropic": "", + "fallback": "", + "gemini": "", + "google": "", + "kimi": "", + "kimiCoding": "", + "minimax": "", + "moonshot": "", + "ollama": "", + "openai": "", + "openaiCodex": "", + "openrouter": "", + "zai": "" + } + }, "skip": "건너뛰기", - "tryAgain": "다시 시도" + "taskForm": { + "nodeStatusConnecting": "", + "nodeStatusError": "", + "nodeStatusOffline": "", + "nodeStatusOnline": "", + "phasePostMerge": "", + "phasePreMerge": "" + }, + "taskReview": { + "never": "", + "refreshSourceBackground": "", + "refreshSourceInitialLoad": "", + "refreshSourceManual": "" + }, + "tryAgain": "다시 시도", + "workflow": { + "postMerge": "", + "preMerge": "", + "statusAdvisory": "", + "statusFailed": "", + "statusPassed": "", + "statusRunning": "", + "statusSkipped": "", + "waitingForOutput": "" + } } diff --git a/packages/i18n/locales/ko/errors.json b/packages/i18n/locales/ko/errors.json index bfd5403c99..0967ef424b 100644 --- a/packages/i18n/locales/ko/errors.json +++ b/packages/i18n/locales/ko/errors.json @@ -1,4 +1 @@ -{ - "fetchProjectsFailed": "프로젝트를 불러오지 못했습니다", - "openTaskLogsFailed": "작업 로그를 열지 못했습니다: {{detail}}" -} +{} diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 889a19981a..9b5904cb9a 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -16,7 +16,6 @@ "dismissOAuth": "关闭 OAuth 重新登录横幅", "done": "完成", "edit": "编辑", - "generateInsights": "生成新洞察", "no": "否", "openSettings": "打开设置", "pull": "拉取", @@ -66,10 +65,13 @@ "notMerged": "未合并", "refresh": "刷新", "time": { - "daysAgo": "{{count}}天前", - "hoursAgo": "{{count}}小时前", + "daysAgo_other": "", + "hoursAgo_other": "", "justNow": "刚刚", - "minutesAgo": "{{count}}分钟前" + "minutesAgo_other": "", + "daysAgo_one": "", + "hoursAgo_one": "", + "minutesAgo_one": "" }, "title": "活动日志" }, @@ -95,29 +97,33 @@ "hideToolCallsResults": "隐藏工具调用和结果", "hideToolOutput": "隐藏工具输出", "live": "实时", - "loadMore": "加载更多", "loading": "加载代理日志中…", "loadingMore": "加载中…", + "loadMore": "加载更多", "markdown": "Markdown", "plain": "纯文本", "planning": "规划", "reviewer": "审查者", "showFormattedMarkdown": "显示格式化的 markdown", + "showing": "显示 {{visible}} 条,共 {{total}} 条条目", "showOutput": "显示输出", "showRawText": "显示原始文本", "showToolCallsResults": "显示工具调用和结果", "showToolOutput": "显示工具输出", - "showing": "显示 {{visible}} 条,共 {{total}} 条条目", "switchMarkdown": "切换到 markdown 模式", "switchPlainText": "切换到纯文本模式", - "timeDaysAgo": "{{count}} 天前", - "timeHoursAgo": "{{count}} 小时前", + "timeDaysAgo_other": "", + "timeHoursAgo_other": "", "timeJustNow": "刚刚", - "timeMinutesAgo": "{{count}} 分钟前", - "toolEntriesHidden": "{{count}} 条工具条目已隐藏", + "timeMinutesAgo_other": "", + "toolEntriesHidden_other": "", "toolsOff": "工具:关闭", "toolsOn": "工具:开启", - "usingDefault": "使用默认值" + "usingDefault": "使用默认值", + "timeDaysAgo_one": "", + "timeHoursAgo_one": "", + "timeMinutesAgo_one": "", + "toolEntriesHidden_one": "" }, "agentMention": { "membersOf": "#{{roomName}} 的成员", @@ -239,15 +245,6 @@ "promptDefault": "默认值:{{preview}}", "templateName": "例如 我的自定义执行器" }, - "roles": { - "custom": "自定义代理", - "engineer": "工程代理", - "executor": "执行代理", - "merger": "合并代理", - "reviewer": "审查代理", - "scheduler": "调度代理", - "triage": "分类代理" - }, "sections": { "builtinTemplates": "内置模板", "customTemplates": "自定义模板" @@ -281,7 +278,7 @@ }, "agents": { "activate": "激活", - "activeAgents": "活跃代理 ({{count}})", + "activeAgents_other": "", "activePrefix": "活跃:", "advancedSettingsDesc": "此代理的底层配置选项。", "advancedSettingsTitle": "高级设置", @@ -291,15 +288,15 @@ "agentMail": "代理邮件", "agentModelLabel": "代理模型", "agentPlural": "代理", + "agentsFound_other": "", "agentSingular": "代理", - "agentSoulLabel": "代理灵魂", - "agentsFound": "找到{{count}}个代理{{plural}}", "agentsLabel": "代理", + "agentSoulLabel": "代理灵魂", "aiInterview": "AI 访谈", "allChangesSaved": "所有更改已保存", - "allTime": "全部时间", "allowParallelExecution": "允许并行执行", "allowParallelExecutionHint": "允许此代理并发运行多个心跳。", + "allTime": "全部时间", "alreadyOnDefault": "已是默认", "applyPreset": "应用预设", "assignedSkills": "已分配技能", @@ -330,12 +327,11 @@ "bulkActions": "批量操作", "bulkActionsLoadFailed": "加载批量智能体操作失败:{{error}}", "bulkAgentActions": "批量智能体操作", - "bulkConfirmMessage": "{{action}} {{count}} 个代理?", + "bulkConfirmMessage_other": "", "bulkNoEligible": "没有符合条件的代理", - "bulkResult": "已{{action}} {{count}} 个代理", - "bulkResultWithFailures": "已{{action}} {{count}} 个代理,{{failed}} 个失败", "bulkResult_one": "{{action}} {{successCount}} 个{{agentWord}};跳过 {{skippedCount}} 个", "bulkResult_other": "{{action}} {{successCount}} 个{{agentWord}};跳过 {{skippedCount}} 个", + "bulkResultWithFailures": "已{{action}} {{count}} 个代理,{{failed}} 个失败", "bundleDescription": "配置此代理代码包的管理方式。", "bundleEntryFileHint": "托管包的入口文件。", "bundleEntryFileLabel": "入口文件", @@ -381,9 +377,9 @@ "copyId": "复制 ID", "create": "创建", "createAgent": "创建代理", + "created": "代理\"{{name}}\"已创建", "createError": "创建代理失败", "createSuccess": "智能体「{{name}}」已创建", - "created": "代理\"{{name}}\"已创建", "creating": "正在创建…", "creatingAgent": "正在创建代理...", "currentAgent": "当前代理", @@ -400,12 +396,12 @@ "delete": "删除", "deleteAgent": "删除代理", "deleteConfirm": "删除智能体「{{name}}」?此操作无法撤销。", + "deleted": "智能体「{{name}}」已删除", "deleteError": "删除智能体失败:{{error}}", "deleteFailed": "删除智能体失败:{{error}}", "deleteMessage": "删除智能体「{{name}}」?此操作无法撤销。", "deleteSuccess": "智能体「{{name}}」已删除", "deleteTitle": "删除智能体", - "deleted": "智能体「{{name}}」已删除", "deletionNotAvailable": "代理运行时无法删除。", "deletionPermanent": "这将永久删除代理及其所有相关数据。", "details": "详情", @@ -473,6 +469,7 @@ "healthError": "错误", "heartbeat": "心跳:", "heartbeatAndHealth": "心跳与健康", + "heartbeatClampedToMin_other": "", "heartbeatCustom": "自定义心跳运行", "heartbeatEnabled": "启用心跳", "heartbeatEnabledHint": "允许此代理按计划心跳运行。", @@ -483,12 +480,12 @@ "heartbeatFileLoadFailed": "加载心跳文件失败", "heartbeatFilePlaceholder": "心跳流程内容...", "heartbeatFilePreviewMode": "预览模式", - "heartbeatFileSaveFailed": "保存心跳文件失败", "heartbeatFileSaved": "心跳文件已保存", + "heartbeatFileSaveFailed": "保存心跳文件失败", "heartbeatIntervalHint": "心跳运行的频率(秒)。", "heartbeatIntervalLabel": "心跳间隔(秒)", - "heartbeatIntervalUpdateFailed": "更新心跳间隔失败:{{error}}", "heartbeatIntervalUpdated": "{{name}} 的心跳间隔已更新为 {{interval}}", + "heartbeatIntervalUpdateFailed": "更新心跳间隔失败:{{error}}", "heartbeatMustBeNumber": "心跳间隔必须是有效数字", "heartbeatMustBePositive": "心跳间隔必须大于 0", "heartbeatOverdue": "心跳超期 {{elapsed}}", @@ -512,8 +509,8 @@ "heartbeatSpeedPreset": "心跳速度预设", "heartbeatSpeedSaveFailed": "保存心跳倍数失败:{{error}}", "heartbeatSpeedSet": "心跳速度已设为 ×{{value}}", - "heartbeatStartFailed": "启动心跳失败", "heartbeatStarted": "心跳已启动", + "heartbeatStartFailed": "启动心跳失败", "heartbeatTimeoutHint": "心跳运行在被终止前的最长时间(秒)。", "heartbeatTimeoutLabel": "心跳超时(秒)", "heartbeatUpgradeFailed": "升级心跳流程失败", @@ -527,16 +524,16 @@ "importButton": "导入{{label}}", "importComplete": "导入完成", "importDescription": "从Agent Companies包导入代理。浏览companies.sh目录以发现已发布的代理、上载AGENTS.md文件、选择目录或粘贴清单内容。", - "importingAgents": "正在导入 {{count}} 个 Agent...", + "importingAgents_other": "", "importingAgentsAndSkills": "正在导入 {{agentCount}} 个 Agent 和 {{skillCount}} 个技能...", - "importingSkills": "正在导入 {{count}} 个技能...", - "inProgress": "进行中", + "importingSkills_other": "", "inbox": "收件箱", - "inheritProjectDefault": "继承项目默认", "inheritingProjectDefault": "继承项目默认值", + "inheritProjectDefault": "继承项目默认", "inlineMemoryFieldHint": "此记忆直接嵌入代理的上下文中。", "inlineMemoryHint": "每次心跳时注入的简短记忆。", "inlineMemoryLabel": "内联记忆", + "inProgress": "进行中", "input": "输入", "inputTokens": "输入令牌", "installs": "安装次数", @@ -544,15 +541,15 @@ "instructionsEmptyPreview": "暂无指令——切换到编辑模式以添加。", "instructionsFileEditorDesc": "直接编辑链接的指令文件。", "instructionsFileEditorTitle": "指令文件", - "instructionsFileSaveFailed": "保存指令文件失败", "instructionsFileSaved": "指令文件已保存", + "instructionsFileSaveFailed": "保存指令文件失败", "instructionsHint": "这些指令会被添加到此代理收到的每个提示词之前。", "instructionsPathHint": "包含此代理指令的 Markdown 文件路径。", "instructionsPathLabel": "指令文件路径", "instructionsPathPlaceholder": "例如:.fusion/agents/reviewer.md", "instructionsPlaceholder": "输入此代理的指令...", - "instructionsSaveFailed": "保存指令失败", "instructionsSaved": "指令已保存", + "instructionsSaveFailed": "保存指令失败", "instructionsTextPlaceholder": "添加自定义行为指令…", "instructionsTitle": "指令", "intentPrompt": "您希望此代理执行什么操作?", @@ -574,7 +571,6 @@ "liveLogs": "实时日志", "liveRun": "实时运行", "loadError": "加载智能体失败:{{error}}", - "loadTasksFailed": "加载任务失败", "loading": "正在加载代理...", "loadingAgents": "正在加载智能体…", "loadingCompanies": "正在加载公司…", @@ -594,15 +590,16 @@ "loadingRuntimes": "正在加载运行时…", "loadingSkillContent": "正在加载技能内容...", "loadingTasks": "正在加载任务...", - "logEntries": "条日志", + "loadTasksFailed": "加载任务失败", + "logEntries_other": "", "logsWillAppear": "代理开始运行后,日志将显示在此处。", "logsWillAppearActive": "日志将显示在此处。", + "mailboxLoadFailed": "加载邮箱失败", "mailFrom": "发件人", "mailSent": "发送时间", "mailTo": "收件人", "mailToLabel": "收件人", "mailType": "类型", - "mailboxLoadFailed": "加载邮箱失败", "manifestContent": "清单内容", "manifestPlaceholder": "---\nname: CEO\ntitle: 首席执行官\nreportsTo: null\nskills:\n - review\n---\n在此处填写 Agent 指令...", "maxConcurrentRunsHint": "可以同时运行的最大心跳数。", @@ -616,8 +613,8 @@ "memoryFileMeta": "{{size}} 字节 · 更新于 {{date}}", "memoryFilePlaceholder": "记忆文件内容...", "memoryFilePreviewMode": "预览模式", - "memoryFileSaveFailed": "保存记忆文件失败", "memoryFileSaved": "记忆文件已保存", + "memoryFileSaveFailed": "保存记忆文件失败", "memoryFilesHint": "存储在代理记忆层中的文件。", "memoryFilesHintSuffix": "选择一个文件以查看或编辑其内容。", "memoryFilesLabel": "记忆文件", @@ -630,8 +627,8 @@ "memoryLayerLongTermDesc": "跨会话保留的持久事实和知识。", "memoryPlaceholder": "仅此代理可见——持久化偏好设置、操作习惯以及跨任务应保留的上下文…", "memoryReadOnly": "只读", - "memorySaveFailed": "保存记忆失败", "memorySaved": "记忆已保存", + "memorySaveFailed": "保存记忆失败", "memoryTitle": "记忆", "memoryTooLong": "记忆内容太长", "messageResponseModeHint": "此代理响应传入消息的时机。", @@ -672,6 +669,7 @@ "noLogsForRun": "此次运行没有日志", "noManager": "无管理员", "noMemoryFiles": "没有记忆文件", + "noneUsingBuiltIn": "无(使用内置)", "noOutboxMessages": "发件箱中没有邮件", "noOutputCaptured": "未捕获输出", "noPausedEligible": "没有符合条件可恢复的已暂停代理", @@ -684,11 +682,9 @@ "noSkillsInPackage": "包中没有技能", "noTasksAssigned": "未分配任务", "noTokenUsageYet": "还未记录令牌使用情况。代理运行后,令牌总计将显示在此处。", - "noneUsingBuiltIn": "无(使用内置)", "notScheduled": "未计划", "notSelected": "未选择", "off": "关闭", - "onHeartbeat": "心跳时", "onboarding": { "applyDraftAgent": "将草稿应用到智能体表单", "applyDraftSettings": "将草稿应用到设置表单", @@ -729,9 +725,9 @@ "updatedDraftReady": "更新后的草稿已准备好供审阅", "yes": "是" }, + "onHeartbeat": "心跳时", "openDetails": "打开 {{name}} 的详情", "optional": "(可选)", - "orPasteManifest": "或粘贴清单内容", "orgChartCanvas": "组织架构图画布", "orgChartCenter": "居中组织架构图", "orgChartEmployees": "{{name}} 的下属", @@ -740,6 +736,7 @@ "orgChartView": "组织架构图视图", "orgChartZoomIn": "放大组织架构图", "orgChartZoomOut": "缩小组织架构图", + "orPasteManifest": "或粘贴清单内容", "outbox": "发件箱", "output": "输出", "outputTokens": "输出令牌", @@ -750,13 +747,16 @@ "pauseAgentsFailed": "暂停智能体失败:{{error}}", "pauseAll": "暂停全部", "pauseAllAgents": "暂停所有智能体", + "pauseAllConfirm_other": "", "pauseAllTitle": "暂停所有智能体", - "pauseCountHint": "将暂停 {{count}} 个活动代理", "pauseCountHint_one": "暂停 {{count}} 个活跃/运行中的代理", "pauseCountHint_other": "暂停 {{count}} 个活跃/运行中的代理", + "pauseCountHint_one_other": "", + "pauseCountHint_other_other": "", "pausedPast": "已暂停", + "pausedSummary_other": "", "pendingApprovals": "待审批", - "pendingApprovalsCount": "{{count}} 待处理", + "pendingApprovalsCount_other": "", "performance": { "avgDuration": "平均时长", "noData": "暂无性能数据", @@ -774,6 +774,7 @@ "preview": "预览", "promptSize": "提示词大小", "promptSizeChart": "提示词大小图表", + "provideManifest": "", "ratings": { "addError": "添加评分失败:{{error}}", "addRating": "添加评分", @@ -786,7 +787,7 @@ "categorySelect": "选择分类...", "categorySpeed": "速度", "commentPlaceholder": "可选备注...", - "count": "{{count}} 个评分", + "count_other": "", "deleteError": "删除评分失败:{{error}}", "deleteRating": "删除评分", "deleteSuccess": "评分已删除", @@ -794,14 +795,12 @@ "loadError": "加载评分失败:{{error}}", "loading": "加载评分中...", "noRatings": "暂无评分", - "starCount": "{{count}} 星", + "starCount_other": "", "submitRating": "提交评分", "submitting": "提交中...", "title": "用户评分", - "trendDeclining": "↓ 下降中", - "trendImproving": "↑ 改善中", - "trendInsufficient": "数据不足", - "trendStable": "→ 稳定" + "count_one": "", + "starCount_one": "" }, "recentRuns": "最近运行", "reflections": { @@ -818,18 +817,14 @@ "metricAvgDuration": "平均时长:", "metricErrors": "错误:", "metricFailed": "失败:", - "metricTasks": "任务:", "metrics": "指标", + "metricTasks": "任务:", "noReflections": "暂无反思", + "reflecting": "反思中...", "reflectNow": "立即反思", "reflectNowTitle": "手动生成反思", - "reflecting": "反思中...", "sectionTitle": "性能、反思与评分", - "suggestedImprovements": "改进建议", - "triggerManual": "手动", - "triggerPeriodic": "定期", - "triggerPostTask": "任务后", - "triggerUserRequested": "用户请求" + "suggestedImprovements": "改进建议" }, "refresh": "刷新", "removeAvatar": "移除头像", @@ -842,19 +837,22 @@ "resetDayWeekly": "星期几(0=周日)", "resetting": "正在重置...", "result": "结果", - "resultCreated": "{{count}}已创建", - "resultErrors": "{{count}}个错误{{plural}}", - "resultSkipped": "{{count}}个跳过(已存在)", + "resultCreated_other": "", + "resultErrors_other": "", + "resultSkipped_other": "", "resume": "恢复", "resumeAction": "恢复", "resumeAgentsFailed": "恢复智能体失败:{{error}}", "resumeAll": "恢复全部", "resumeAllAgents": "恢复所有智能体", + "resumeAllConfirm_other": "", "resumeAllTitle": "恢复所有智能体", - "resumeCountHint": "将恢复 {{count}} 个已暂停代理", "resumeCountHint_one": "恢复 {{count}} 个已暂停的代理", "resumeCountHint_other": "恢复 {{count}} 个已暂停的代理", + "resumeCountHint_one_other": "", + "resumeCountHint_other_other": "", "resumedPast": "已恢复", + "resumedSummary_other": "", "retry": "重试", "reviewConfiguration": "查看生成的配置", "reviewHint": "在创建之前请检查代理配置。", @@ -868,20 +866,18 @@ "roleReviewer": "审核者", "roleScheduler": "调度者", "roleTriage": "分流", + "roleUpdated": "智能体角色已更新为 {{role}}", "roleUpdateError": "更新角色失败:{{error}}", "roleUpdateFailed": "更新角色失败:{{error}}", "roleUpdateSuccess": "智能体角色已更新为 {{role}}", - "roleUpdated": "智能体角色已更新为 {{role}}", "runAriaLabel": "运行 {{id}}", "runDetailsFailed": "加载运行详情失败", "runMissedHeartbeat": "运行错过的心跳", "runMissedHeartbeatHint": "如果代理错过计划心跳,触发一次运行。", + "running": "运行中", "runNow": "立即运行", "runNowAria": "立即运行 {{name}}", "runNowFor": "立即为 {{name}} 运行", - "runStarted": "运行已启动", - "runStopped": "运行已停止", - "running": "运行中", "runs": { "empty": "还没有运行", "loading": "加载运行…", @@ -890,9 +886,11 @@ "stopMessage": "停止此运行?", "stopTitle": "停止运行" }, - "runsCount": "{{count}} 次运行", + "runsCount_other": "", "runsSuccessRate": "{{rate}}% 成功率", + "runStarted": "运行已启动", "runsToday": "今日运行次数", + "runStopped": "运行已停止", "runtime": "运行时", "runtimeEmpty": "没有可用的插件运行时", "runtimeLabel": "运行时", @@ -925,29 +923,30 @@ "selectAllAgents": "选择所有代理", "selectAllSkills": "选择所有技能", "selectAnAgent": "选择一个智能体", + "selectCompany": "", "selectDirectory": "选择目录", + "selected": "已选择:", + "selectedAgentLabel_other": "", + "selectedSkillLabel_other": "", "selectMemoryFile": "选择一个记忆文件", "selectModel": "模型", "selectModelPlaceholder": "选择模型...", "selectRuntime": "选择运行时", "selectSkill": "选择技能{{name}}", - "selected": "已选择:", - "selectedAgentLabel": "{{count}} 个 Agent", - "selectedSkillLabel": "{{count}} 个技能", "setHeartbeatAria": "设置 {{name}} 的心跳间隔", - "settingsSaveFailed": "保存设置失败", "settingsSaved": "设置已保存", + "settingsSaveFailed": "保存设置失败", "setupModeAriaLabel": "代理设置模式", "showSystemAgents": "显示系统智能体", "skills": "技能", "skillsDescription": "管理此代理可用的技能。", - "skillsErrors": "{{count}}个技能{{plural}}错误{{pluralError}}", - "skillsFound": "找到{{count}}个技能{{plural}}", + "skillsErrors_other": "", + "skillsFound_other": "", "skillsHint": "可选择分配给此代理的技能", - "skillsImported": "{{count}}个技能{{plural}}已导入", + "skillsImported_other": "", "skillsNone": "未分配技能", - "skillsSelected": "已选择 {{count}} 个技能", - "skillsSkipped": "{{count}}个技能{{plural}}跳过(已存在)", + "skillsSelected_other": "", + "skillsSkipped_other": "", "skillsTitle": "技能", "skipHeartbeatWhenIdle": "空闲时跳过心跳", "skipHeartbeatWhenIdleHint": "当代理没有任务时,避免运行心跳。", @@ -955,23 +954,23 @@ "soulEmptyPreview": "暂无灵魂——切换到编辑模式以添加。", "soulHint": "描述此代理是谁——其性格、语气和价值观。", "soulPlaceholder": "描述代理的个性和沟通风格…", - "soulSaveFailed": "保存灵魂失败", "soulSaved": "灵魂已保存", + "soulSaveFailed": "保存灵魂失败", "soulTitle": "灵魂", "soulTooLong": "灵魂内容太长", "start": "启动", - "startOnboarding": "开始入职", "starting": "启动中...", + "startOnboarding": "开始入职", "stateActive": "活跃", "stateAll": "所有状态", "stateError": "错误", "stateIdle": "空闲", "statePaused": "已暂停", "stateRunning": "运行中", + "stateUpdated": "智能体状态已更新为 {{state}}", "stateUpdateError": "更新状态失败:{{error}}", "stateUpdateFailed": "更新状态失败:{{error}}", "stateUpdateSuccess": "智能体状态已更新为 {{state}}", - "stateUpdated": "智能体状态已更新为 {{state}}", "status": "状态", "statusCount": "{{activeCount}} 活跃 · {{runningCount}} 运行中", "step": "步 {{number}}{{total}}: {{name}}", @@ -1012,16 +1011,6 @@ "thinkingMinimal": "最低", "thinkingOff": "关闭", "throughput": "吞吐量", - "time": { - "daysAgo": "{{count}}天前", - "hoursAgo": "{{count}}小时前", - "inAMoment": "马上", - "inDays": "{{count}}天后", - "inHours": "{{count}}小时后", - "inMinutes": "{{count}}分钟后", - "justNow": "刚刚", - "minutesAgo": "{{count}}分钟前" - }, "title": "智能体", "titleLabel": "标题", "titlePlaceholder": "例如:高级代码审查员", @@ -1060,7 +1049,34 @@ "weekly": "每周", "workingOn": "正在处理:", "zoomIn": "放大", - "zoomOut": "缩小" + "zoomOut": "缩小", + "activeAgents_one": "", + "agentsFound_one": "", + "bulkConfirmMessage_one": "", + "heartbeatClampedToMin_one": "", + "importingAgents_one": "", + "importingSkills_one": "", + "logEntries_one": "", + "pauseAllConfirm_one": "", + "pauseCountHint_one_one": "", + "pauseCountHint_other_one": "", + "pausedSummary_one": "", + "pendingApprovalsCount_one": "", + "resultCreated_one": "", + "resultErrors_one": "", + "resultSkipped_one": "", + "resumeAllConfirm_one": "", + "resumeCountHint_one_one": "", + "resumeCountHint_other_one": "", + "resumedSummary_one": "", + "runsCount_one": "", + "selectedAgentLabel_one": "", + "selectedSkillLabel_one": "", + "skillsErrors_one": "", + "skillsFound_one": "", + "skillsImported_one": "", + "skillsSelected_one": "", + "skillsSkipped_one": "" }, "app": { "backendError": { @@ -1070,11 +1086,12 @@ }, "approval": { "dismissBanner": "关闭审批通知横幅", - "needAttention": "{{count}} 个审批{{noun}}需要您的注意", + "needAttention_other": "", "openMailbox": "打开邮箱", "requestPlural": "请求", + "requests": "审批请求", "requestSingular": "请求", - "requests": "审批请求" + "needAttention_one": "" }, "auth": { "clearAndRetry": "清除令牌并重试", @@ -1100,9 +1117,9 @@ "confirmMessage": "此会话在另一个标签页中处于活跃状态。仍然打开?", "confirmTitle": "打开活跃会话", "dismissButton": "关闭", - "pillLabel": "AI {{count}}", - "pillTitle": "{{count}} 个后台 AI 任务", - "pillTitleWithInput": "{{count}} 个后台 AI 任务({{needsInput}} 个需要输入)", + "pillLabel_other": "", + "pillTitle_other": "", + "pillTitleWithInput_other": "", "popoverHeader": "后台任务", "status": { "activeElsewhere": "在另一个标签页中活跃", @@ -1116,17 +1133,29 @@ "planning": "规划", "sliceInterview": "切片采访", "subtask": "子任务分解" - } + }, + "pillLabel_one": "", + "pillTitle_one": "", + "pillTitleWithInput_one": "" }, "board": { "archived": "已归档", "done": "完成", "inProgress": "进行中", "inReview": "审查中", + "rejection": { + "capacityExhausted": "", + "guardRejected": "", + "mergeBlocked": "", + "promoteRejected": "", + "unknownColumn": "", + "workflowMismatch": "" + }, "todo": "待办", "triage": "分诊" }, "branchGroup": { + "abandonGroup": "", "autoMergeEnabled": "自动合并已启用", "collapseLabel": "折叠分支组", "completionText": "{{landed}} 个成员完成,共 {{total}} 个", @@ -1185,13 +1214,8 @@ "failedToCreateSession": "创建对话失败", "failedToDeleteConversation": "删除对话失败", "failedToDeleteRoom": "删除频道失败", - "failedToGetResponse": "无法获得响应", "failedToSendRoomMessage": "发送房间消息失败", "failureDetails": "失败详情", - "failureReferenceId": "ID", - "failureReferenceKind": "类型", - "failureReferenceLabel": "参考", - "failureReferenceMetaLabel": "标签", "helpMessageContent": "可用命令:\n- `/new` 或 `/clear` — 清除对话并重新开始\n- `/skill:{name}` — 使用特定技能\n- `/help` — 显示此帮助", "jumpToLatest": "最新", "latest": "最新", @@ -1222,14 +1246,13 @@ "noRoomsYet": "暂无频道。", "noSkillsAvailable": "暂无可用技能", "noSkillsFound": "未找到技能", - "openMailboxMessage": "打开邮箱消息", "openQuickChat": "打开快速聊天", "queuedMessage": "已排队:{{preview}}", "quickChatTitle": "快速聊天", - "relativeTimeDays": "{{count}}天前", - "relativeTimeHours": "{{count}}小时前", + "relativeTimeDays_other": "", + "relativeTimeHours_other": "", "relativeTimeJustNow": "刚刚", - "relativeTimeMinutes": "{{count}}分钟前", + "relativeTimeMinutes_other": "", "removeAttachment": "移除 {{name}}", "resizePanelBottom": "从底部调整面板大小", "resizePanelBottomLeft": "从左下角调整面板大小", @@ -1242,7 +1265,7 @@ "resizeSidebar": "调整侧边栏大小", "responseCopied": "已复制回复", "responseFailed": "响应失败", - "roomMemberCount": "{{count}} 位成员", + "roomMemberCount_other": "", "roomsGroupLabel": "频道", "scopeDirect": "直接", "scopeRooms": "频道", @@ -1269,20 +1292,17 @@ "thinking": "思考中", "thinkingLabel": "思考", "thinkingStatus": "思考中……", - "toolCallArgsPrefix": "参数", - "toolCallResultPrefix": "结果", - "toolCallStatusCompleted": "已完成", - "toolCallStatusError": "错误", - "toolCallStatusErrors": "错误", - "toolCallStatusRunning": "运行中", "toolCalls": "工具调用", - "toolCallsCount": "{{count}} 个工具调用", - "toolCallsHeader": "工具调用", + "toolCallsCount_other": "", "typeMessage": "输入消息...", "unreadMessages": "未读消息", "untitledSession": "无标题", - "viewFailureDetails": "查看失败详情", - "you": "你" + "you": "你", + "relativeTimeDays_one": "", + "relativeTimeHours_one": "", + "relativeTimeMinutes_one": "", + "roomMemberCount_one": "", + "toolCallsCount_one": "" }, "chatRooms": { "error": { @@ -1295,8 +1315,8 @@ "cli": { "installBody": "在你的终端上获取 {{fn}} 和 {{fusion}} 命令,这样你就可以从任何地方驱动 Fusion。下面一键点击或复制命令到你的 shell。", "installButton": "使用 npm 安装", - "installTitle": "安装 Fusion CLI", "installing": "安装中…", + "installTitle": "安装 Fusion CLI", "openSettings": "打开设置", "updateButton": "使用 npm 更新", "updateTitle": "更新 Fusion CLI", @@ -1312,8 +1332,8 @@ "failedExit": "安装失败(退出代码 {{code}})", "heading": "CLI 二进制文件", "help": "安装全局 CLI 可以让您从任何终端运行 fn 和 fusion。自动化和脚本可以通过 npx 工作而不需要它,但全局安装更快更方便。", - "installWithNpm": "使用 npm 安装", "installing": "安装中…", + "installWithNpm": "使用 npm 安装", "notOnPath": "在 PATH 上未找到 fn 或 fusion。", "orCopyLabel": "或自行复制并运行:", "refresh": "刷新", @@ -1329,9 +1349,9 @@ "actionsTitle": "列操作", "archiveAllDoneAriaLabel": "存档所有已完成的任务", "archiveAllDoneTitle": "存档所有已完成的任务", - "archiveAllMessage": "存档所有 {{count}} 个已完成的任务?", + "archiveAllMessage_other": "", "archiveAllTitle": "全部存档已完成", - "archivedTasks": "已存档 {{count}} 个任务", + "archivedTasks_other": "", "autoMerge": "自动合并", "autoMergeDisabled": "自动合并已禁用", "autoMergeEnabled": "自动合并已启用", @@ -1342,26 +1362,28 @@ "expandArchivedTitle": "展开已存档的任务", "failedToArchive": "存档任务失败", "keepProgress": "保留进度", - "loadMore": "加载 {{count}} 个更多(剩余 {{remaining}} 个)", + "loadMore_other": "", "moveAllToTodo": "全部移至待办", - "moveAllToTodoMessage": "将所有 {{count}} 个 {{columnLabel}} 任务{{plural}}移至待办?", + "moveAllToTodoMessage_other": "", "moveAllToTodoTitle": "全部移至待办", + "movedToPlanning_other": "", + "movedToTodo_other": "", "movePartialFailure": "已移动 {{total}} 个任务中的 {{moved}} 个;{{failed}} 个失败", - "moveToTodoHint": "将 {{count}} 个任务{{plural}}移至待办", + "moveToTodoHint_other": "", "moveToTodoPartialFailure": "已将 {{total}} 个任务中的 {{moved}} 个移至待办;{{failed}} 个失败", - "movedToPlanning": "已将 {{count}} 个任务{{plural}}移至规划以重新计划", - "movedToTodo": "已将 {{count}} 个任务{{plural}}移至待办", "newTask": "新任务", "noManuallyPausableTasks": "没有可手动暂停的任务", "noTasks": "没有任务", "noTasksInColumn": "此列中没有任务", - "pauseHint": "暂停 {{count}} 个活动的未分配任务{{plural}}", + "pauseHint_other": "", "preserveProgressMessage": "此任务已完成步骤。在移动前保留进度?", "preserveProgressMoveTodoMessage": "某些任务已完成步骤。在移至待办前保留进度?", "preserveProgressTitle": "保留进度?", + "promote": "", + "promoting": "", "replanAll": "全部重新计划", - "replanAllHint": "将 {{count}} 个任务{{plural}}移至规划", - "replanAllMessage": "将所有 {{count}} 个待办任务{{plural}}移回规划以重新计划?", + "replanAllHint_other": "", + "replanAllMessage_other": "", "replanAllTitle": "重新计划所有任务", "resetProgress": "重置进度", "resetProgressConfirm": "重置进度", @@ -1369,10 +1391,22 @@ "resetProgressMoveTodoMessage": "在移至待办前重置任务的步骤进度?", "resetProgressTitle": "重置进度?", "stopAll": "全部停止", - "stopAllMessage": "停止所有 {{count}} 个 {{columnLabel}} 任务{{plural}}?", + "stopAllMessage_other": "", "stopAllTitle": "停止所有任务", "stopPartialFailure": "已停止 {{total}} 个任务中的 {{paused}} 个;{{failed}} 个失败", - "stoppedTasks": "已停止 {{count}} 个任务{{plural}}" + "stoppedTasks_other": "", + "archiveAllMessage_one": "", + "archivedTasks_one": "", + "loadMore_one": "", + "moveAllToTodoMessage_one": "", + "movedToPlanning_one": "", + "movedToTodo_one": "", + "moveToTodoHint_one": "", + "pauseHint_one": "", + "replanAllHint_one": "", + "replanAllMessage_one": "", + "stopAllMessage_one": "", + "stoppedTasks_one": "" }, "comments": { "addButton": "添加评论", @@ -1387,7 +1421,8 @@ "updatedSuccess": "评论已更新" }, "commit": { - "filesChanged": "已更改文件 ({{count}})" + "filesChanged_other": "", + "filesChanged_one": "" }, "commitDiff": { "error": "加载提交差异出错:{{error}}", @@ -1398,6 +1433,7 @@ "noSha": "没有可用的提交 SHA。" }, "common": { + "archive": "", "back": "返回", "cancel": "取消", "close": "关闭", @@ -1419,11 +1455,13 @@ "save": "保存", "saveAndTest": "保存并测试", "saving": "保存中…", + "skip": "", "somethingWentWrong": "加载此视图时出错。", "stop": "停止", "test": "测试", "testing": "测试中…", "total": "总计", + "tryAgain": "", "unableToLoadData": "无法加载数据", "unknown": "未知", "unsavedChanges": "未保存的更改", @@ -1436,8 +1474,8 @@ "messagePlaceholder": "输入您的消息…", "newMessageTitle": "新消息", "noAgentsAvailable": "没有可用的代理", - "replyTitle": "回复", "replyingToLabel": "正在回复:", + "replyTitle": "回复", "selectAgent": "选择代理…", "sendingButton": "发送中…", "toLabel": "收件人:", @@ -1464,30 +1502,19 @@ "createRoom": { "create": "创建房间", "creating": "创建中...", - "duplicate": "已存在同名的房间。", "failedCreate": "创建房间失败。", "failedLoadAgents": "加载代理失败。", "loadingAgents": "加载代理中...", - "lowercase": "仅使用小写字母。", - "maxLength": "房间名称最多为 80 个字符。", "members": "成员", "nameLabel": "房间名称", - "nameRequired": "房间名称是必需的。", "noAgents": "此项目中还没有代理。", - "noEdgeChars": "房间名称不能以连字符或下划线开头或结尾。", "noMatch": "没有代理匹配您的搜索。", "searchAgents": "搜索代理", "selectMember": "至少选择一个成员。", - "title": "创建房间", - "validChars": "仅使用小写字母、数字、连字符或下划线。" + "title": "创建房间" }, "dashboard": { "initializingDashboard": "初始化仪表板...", - "loaderSteps": { - "project": "选择项目中", - "projects": "加载项目中", - "tasks": "获取任务中" - }, "loadingMessage": "加载 Fusion 仪表板", "loadingProgress": "仪表板加载进度", "updatingMessage": "更新 Fusion 仪表板", @@ -1537,15 +1564,16 @@ "filterBySeverity": "按严重程度筛选日志", "info": "信息", "lines": "行", - "loadOlderLogs": "加载较早的日志", + "lines_other": "", "loading": "加载中...", "loadingConfig": "加载开发服务器配置...", "loadingLogs": "加载日志中…", "loadingOlderLogs": "加载较早的日志中…", + "loadOlderLogs": "加载较早的日志", "logs": "日志", "lostConnection": "日志流连接已断开。", "manual": "手动", - "matchCount": "{{count}} 个匹配", + "matchCount_other": "", "newLogs": "新日志", "noLogsYet": "暂无日志。启动开发服务器查看输出。", "noMatchesSearch": "没有日志行匹配您的搜索。", @@ -1593,7 +1621,9 @@ "started": "开发服务器已启动。", "stopped": "开发服务器已停止。" }, - "warn": "警告" + "warn": "警告", + "lines_one": "", + "matchCount_one": "" }, "dirPicker": { "ariaLabel": "目录浏览器", @@ -1712,7 +1742,7 @@ "clearSearch": "清除搜索", "collapse": "折叠", "collapseContent": "折叠内容", - "docCount": "{{count}} 个文档", + "docCount_other": "", "documentsCreatedIn": "文档在任务详细信息选项卡中创建。", "expand": "展开", "expandContent": "展开内容", @@ -1732,7 +1762,7 @@ "plain": "纯文本", "projectFiles": "项目文件", "projectFilesTab": "项目文件", - "resultCount": "{{count}} 个结果", + "resultCount_other": "", "retry": "重试", "retryLoading": "重试加载文档", "searchProjectFiles": "搜索项目Markdown文件…", @@ -1748,7 +1778,9 @@ "taskDocuments": "任务文档", "taskDocumentsTab": "任务文档", "title": "文档", - "untitled": "未命名" + "untitled": "未命名", + "docCount_one": "", + "resultCount_one": "" }, "droidCli": { "active": "活跃", @@ -1811,28 +1843,33 @@ }, "executor": { "blocked": "已阻止", - "daysAgo": "{{count}}天前", + "daysAgo_other": "", "escalated": "已升级", "escalatedSuffix": " (已升级)", "hideProjectDir": "隐藏项目目录", - "hoursAgo": "{{count}}小时前", + "hoursAgo_other": "", "inReview": "审查中", "justNow": "刚刚", "loading": "加载中...", - "minutesAgo": "{{count}}分钟前", + "minutesAgo_other": "", "noActivity": "无活动", - "overlapBottleneck": "{{status}}重叠瓶颈{{blockerId}}:{{count}}个待办事项通过blockedBy被阻止(阈值{{threshold}})", + "overlapBottleneck_other": "", "overlapQueue": "重叠队列", "queued": "已排队", "running": "运行中", - "secondsAgo": "{{count}}秒前", + "secondsAgo_other": "", "showProjectDir": "显示项目目录", "stateIdle": "空闲", "statePaused": "已暂停", "stateRunning": "运行中", "status": "执行器状态", "stuck": "卡顿", - "temporary": "临时" + "temporary": "临时", + "daysAgo_one": "", + "hoursAgo_one": "", + "minutesAgo_one": "", + "overlapBottleneck_one": "", + "secondsAgo_one": "" }, "fileBrowser": { "back": "返回文件列表", @@ -1906,8 +1943,8 @@ "advancesHelpItem2": "reachable / subsumed / orphaned / superseded — 已处理(包括等效内容已落地、原始 SHA 已消失或 HEAD 已与重写的集成提示对齐的历史重写情况)。", "advancesHelpItem3": "pending + off / not run — 设置中禁用了自动同步;分支引用已移动,但工作树未跟进。", "advancesHelpItem4": "pending + stash-failed / would-conflict / 类似情况 — 自动同步尝试了但无法调和(通常是本地编辑与新提交冲突)。", - "advancesNeedAction": "{{count}} 个需要处理", - "aheadOfUpstream": "领先上游 {{count}} 个提交", + "advancesNeedAction_other": "", + "aheadOfUpstream_other": "", "aligned": "已对齐", "apply": "应用", "applyStashKeep": "应用储藏(保留)", @@ -1923,7 +1960,7 @@ "backToIssuesList": "返回 Issue 列表", "backToPullsList": "返回 PR 列表", "baseHead": "基于:HEAD", - "behindUpstream": "落后上游 {{count}} 个提交", + "behindUpstream_other": "", "branchLabel": "分支:", "cancel": "取消", "capturedAt": "捕获时间:", @@ -1936,16 +1973,16 @@ "commentLast": "最后:", "commit": "提交", "commitMessagePlaceholder": "提交信息……", - "commitStagedChanges": "提交已暂存更改", "commitsOnBranch": "{{name}} 上的提交", - "commitsToPull": "{{count}} 个待拉取", - "commitsToPush": "{{count}} 个待推送", - "commitsToPushHeader": "待推送提交({{count}})", + "commitStagedChanges": "提交已暂存更改", + "commitsToPull_other": "", + "commitsToPush_other": "", + "commitsToPushHeader_other": "", "committedHash": "已提交:{{hash}}", + "conflictedCount_other": "", "conflictReclaimFailed": "添加冲突修复任务失败", "conflictReclaimQueued": "冲突修复任务已加入队列", "conflictReclaimUnavailable": "冲突修复不可用", - "conflictedCount": "{{count}} 个冲突", "conflictsButton": "冲突", "copiedButton": "已复制", "copiedLabel": "已复制 {{label}}", @@ -1962,9 +1999,9 @@ "couldNotLoadIssues": "无法加载 Issue", "couldNotLoadPulls": "无法加载 PR", "create": "创建", + "createdBranch": "已创建分支 {{name}}", "createPrButton": "创建 PR", "createPrTitle": "为此任务创建 PR", - "createdBranch": "已创建分支 {{name}}", "defaultBadge": "默认", "deleteBranch": "删除", "deleteBranchMessage": "删除分支「{{name}}」?", @@ -1972,10 +2009,10 @@ "deletedBranch": "已删除分支 {{name}}", "detectingRemotes": "检测中……", "diffColon": "差异:", - "discardChangesMessage": "放弃 {{count}} 个文件的更改?此操作无法撤销。", + "discardChangesMessage_other": "", "discardChangesTitle": "放弃更改", + "discardedFiles_other": "", "discardSelected": "放弃选中", - "discardedFiles": "已放弃 {{count}} 个文件的更改", "dismiss": "忽略", "dismissPrError": "关闭 PR 错误", "dropStash": "删除暂存", @@ -2011,9 +2048,9 @@ "fetch": "拉取", "fetchCompleted": "拉取完成", "fetchFailed": "拉取失败", + "fetchingFromGitHub": "正在从 GitHub 获取最新列表。", "fetchLabel": "拉取:", "fetchUrlLabel": "拉取 URL", - "fetchingFromGitHub": "正在从 GitHub 获取最新列表。", "filterBranches": "筛选分支……", "filterByLabelsLabel": "按标签过滤", "filterByLabelsPlaceholder": "过滤:bug,enhancement……", @@ -2025,24 +2062,22 @@ "forceDeletedBranch": "已强制删除分支 {{name}}", "fullShaAbbrev": "完整", "ghAuthLoginHint": "运行 {{code}} 以启用 PR 创建。", - "headAheadOfIntegration": "HEAD 有 {{count}} 个提交不在 {{branch}} 上", - "headAheadOfOriginIntegration": "HEAD 有 {{count}} 个提交不在 origin/{{branch}} 上", + "headAheadOfIntegration_other": "", + "headAheadOfOriginIntegration_other": "", "headVsIntegration": "HEAD 与 {{branch}} 对比", "headVsOriginIntegration": "HEAD 与 origin/{{branch}} 对比", "hide": "隐藏", "hideExplanation": "隐藏说明", "import": "导入", + "imported": "已导入", + "importedCount_other": "", "importFromGitHub": "从 GitHub 导入", "importSubtitle": "选择检测到的远端,加载开放中的 Issue 或 PR,并导入到看板。", "importTypeAriaLabel": "导入类型", - "imported": "已导入", - "importedCount": "已导入 {{count}} 个", - "integrationAheadOfHead": "{{branch}} 有 {{count}} 个 HEAD 没有的提交", - "issueCount": "{{count}} 个 Issue", + "integrationAheadOfHead_other": "", + "issueCount_other": "", "load": "加载", "loadFromRepoAriaLabel": "从仓库加载 {{tab}}", - "loadMoreCommits": "加载更多提交", - "loadTabTitle": "加载 {{tab}}", "loading": "加载中……", "loadingAriaLabel": "正在加载 {{tab}}", "loadingCommits": "加载提交中……", @@ -2051,23 +2086,25 @@ "loadingPulls": "正在加载开放中的 PR……", "loadingStashDiff": "加载储藏差异中……", "loadingTitle": "加载中……", - "localAheadOfOriginIntegration": "本地 {{branch}} 领先 origin/{{branch}} {{count}} 个提交", - "localBehindOriginIntegration": "本地 {{branch}} 落后 origin/{{branch}} {{count}} 个提交", + "loadMoreCommits": "加载更多提交", + "loadTabTitle": "加载 {{tab}}", + "localAheadOfOriginIntegration_other": "", + "localBehindOriginIntegration_other": "", "localVsOrigin": "本地 {{branch}} 与 origin 对比", "manualPrFlowHint": "使用底部操作为此任务运行 PR 优先完成流程。", "mergeBadge": "合并", "mergeConflictDetected": "检测到合并冲突,请手动解决。", + "mergedTaskDone": "已合并 — 任务已移至完成", "mergeLabel": "合并", "mergePrButton": "合并拉取请求", "mergeStrategyMerge": "合并", "mergeStrategyRebase": "变基", "mergeStrategySquash": "压缩合并", - "mergedTaskDone": "已合并 — 任务已移至完成", "mergingPrHint": "正在合并拉取请求……", "mergingStatus": "合并中……", "modalTitle": "Git 管理器", "modified": "已修改", - "modifiedCount": "{{count}} 个已修改", + "modifiedCount_other": "", "newBranchName": "新分支名称", "noAheadCommitsFound": "未找到领先提交(可能需要先 Fetch)", "noBranchesFound": "未找到分支", @@ -2081,9 +2118,7 @@ "noMatchingBranches": "无匹配分支", "noMatchingCommits": "无匹配提交", "noOpenIssues": "未找到开放中的 Issue", - "noOpenIssuesFound": "未找到开放中的 Issue", "noOpenPulls": "未找到开放中的 PR", - "noOpenPullsFound": "未找到开放中的 PR", "noOriginTracking": "无 origin 跟踪", "noPullSelected": "未选择 PR", "noPullSelectedHint": "从列表中选择一个 PR 以查看其详情。", @@ -2097,14 +2132,14 @@ "noStagedChanges": "无已暂存更改", "noStagedChangesToCommit": "没有可提交的已暂存更改", "noStashes": "无储藏", - "noUnstagedChanges": "无未暂存更改", + "nothingLoadedInstructions": "选择仓库并点击加载,开始审阅可导入的内容。", + "nothingLoadedYet": "尚未加载", "notOnIntegrationBranch": "(不在 {{branch}} 上)", "notOnIntegrationBranchBtn": "不在集成分支({{branch}})上", "notOnIntegrationBranchTitle": "当前位于非集成分支", - "nothingLoadedInstructions": "选择仓库并点击加载,开始审阅可导入的内容。", - "nothingLoadedYet": "尚未加载", + "noUnstagedChanges": "无未暂存更改", "openPullsFrom": "来自 {{remote}} 的开放 PR", - "originIntegrationAheadOfHead": "origin/{{branch}} 有 {{count}} 个 HEAD 没有的提交", + "originIntegrationAheadOfHead_other": "", "pop": "弹出", "popStashTitle": "弹出储藏(应用并删除)", "prAuthUnavailable": "PR 授权不可用 — 请运行 'gh auth login'", @@ -2116,34 +2151,34 @@ "summary": "{{passing}} 通过,{{failing}} 失败,{{pending}} 待处理", "viewDetails": "查看详情" }, - "prMergeFailed": "合并拉取请求失败", + "previewHeading": "预览", + "previewIssueMeta": "Issue #{{number}}", + "previewPullMeta": "PR #{{number}}", "prMerged": "拉取请求已合并", + "prMergeFailed": "合并拉取请求失败", + "projectRootNotAvailable": "项目根路径不可用", "prRefreshFailed": "刷新 PR 失败", "prStatusRefreshed": "PR 状态已刷新", "prUnlinkConfirm": "从此任务取消关联 PR #{{number}}?PR 不会被关闭。", "prUnlinked": "已取消关联 PR #{{number}}", - "previewHeading": "预览", - "previewIssueMeta": "Issue #{{number}}", - "previewPullMeta": "PR #{{number}}", - "projectRootNotAvailable": "项目根路径不可用", "pull": "Pull", "pullCompleted": "Pull 完成", - "pullCount": "{{count}} 个 PR", + "pullCount_other": "", "pullFailed": "Pull 失败", "pullOptions": "Pull 选项", "pullOptionsMenu": "Pull 选项菜单", "pullRebase": "Pull --rebase", "pullRebaseCompleted": "Pull --rebase 完成", "pullRequestHeading": "拉取请求", - "pullRequestsCount": "{{count}} 个拉取请求", + "pullRequestsCount_other": "", "push": "Push", "pushCompleted": "Push 完成", "pushFailed": "Push 失败", "pushLabel": "推送:", "pushUrlLabel": "推送 URL", - "reCheckConflicts": "重新检查冲突", "recentCommitsOnRemote": "{{remote}} 上的近期提交", "recentIntegrationAdvances": "近期集成分支推进", + "reCheckConflicts": "重新检查冲突", "refresh": "刷新", "refreshPrStatus": "刷新 PR 状态", "refreshToCheckMerge": "刷新 PR 状态以检查合并准备情况", @@ -2176,24 +2211,24 @@ "sectionStashes": "储藏", "sectionStatus": "状态", "sectionWorktrees": "工作树", + "selectedRemote": "所选远端", "selectFileToViewDiff": "选择文件以查看差异", "selectIssueAriaLabel": "选择 Issue #{{number}}", "selectPullAriaLabel": "选择 PR #{{number}}", "selectRemoteAriaLabel": "选择 Git 远端", "selectRemotePlaceholder": "选择远端……", "selectRemoteToViewDetails": "选择远程以查看详情", - "selectedRemote": "所选远端", "sidebarAriaLabel": "Git 管理器各区块", "stageAll": "全部暂存", "stageAllAndCommit": "全部暂存并提交", "stageAllAndCommitTitle": "全部暂存并提交", - "stageCount": "暂存({{count}})", + "stageCount_other": "", + "staged": "已暂存", + "stagedChanges_other": "", + "stagedCount_other": "", + "stagedFiles_other": "", "stageFile": "暂存文件", "stageSelected": "暂存选中", - "staged": "已暂存", - "stagedChanges": "已暂存更改({{count}})", - "stagedCount": "{{count}} 个已暂存", - "stagedFiles": "已暂存 {{count}} 个文件", "staleIndexWarning": "检测到过时的索引。 HEAD 已前进(通常是因为 Fusion 的合并器更新了集成分支引用),但索引仍反映之前的提示 — `git status` 将把新提交倒置显示为\"已暂存的更改\"。在设置中启用 mergeAdvanceAutoSync 让合并器自动调和,或运行 git reset --hard HEAD 手动追上。", "stash": "储藏", "stashApplied": "已应用储藏", @@ -2220,17 +2255,17 @@ "statusLabelWorkingTree": "工作区", "switchedToBranch": "已切换到 {{name}}", "sync": "同步", + "synced": "已同步", + "syncedWithOrigin": "已与 origin 同步(pull --rebase + push)", + "syncedWorktreeToIntegrationTip": "已将工作树同步到本地集成分支顶端", "syncFailed": "同步失败", + "syncing": "同步中……", "syncLocalTip": "同步本地顶端", "syncLocalTipTitle": "将工作树同步到本地集成分支顶端(与横幅 Pull 相同)", "syncOriginTitle": "从 origin pull --rebase,然后推送当前分支", "syncWithOriginFailed": "与 origin 同步失败", "syncWorkingTree": "同步工作树", "syncWorkingTreeTitle": "将集成分支拉取到工作树(自动储藏未提交的编辑并还原)", - "synced": "已同步", - "syncedWithOrigin": "已与 origin 同步(pull --rebase + push)", - "syncedWorktreeToIntegrationTip": "已将工作树同步到本地集成分支顶端", - "syncing": "同步中……", "tabIssues": "Issues", "tabPullRequests": "Pull Requests", "tip": "最新", @@ -2239,14 +2274,14 @@ "unlinkButton": "取消关联", "unresolvedMergeConflicts": "未解决的合并冲突", "unstageAll": "全部取消暂存", - "unstageCount": "取消暂存({{count}})", + "unstageCount_other": "", + "unstaged": "未暂存", + "unstagedChanges_other": "", + "unstagedFiles_other": "", "unstageFile": "取消暂存文件", "unstageSelected": "取消暂存选中", - "unstaged": "未暂存", - "unstagedChanges": "未暂存更改({{count}})", - "unstagedFiles": "已取消暂存 {{count}} 个文件", "untracked": "未跟踪", - "untrackedCount": "{{count}} 个未跟踪", + "untrackedCount_other": "", "upToDate": "已是最新", "view": "查看", "viewOnGithub": "在 GitHub 上查看", @@ -2256,18 +2291,48 @@ "workingTreeModified": "已修改", "worktreeBadgeBare": "裸库", "worktreeBadgeMain": "主", - "worktreesInUse": "{{count}} 个使用中", - "worktreesTotal": "共 {{count}} 个" + "worktreesInUse_other": "", + "worktreesTotal_other": "", + "advancesNeedAction_one": "", + "aheadOfUpstream_one": "", + "behindUpstream_one": "", + "commitsToPull_one": "", + "commitsToPush_one": "", + "commitsToPushHeader_one": "", + "conflictedCount_one": "", + "discardChangesMessage_one": "", + "discardedFiles_one": "", + "headAheadOfIntegration_one": "", + "headAheadOfOriginIntegration_one": "", + "importedCount_one": "", + "integrationAheadOfHead_one": "", + "issueCount_one": "", + "localAheadOfOriginIntegration_one": "", + "localBehindOriginIntegration_one": "", + "modifiedCount_one": "", + "originIntegrationAheadOfHead_one": "", + "pullCount_one": "", + "pullRequestsCount_one": "", + "stageCount_one": "", + "stagedChanges_one": "", + "stagedCount_one": "", + "stagedFiles_one": "", + "unstageCount_one": "", + "unstagedChanges_one": "", + "unstagedFiles_one": "", + "untrackedCount_one": "", + "worktreesInUse_one": "", + "worktreesTotal_one": "" }, "goals": { - "activeCount": "{{count}} 个活动目标", + "activeCount_other": "", "addGoal": "添加目标", "archive": "存档", "capError": "无法激活超过 5 个目标。激活另一个之前,请解决一个活动目标。", "capWarning": "接近 5 个活动目标上限。保持活动目标集中。", "createError": "现在无法创建目标。请重试。", - "draftWithAi": "使用 AI 起草", "drafting": "起草中…", + "draftWithAi": "使用 AI 起草", "emptyState": "还没有目标。添加一个以开始跟踪战略结果。", "labelDescription": "描述", "labelTitle": "标题", @@ -2278,9 +2343,11 @@ "title": "目标", "titleRequired": "标题是必需的。", "unarchive": "取消存档", - "updateError": "现在无法更新目标状态。请重试。" + "updateError": "现在无法更新目标状态。请重试。", + "activeCount_one": "" }, "groupTask": { + "abandonGroup": "", "ariaLabel": "分支组详情", "autoMergeEnabled": "自动合并已启用", "completionText": "{{landed}} 个成员中有 {{total}} 个已完成", @@ -2290,12 +2357,15 @@ "mergeIntoMain": "将组合并到主分支", "openPR": "打开 PR", "openTask": "打开任务", + "prClosed": "", + "prMerged": "", "sharedBranch": "共享分支", "status": "状态", "title": "分支组 {{id}}", "unavailable": "分支组不可用" }, "header": { + "activePlanningSessions_other": "", "addFirstScript": "添加第一个脚本", "additionalHeaderActions": "更多标题栏操作", "agentsView": "代理视图", @@ -2321,7 +2391,7 @@ "localNode": "本地", "mailbox": "邮箱", "mailboxView": "邮箱视图", - "mailboxWithCount": "邮箱({{count}})", + "mailboxWithCount_other": "", "manageProjects": "管理项目", "manageScripts": "管理脚本…", "memoryView": "记忆", @@ -2330,10 +2400,10 @@ "moreHeaderActions": "更多标题栏操作", "moreViews": "更多视图", "noBaseBranch": "无基础分支", + "nodes": "节点", "noScriptsAddOne": "暂无脚本,添加一个…", "noScriptsConfigured": "未配置脚本", "noWorkingBranch": "无工作分支", - "nodes": "节点", "openSearch": "打开搜索", "openTerminal": "打开终端", "pauseTriage": "暂停分类", @@ -2343,7 +2413,7 @@ "reliabilityView": "可靠性", "researchView": "研究", "resumePlanningSession": "恢复规划会话", - "resumePlanningSessionCount": "恢复规划会话({{count}})", + "resumePlanningSessionCount_other": "", "resumeScheduling": "恢复调度", "scripts": "脚本", "scriptsSubmenu": "脚本子菜单", @@ -2364,21 +2434,19 @@ "terminal": "终端", "todosView": "待办事项", "unreadChatResponse": "未读聊天回复", - "unreadMessages": "{{count}} 条未读消息", + "unreadMessages_other": "", "viewActivityLog": "查看活动日志", "viewProjects": "查看项目", "viewUsage": "查看用量", "workflowSteps": "工作流步骤", - "workingBranch": "工作分支" + "workingBranch": "工作分支", + "activePlanningSessions_one": "", + "mailboxWithCount_one": "", + "resumePlanningSessionCount_one": "", + "unreadMessages_one": "" }, "health": { "activeTasks": "活动任务", - "anomaly": { - "duplicateActiveId": "重复的活动任务 ID", - "idInBothStorages": "任务 ID 同时出现在活动和存档存储中", - "sequenceOverlap": "分配器下一个序列与现有任务 ID 重叠", - "unknownPrefix": "任务行使用分配器状态外的前缀" - }, "anomalyBody": "Fusion 发现分配器状态可能导致任务 ID 被重用或覆盖活动任务记录。", "anomalyDetected": "检测到任务 ID 完整性异常", "completed": "已完成", @@ -2436,26 +2504,22 @@ "collapse": "折叠", "collapseDescription": "折叠描述", "collapseTaskOptions": "折叠高级任务选项", - "connecting": "连接中", "creating": "创建中...", "custom": "自定义", "deps": "依赖", "editingDescription": "编辑描述", "enableBrowserVerification": "启用浏览器验证工作流步骤", "enterDescriptionFirst": "先输入描述", - "error": "错误", "expand": "展开", "expandDescription": "展开描述", "expandTaskOptions": "展开高级任务选项", "hintEnterEsc": "按 Enter 创建 · Esc 取消", "loadingAgents": "加载代理...", - "model": "模型", + "model_other": "", "models": "模型", "noAgentsAvailable": "没有可用的代理", - "noExistingTasks": "没有现有任务", "node": "节点", - "offline": "离线", - "online": "在线", + "noExistingTasks": "没有现有任务", "openPlanningMode": "以当前描述打开规划模式", "plan": "计划", "preset": "预设", @@ -2469,45 +2533,29 @@ "selectExecutionNode": "选择执行节点", "subtask": "子任务", "useDefault": "使用默认值", - "whatNeedsToBeDone": "需要做什么?" + "whatNeedsToBeDone": "需要做什么?", + "model_one": "" }, "insights": { "allInsights": "所有洞察", "alreadyRunning": "洞察生成已在运行。显示活动运行。", "alreadyRunningShort": "洞察生成已在运行", - "archiveLabel": "存档此洞察", - "archiveTitle": "存档此洞察", "archived": "已存档\"{{title}}\"", "archivedMsg": "已存档的洞察:{{title}}", + "archiveLabel": "存档此洞察", + "archiveTitle": "存档此洞察", "archiving": "正在存档\"{{title}}\"...", "backlogHealth": "积压健康", - "category": { - "architecture": "架构", - "competitive_analysis": "竞争分析", - "dependency": "依赖项", - "documentation": "文档", - "features": "功能", - "other": "其他", - "performance": "性能", - "quality": "质量", - "reliability": "可靠性", - "research": "研究", - "security": "安全", - "testability": "可测试性", - "trends": "趋势", - "ux": "用户体验", - "workflow": "工作流程" - }, "configureModel": "配置洞察生成模型", "configureModelTitle": "配置模型", "createTaskLabel": "从此洞察创建任务", "createTaskTitle": "从此洞察创建任务", "creatingTask": "正在从\"{{title}}\"创建任务...", - "dismissLabel": "关闭此洞察", - "dismissTitle": "关闭此洞察", "dismissed": "已关闭\"{{title}}\"", "dismissedMsg": "已关闭的洞察:{{title}}", "dismissing": "正在关闭\"{{title}}\"...", + "dismissLabel": "关闭此洞察", + "dismissTitle": "关闭此洞察", "failedToArchive": "存档洞察失败", "failedToCreateTask": "创建任务失败", "failedToDismiss": "关闭洞察失败", @@ -2516,6 +2564,7 @@ "failedToUnarchive": "取消存档洞察失败", "generateDescription": "生成洞察以获得项目的 AI 驱动建议。", "generateFirst": "生成第一个洞察", + "generateInsights": "", "generateInsightsBtn": "生成洞察", "generating": "生成中...", "generatingInsights": "生成洞察中...", @@ -2531,18 +2580,19 @@ "runCompleted": "已创建 {{created}} 个,已更新 {{updated}} 个", "showAllInsights": "显示所有洞察", "showArchived": "显示已存档的洞察", - "showArchivedLabel": "显示已存档 ({{count}})", + "showArchivedLabel_other": "", "showBacklogHealth": "仅显示积压健康洞察", "taskCreated": "从\"{{title}}\"创建的任务", "taskCreatedMsg": "已创建的任务:{{title}}", "taskCreationUnavailable": "在此视图中任务创建不可用", "title": "洞察", - "unarchiveLabel": "取消存档此洞察", - "unarchiveTitle": "取消存档此洞察", "unarchived": "已取消存档\"{{title}}\"", "unarchivedMsg": "已取消存档的洞察:{{title}}", + "unarchiveLabel": "取消存档此洞察", + "unarchiveTitle": "取消存档此洞察", "unarchiving": "正在取消存档\"{{title}}\"...", - "usePlanningDefault": "使用规划默认值" + "usePlanningDefault": "使用规划默认值", + "showArchivedLabel_one": "" }, "interview": { "addContextDirection": "添加任何额外的上下文或方向...", @@ -2570,8 +2620,8 @@ "preparingQuestion": "准备下一个问题...", "progressText": "问题{{progress}}/约6个", "reconnecting": "重新连接中…", - "refineScope": "用AI精炼{{label}}范围", "refinedScope": "精炼范围", + "refineScope": "用AI精炼{{label}}范围", "sendToBackground": "发送到后台", "sessionActiveAnotherTab": "会话在另一个标签页中处于活跃状态。", "showThinking": "显示思考", @@ -2586,6 +2636,10 @@ "verificationCriteria": "验证标准", "yes": "是" }, + "lane": { + "collapse": "", + "expand": "" + }, "listView": { "apply": "应用", "applying": "应用中…", @@ -2593,16 +2647,15 @@ "archiveSelectedTitle": "归档选中的已完成任务", "archiveUnavailable": "归档操作不可用", "archiveViaButton": "任务只能通过归档按钮归档", - "bulkArchiveDone": "归档 {{count}} 个已完成", - "bulkArchiveMessage": "归档 {{count}} 个选中的任务?", + "bulkArchiveDone_other": "", + "bulkArchiveMessage_other": "", "bulkArchiveNoTasks": "没有可以归档的选中任务(只有已完成的任务)", "bulkArchiveSummary": "已归档 {{archived}} · {{skipped}} 已跳过 · {{failed}} 失败", "bulkArchiveTitle": "归档选中的任务", "bulkDeleteAll": "全部删除", "bulkDeleteArchiveSummary": "已归档 {{archived}},已删除 {{deleted}},失败 {{failed}}", - "bulkDeleteMessage": "删除 {{count}} 个选中的任务?", + "bulkDeleteMessage_other": "", "bulkDeleteNoTasks": "没有可删除的选中任务(已归档的任务除外)", - "bulkDeleteSummary": "已删除 {{deleted}} 个任务 · {{skipped}} 个已归档已跳过 · {{failed}} 个失败", "bulkDeleteSummary_one": "已删除 {{count}} 个任务 · 跳过 {{skipped}} 个已归档 · {{failed}} 个失败", "bulkDeleteSummary_other": "已删除 {{count}} 个任务 · 跳过 {{skipped}} 个已归档 · {{failed}} 个失败", "bulkDeleteTitle": "删除选中的任务", @@ -2616,7 +2669,7 @@ "bulkUnpauseSummary": "已恢复 {{unpaused}} · {{skipped}} 已跳过 · {{failed}} 失败", "bulkUpdateFailed": "更新模型失败", "bulkUpdateNoTasks": "没有可更新的有效任务(已归档的任务无法修改)", - "bulkUpdateSuccess": "已更新 {{count}} 个任务", + "bulkUpdateSuccess_other": "", "cancelMove": "取消移动", "clear": "清除", "clearColumnFilter": "清除列过滤器", @@ -2636,7 +2689,7 @@ "filterChip": "过滤:{{column}}", "forceDelete": "强制删除", "forceDeleteTitle": "强制删除任务", - "hidden": "已隐藏 {{count}}", + "hidden_other": "", "hideDone": "隐藏已完成", "hideDoneTitle": "隐藏已完成的任务", "keepProgress": "保留进度", @@ -2646,18 +2699,18 @@ "listControlsLabel": "列表控件", "newTask": "+ 新建任务", "noChange": "不更改", - "noTasks": "暂无任务", - "noTasksMatch": "没有任务匹配您的过滤条件", - "noTasksYet": "暂无任务", "nodeOverrideLabel": "节点覆盖", "nodeStatusConnecting": "连接中", "nodeStatusError": "错误", "nodeStatusOffline": "离线", "nodeStatusOnline": "在线", + "noTasks": "暂无任务", + "noTasksMatch": "没有任务匹配您的过滤条件", + "noTasksYet": "暂无任务", + "pausedByAgent": "已被代理暂停", "pauseSelected": "暂停选中", "pauseSelectedTitle": "暂停所有未暂停的选中任务", "pauseUnavailable": "暂停操作不可用", - "pausedByAgent": "已被代理暂停", "preserveProgressMessage": "此任务有已完成的步骤。移动前保留进度?", "preserveProgressTitle": "保留进度?", "resetProgress": "重置进度", @@ -2666,9 +2719,9 @@ "resizeSidebar": "调整任务列表侧边栏大小", "reviewerModel": "审查器模型", "selectAll": "选择所有可见任务", + "selectedCount_other": "", "selectTask": "选择 {{taskId}}", "selectTaskPrompt": "选择一个任务以查看详情", - "selectedCount": "已选 {{count}}", "showAll": "显示全部", "showAllTitle": "显示所有任务", "showDone": "显示已完成", @@ -2677,8 +2730,8 @@ "staleOnlyTitle": "仅显示过期任务", "stalePausedReview": "过期暂停审核", "stalePausedReviewTitle": "仅显示过期暂停审核任务", - "stats": "{{total}} 个任务中的 {{count}} 个", - "statsInColumn": "{{column}} 中 {{total}} 个任务里的 {{count}} 个", + "stats_other": "", + "statsInColumn_other": "", "statusMergingFix": "合并修复中…", "stuck": "卡住", "taskCreationUnavailable": "任务创建不可用", @@ -2686,12 +2739,18 @@ "unpauseSelectedTitle": "恢复当前已暂停的选中任务", "unpauseUnavailable": "恢复操作不可用", "useProjectDefault": "使用项目默认", - "viewOptions": "视图选项" + "viewOptions": "视图选项", + "bulkArchiveDone_one": "", + "bulkArchiveMessage_one": "", + "bulkDeleteMessage_one": "", + "bulkUpdateSuccess_one": "", + "hidden_one": "", + "selectedCount_one": "", + "stats_one": "", + "statsInColumn_one": "" }, "mailbox": { "agent": "代理", - "agentById": "代理:{{id}}", - "agentByName": "代理:{{name}}", "agents": "代理", "agentsTab": "代理", "ago": "前", @@ -2702,8 +2761,8 @@ "approvalDeny": "拒绝", "approvalRequested": "请求时间", "approvalRequester": "请求者", - "approvalTask": "任务", "approvals": "审批", + "approvalTask": "任务", "back": "返回", "backButton": "← 返回", "closeAriaLabel": "关闭", @@ -2732,8 +2791,8 @@ "markAllRead": "全部已读", "markAllReadButton": "全部标记为已读", "markAllReadTitle": "全部标记为已读", + "markedAsRead_other": "", "markReadFailed": "无法将消息标记为已读", - "markedAsRead": "标记 {{count}} 条消息为已读", "messageDeleted": "消息已删除", "messageSent": "消息已发送", "noAgentMessages": "没有代理间消息", @@ -2753,15 +2812,15 @@ "refreshTitle": "刷新", "reply": "回复", "replyButton": "回复", - "replyLoadFailed": "加载回复消息失败。点击重试。", "replyingTo": "回复 {{preview}}", "replyingToMessage": "回复消息", + "replyLoadFailed": "加载回复消息失败。点击重试。", "selectMessageToRead": "选择要阅读的消息", "system": "系统", - "timeDaysAgo": "{{count}} 天前", - "timeHoursAgo": "{{count}} 小时前", + "timeDaysAgo_other": "", + "timeHoursAgo_other": "", "timeJustNow": "刚刚", - "timeMinsAgo": "{{count}} 分钟前", + "timeMinsAgo_other": "", "title": "邮箱", "to": "至", "toLabel": "至:", @@ -2771,8 +2830,11 @@ "typeSystem": "系统", "typeUserToAgent": "你 → 代理", "user": "用户", - "userLabel": "用户:{{id}}", - "you": "你" + "you": "你", + "markedAsRead_one": "", + "timeDaysAgo_one": "", + "timeHoursAgo_one": "", + "timeMinsAgo_one": "" }, "memory": { "auditChecksTitle": "审计检查", @@ -2789,32 +2851,32 @@ "capReadable": "可读", "capWritable": "可写", "categories": "分类", - "charCount": "{{count}} 个字符", + "charCount_other": "", "compactFailed": "压缩记忆失败", - "compactSelectedFile": "压缩所选文件", "compacting": "正在压缩…", "compactionThresholdHint": "当记忆超过此字符数时将自动压缩", "compactionThresholdLabel": "压缩阈值(字符数)", + "compactSelectedFile": "压缩所选文件", "currentBackendTitle": "当前后端", "description": "工作记忆、长期洞察和引擎状态", "disabledMessage": "记忆当前已禁用。请在设置中启用记忆工具以编辑这些自动化。", + "dreaming": "正在梦境处理…", "dreamNow": "立即处理梦境", "dreamNowHint": "立即手动触发梦境处理。", "dreamProcessingComplete": "梦境处理已完成", "dreamProcessingFailed": "运行梦境处理失败", - "dreaming": "正在梦境处理…", "dreamsEnabledHint": "将每日笔记转换为 DREAMS.md,并将可复用的经验提升到 MEMORY.md。", "dreamsEnabledLabel": "从每日记忆中处理梦境", "dreamsScheduleHint": "梦境处理的 Cron 表达式。", "dreamsScheduleLabel": "梦境计划", - "editRaw": "编辑原始内容", "editorDefaultDescription": "编辑所选记忆文件。", "editorLabel": "记忆编辑器", - "extractInsightsFailed": "提取洞察失败", - "extractNow": "立即提取", + "editRaw": "编辑原始内容", "extracting": "正在提取…", + "extractInsightsFailed": "提取洞察失败", "extractionFailed": "失败", "extractionSuccess": "成功", + "extractNow": "立即提取", "fileCompacted": "记忆文件已压缩", "fileLabel": "记忆文件", "fileSummary": "{{size}} 字节 · 更新于 {{updatedAt}}", @@ -2824,13 +2886,13 @@ "healthIssues": "发现问题", "healthStatusTitle": "健康状态", "healthWarning": "警告", - "insightCount": "{{count}} 条洞察", - "insightsExtracted": "已提取 {{count}} 条洞察", + "insightCount_other": "", + "insightsExtracted_other": "", "insightsMemoryLabel": "洞察记忆", "insightsSaved": "洞察已保存", + "installing": "正在安装…", "installQmd": "安装 qmd", "installQmdFailed": "安装 qmd 失败", - "installing": "正在安装…", "lastExtractionLabel": "最后提取", "lastUpdated": "最后更新", "layerDaily": "每日", @@ -2854,9 +2916,9 @@ "qmdAvailableOnPath": "qmd 已在 PATH 中可用。", "qmdChecking": "检查中", "qmdCheckingAvailability": "正在检查 qmd 是否可用…", + "qmdInstalled": "已安装", "qmdInstallSuccess": "qmd 安装成功", "qmdInstallUnavailable": "qmd 安装已完成,但 qmd 仍不可用", - "qmdInstalled": "已安装", "qmdIntegrationTitle": "QMD 集成", "qmdNotInstalled": "qmd 未安装。搜索将使用本地文件。安装索引检索:", "qmdPathUsed": "已使用 qmd 路径", @@ -2875,7 +2937,7 @@ "saveSettingsFailed": "保存记忆设置失败", "saving": "正在保存…", "searchPlaceholder": "使用 qmd 搜索记忆", - "sectionCount": "{{count}} 个章节", + "sectionCount_other": "", "settingsNote": "注意:在以下位置更改后端类型:", "settingsNoteLink": "设置 → 记忆", "settingsNoteToast": "打开「设置 → 记忆」以更改后端类型", @@ -2884,15 +2946,20 @@ "tabEngines": "引擎", "tabInsights": "洞察", "tabWorking": "工作记忆", + "testing": "正在测试…", "testMemorySearchTitle": "测试记忆搜索", - "testResultCount": "\"{{query}}\" 的 {{count}} 个结果", + "testResultCount_other": "", "testResultStatus": "qmd {{qmdStatus}} · {{fallbackStatus}}", "testRetrieval": "测试检索", "testSearchHint": "运行与代理使用的相同 qmd 支持的 memory_search 路径。", - "testing": "正在测试…", "title": "记忆", "totalInsights": "洞察总数", - "workingMemoryLabel": "工作记忆" + "workingMemoryLabel": "工作记忆", + "charCount_one": "", + "insightCount_one": "", + "insightsExtracted_one": "", + "sectionCount_one": "", + "testResultCount_one": "" }, "merge": { "advanced": "高级", @@ -2913,16 +2980,16 @@ "pr": "PR", "pulling": "拉取中…", "pushForceWithLease": "推送 (force-with-lease)", - "pushHeading": "推送 {{branch}} 到源 — 领先 {{count}} 个提交{{plural}}。", + "pushHeading_other": "", + "pushing": "推送中…", "pushSuccess": "已推送到 origin/{{branch}} @ {{sha}}。", "pushToOrigin": "推送到源", - "pushing": "推送中…", "recordedNoConfirm": "已记录,但未进行本地合并确认", "shortstatTitle": "最终提交简统计;有关所有任务提交的完整着陆差异,请参见 Changes 标签。", "smartPull": "智能拉取", "status": "状态", "title": "合并详情", - "unknown": "未知" + "pushHeading_one": "" }, "mesh": { "ariaLabel": "节点网格拓扑可视化", @@ -2947,23 +3014,23 @@ "addAssertion": "添加断言", "addContext": "添加任何额外的上下文或方向...", "addFeature": "添加功能", + "additionalComments": "其他评论(可选)", "addMilestone": "添加里程碑", "addSlice": "添加切片", - "additionalComments": "其他评论(可选)", "aiThinking": "AI 正在思考...", "aiValidatedAtRuntime": "运行时由 AI 验证", "aiValidatedMissionGate": "AI 验证的任务门控", "allFeaturesLinked": "所有功能已关联", "approvePlan": "批准计划", - "assertionCreateFailed": "创建断言失败", "assertionCreated": "断言已创建", + "assertionCreateFailed": "创建断言失败", "assertionFieldsRequired": "标题和断言文本不能为空", "assertionTextEditPlaceholder": "断言文本", "assertionTextPlaceholder": "断言文本(完成时应为真的内容)", "assertionTitlePlaceholder": "断言标题", - "assertionUpdateFailed": "更新断言失败", "assertionUpdated": "断言已更新", - "attemptRetries": "第 {{attempt}} 次尝试 · 剩余 {{count}} 次{{label}}", + "assertionUpdateFailed": "更新断言失败", + "attemptRetries_other": "", "autopilotActivatingSlice": "正在激活切片", "autopilotCompleting": "完成中", "autopilotDescription": "开启后,Fusion 会在工作完成时自动激活下一个切片并规划其功能。", @@ -2973,11 +3040,6 @@ "autopilotLabel": "自动驾驶", "autopilotLastActivation": "上次激活 {{time}}", "autopilotOff": "关闭", - "autopilotStateActivating": "正在激活切片", - "autopilotStateCompleting": "正在完成", - "autopilotStateInactive": "关闭", - "autopilotStateUnknown": "未知", - "autopilotStateWatching": "监视中", "autopilotUpdateFailed": "更新自动驾驶失败", "autopilotWatching": "自动驾驶监控中", "autopilotWatchingSince": "自 {{time}} 起监视", @@ -3009,20 +3071,20 @@ "confirmSlicePlaceholder": "如何确认该切片已完成...", "contractAssertions": "合同断言(AI 验证)", "createButton": "创建", - "createTask": "创建任务", "created": "任务已创建", "createdFromInterview": "任务已从 AI 访谈创建", + "createTask": "创建任务", "creatingMission": "正在创建任务...", "defaultInterviewTitle": "任务访谈", "deleteAssertion": "删除断言", "deleteButton": "删除", "deleteConfirm": "删除此 {{type}}?此操作无法撤销。", + "deleted": "任务已删除", "deleteFailed": "删除任务失败", "deleteFeature": "删除功能", "deleteMilestone": "删除里程碑", "deleteMission": "删除任务", "deleteSlice": "删除切片", - "deleted": "任务已删除", "describeGoal": "描述你想要构建的东西。AI 将采访你以了解范围、约束条件和需求,然后生成一个包含里程碑、切片和功能的结构化计划。", "descriptionLabel": "任务描述", "descriptionOptional": "描述(可选)", @@ -3047,23 +3109,23 @@ "failedLoadModels": "加载模型失败", "featureCreated": "功能已创建", "featureCriteriaAwaitingSync": "等待断言同步的功能标准", - "featureDeleteFailed": "删除功能失败", "featureDeleted": "功能已删除", - "featureLinkFailed": "链接功能失败", - "featureLinkTaskFailed": "将功能链接到任务失败", + "featureDeleteFailed": "删除功能失败", "featureLinkedToAssertion": "功能已链接到断言", "featureLinkedToTask": "功能已链接到任务", + "featureLinkFailed": "链接功能失败", + "featureLinkTaskFailed": "将功能链接到任务失败", "featureSaveFailed": "保存功能失败", + "featuresCount_other": "", "featureTitlePlaceholder": "功能标题", "featureTitleRequired": "功能标题不能为空", - "featureTriageFailed": "分类功能失败", "featureTriaged": "功能已分类 — 任务已创建", - "featureUnlinkFailed": "取消功能链接失败", - "featureUnlinkFromAssertionFailed": "取消功能链接失败", + "featureTriageFailed": "分类功能失败", "featureUnlinkedFromAssertion": "功能已从断言取消链接", "featureUnlinkedFromTask": "功能已从任务取消链接", + "featureUnlinkFailed": "取消功能链接失败", + "featureUnlinkFromAssertionFailed": "取消功能链接失败", "featureUpdated": "功能已更新", - "featuresCount": "{{count}} 个功能", "filterAll": "所有事件", "filterAutopilot": "自动驾驶事件", "filterErrors": "错误和警告", @@ -3074,9 +3136,6 @@ "generatedFixFeatures": "已生成修复功能:", "generatedFixFeaturesTitle": "已生成修复功能", "generatedFromFeature": "从功能生成:{{id}}", - "helperTextActive": "停止将暂停关联任务并将任务标记为已阻塞。", - "helperTextBlocked": "恢复将重新激活任务并继续执行。", - "helperTextPlanning": "启动将激活第一个切片以便工作开始。", "hideDetails": "隐藏详情", "hideMetadata": "隐藏元数据", "hideThinking": "隐藏思考", @@ -3090,42 +3149,36 @@ "interviewErrored": "访谈遇到错误。从此列表项重试。", "interviewGenerating": "正在从访谈上下文生成任务层次结构。", "interviewInProgress": "访谈进行中", - "interviewStatusAwaitingInput": "等待输入", - "interviewStatusComplete": "计划已就绪", - "interviewStatusError": "需要重试", - "interviewStatusGenerating": "正在生成计划", - "interviewStatusNeedsRetry": "需要重试", - "interviewStatusPlanReady": "计划已就绪", "interviewWaiting": "访谈正在等待您的下一个回复。", "lastValidatorStatus": "最近 {{status}}", "linkAFeature": "关联功能", "linkButton": "链接", - "linkFeatureButton": "关联功能", - "linkFeatureToTask": "将功能链接到任务:", - "linkToTask": "链接到任务", - "linkedCount": "{{count}} 个已关联", - "linkedFeaturesCount": "{{count}} 个已关联功能", + "linkedCount_other": "", + "linkedFeaturesCount_other": "", "linkedFeaturesLabel": "关联功能", "linkedGoals": "关联目标", "linkedGoalsTitle": "关联目标", + "linkFeatureButton": "关联功能", + "linkFeatureToTask": "将功能链接到任务:", + "linkToTask": "链接到任务", "loadActivityFailed": "加载任务活动失败", "loadDetailFailed": "加载任务详情失败", "loadFailed": "加载任务失败", - "loadMore": "加载更多", "loadingActivity": "正在加载任务活动…", "loadingMissionDetails": "正在加载任务详情…", "loadingMissions": "正在加载任务…", "loadingModels": "正在加载模型…", + "loadMore": "加载更多", "loopState": "循环状态:{{state}}", "milestoneCreated": "里程碑已创建", - "milestoneDeleteFailed": "删除里程碑失败", "milestoneDeleted": "里程碑已删除", + "milestoneDeleteFailed": "删除里程碑失败", "milestoneDescriptionPlaceholder": "里程碑描述...", "milestoneSaveFailed": "保存里程碑失败", + "milestonesCount_other": "", "milestoneTitlePlaceholder": "里程碑标题", "milestoneTitleRequired": "里程碑标题不能为空", "milestoneUpdated": "里程碑已更新", - "milestonesCount": "{{count}} 个里程碑", "missionHealthAriaLabel": "任务健康状态:{{state}}", "missionInterviewInProgressDesc": "任务访谈仍在进行中。打开此任务以继续规划。", "missionList": "任务列表", @@ -3143,44 +3196,41 @@ "noMilestonesYet": "暂无里程碑。添加一个以开始。", "noMissionsYetBody": "任务是将里程碑、切片和功能捆绑到单一计划中的大型计划。规划一个任务,将目标端到端分解,让代理以自动驾驶方式执行。", "noMissionsYetTitle": "暂无任务", + "none": "无", "noSlicesYet": "暂无切片", "noValidationRunsYet": "暂无验证运行。", - "none": "无", "openMissionAriaLabel": "打开任务 {{title}}", "orSelect": "或选择:", "planMilestone": "规划里程碑", "planNewMission": "规划新任务", + "planningModel": "规划模型", "planReady": "任务计划已准备好", "planSlice": "规划切片", "planStateNeedsUpdate": "需要更新", "planStateNotPlanned": "未规划", "planStatePlanned": "已规划", "planTitle": "用 AI 规划任务", - "planningModel": "规划模型", "prepareQuestion": "准备下一个问题...", - "progressText": "第 {{count}} 个问题,共 ~6 个", + "progressText_other": "", "reconnecting": "正在重新连接…", - "relativeTimeDays": "{{count}} 天前", - "relativeTimeHours": "{{count}} 小时前", + "relativeTimeDays_other": "", + "relativeTimeHours_other": "", "relativeTimeJustNow": "刚刚", - "relativeTimeMinutes": "{{count}} 分钟前", + "relativeTimeMinutes_other": "", "removeFeature": "删除功能", "removeMilestone": "删除里程碑", "removeSlice": "删除切片", "resizeSidebar": "调整任务侧栏大小", + "resumed": "任务已恢复", "resumeFailed": "恢复任务失败", "resumeInterviewAriaLabel": "恢复访谈 {{title}}", "resumeMission": "恢复任务", - "resumed": "任务已恢复", "retries": "次重试", "retry": "次重试", "retryBudgetTitle": "实现尝试次数和剩余重试次数", "retrying": "正在重试...", "roadmapLabel": "路线图", "run": "运行:", - "runHelperActive": "停止将暂停关联任务并将任务标记为已阻塞。", - "runHelperBlocked": "恢复将重新激活任务并继续执行。", - "runHelperPlanning": "启动后将激活第一个切片以开始工作。", "runSettings": "任务运行设置", "runSettingsTitle": "任务运行设置", "saveButton": "保存", @@ -3194,25 +3244,25 @@ "showMetadata": "显示元数据", "showThinking": "显示思考", "showValidationRounds": "显示验证轮次", - "sliceActivateFailed": "激活切片失败", "sliceActivated": "切片已激活", + "sliceActivateFailed": "激活切片失败", "sliceCreated": "切片已创建", - "sliceDeleteFailed": "删除切片失败", "sliceDeleted": "切片已删除", + "sliceDeleteFailed": "删除切片失败", "sliceSaveFailed": "保存切片失败", + "slicesCount_other": "", "sliceTitlePlaceholder": "切片标题", "sliceTitleRequired": "切片标题不能为空", + "sliceTriaged_other": "", "sliceTriageFailed": "分类切片功能失败", - "sliceTriaged": "已分类 {{count}} 个功能", "sliceUpdated": "切片已更新", "sliceVerification": "切片验证", - "slicesCount": "{{count}} 个切片", "source": "来源:", + "started": "任务已启动 — 第一个切片已激活", "startFailed": "启动任务失败", "startInterview": "开始采访", "startMission": "启动任务", "startOver": "重新开始", - "started": "任务已启动 — 第一个切片已激活", "statusActive": "进行中", "statusArchived": "已归档", "statusBlocked": "已阻塞", @@ -3227,9 +3277,9 @@ "statusTriaged": "已分类", "stopFailed": "停止任务失败", "stopMission": "停止任务", - "stopped": "任务已停止(已暂停 {{count}} 个任务)", + "stopped_other": "", "summaryStats": "{{milestones}} 个里程碑,{{features}} 个功能。批准前请审查和编辑。", - "tabActivity": "活动({{count}})", + "tabActivity_other": "", "tabStructure": "结构", "takeControl": "接管", "takingControl": "正在接管...", @@ -3237,7 +3287,7 @@ "targetBranchPlaceholder": "例如 main", "taskIdPlaceholder": "任务 ID(例如 FN-001)", "taskIdRequired": "任务 ID 不能为空", - "tasksFailed": "{{count}} 个失败", + "tasksFailed_other": "", "title": "任务", "titleLabel": "任务标题", "titleRequired": "任务标题不能为空", @@ -3247,25 +3297,41 @@ "triageCreateTask": "分类 — 创建任务", "tryExample": "尝试一个例子:", "typeAnswer": "在此输入你的答案...", + "unlinkedBadge": "未关联", "unlinkFeature": "取消关联功能", "unlinkTask": "取消链接任务", - "unlinkedBadge": "未关联", "untitled": "无标题", "updateButton": "更新", "updated": "任务已更新", "validateFeature": "验证功能", - "validationRoundsCount": "{{count}} 轮", - "validationRoundsLabel": "验证轮次({{count}})", + "validationRoundsCount_other": "", + "validationRoundsLabel_other": "", "validationRuns": "验证运行", "validationState": "验证状态", "validationStateNotStarted": "未开始", "validationTelemetry": "验证遥测", - "validationTriggerFailed": "触发验证失败", "validationTriggered": "已触发验证", + "validationTriggerFailed": "触发验证失败", "verification": "验证:", "verificationCriteria": "验证标准", "viewMissionFailures": "查看任务失败", - "whatToBuild": "你想要构建什么?" + "whatToBuild": "你想要构建什么?", + "attemptRetries_one": "", + "featuresCount_one": "", + "linkedCount_one": "", + "linkedFeaturesCount_one": "", + "milestonesCount_one": "", + "progressText_one": "", + "relativeTimeDays_one": "", + "relativeTimeHours_one": "", + "relativeTimeMinutes_one": "", + "slicesCount_one": "", + "sliceTriaged_one": "", + "stopped_one": "", + "tabActivity_one": "", + "tasksFailed_one": "", + "validationRoundsCount_one": "", + "validationRoundsLabel_one": "" }, "modalManager": { "createdFromPlanning": "在规划模式中创建了 {{id}}", @@ -3276,26 +3342,12 @@ "noChange": "不更改", "selectPlaceholder": "选择模型…" }, - "modelSelection": { - "choose": "为此任务选择模型。如果未选择,将使用默认模型。", - "custom": "自定义", - "executorModel": "执行模型", - "executorPlaceholder": "选择执行模型…", - "loading": "加载模型中…", - "noModels": "没有可用的模型。在设置中配置身份验证以启用模型选择。", - "preset": "预设", - "reviewerModel": "审查模型", - "reviewerPlaceholder": "选择审查模型…", - "title": "选择模型", - "useDefault": "使用默认", - "usingDefault": "使用默认" - }, "models": { "addProviderToFavoritesAriaLabel": "将 {{provider}} 添加到收藏", "addToFavorites": "添加到收藏", "addToFavoritesAriaLabel": "将 {{name}} 添加到收藏", "clearFilter": "清除筛选", - "count": "{{count}} 个模型", + "count_other": "", "descriptions": { "executor": "用于实现此任务的 AI 模型。", "override": "覆盖用于此任务的 AI 模型。如果未指定,则使用项目或全局默认值。", @@ -3320,8 +3372,6 @@ "thinkingLevel": "思维级别" }, "messages": { - "modelSetTo": "{{label}} 模型已设为 {{provider}}/{{modelId}}", - "modelSetToDefault": "{{label}} 模型已设为默认", "thinkingLevelSet": "思维级别设置为 {{level}}", "thinkingLevelSetDefault": "思维级别设置为默认值 ({{level}})", "upToDate": "模型设置已是最新的。", @@ -3348,15 +3398,25 @@ "loading": "正在加载可用模型…", "usingDefault": "使用默认值" }, - "targetLabels": { - "executor": "执行器", - "planning": "规划", - "validator": "审查者" - }, "titles": { "configuration": "模型配置" }, - "useDefault": "使用默认值" + "useDefault": "使用默认值", + "count_one": "" + }, + "modelSelection": { + "choose": "为此任务选择模型。如果未选择,将使用默认模型。", + "custom": "自定义", + "executorModel": "执行模型", + "executorPlaceholder": "选择执行模型…", + "loading": "加载模型中…", + "noModels": "没有可用的模型。在设置中配置身份验证以启用模型选择。", + "preset": "预设", + "reviewerModel": "审查模型", + "reviewerPlaceholder": "选择审查模型…", + "title": "选择模型", + "useDefault": "使用默认", + "usingDefault": "使用默认" }, "nav": { "activityLog": "活动日志", @@ -3380,8 +3440,8 @@ "missions": "任务集", "more": "更多", "moreSheetTitle": "导航", - "noScriptsAddOne": "无脚本 — 添加一个…", "nodes": "节点", + "noScriptsAddOne": "无脚本 — 添加一个…", "planning": "规划", "primaryNavAriaLabel": "主导航", "projects": "项目", @@ -3417,27 +3477,11 @@ "noAvailableTasks": "没有可用的任务", "searchTasks": "搜索任务…", "selectAgent": "选择代理", - "selectedCount": "已选择 {{count}} 个", + "selectedCount_other": "", "taskCreated": "已创建 {{taskId}}", "title": "新任务", - "unsavedChanges": "您有未保存的更改。放弃它们吗?" - }, - "nodeStatus": { - "connecting": "连接中", - "error": "错误", - "local": "本地", - "offline": "离线", - "online": "在线", - "unknown": "未知" - }, - "nodeSync": { - "error": { - "authSyncFailed": "认证同步失败", - "failedToFetchStatus": "获取同步状态失败", - "pullFailed": "拉取设置失败", - "pushFailed": "推送设置失败", - "someRequestsFailed": "部分同步状态请求失败" - } + "unsavedChanges": "您有未保存的更改。放弃它们吗?", + "selectedCount_one": "" }, "nodes": { "actions": { @@ -3481,22 +3525,16 @@ "addDockerNode": "添加 Docker 节点", "addDockerNodeTitle": "添加托管的 Docker 节点", "addFirstNode": "添加第一个节点", + "adding": "正在添加...", "addMountButton": "添加挂载", "addNode": "添加节点", "addVariableButton": "添加变量", - "adding": "正在添加...", "apiKey": "API密钥", "apiKeyMode": "API密钥模式", "apiKeyNotConfigured": "未配置", "apiKeyPlaceholder": "留空保持不变", "attachProjects": "附加现有项目", "attachProjectsHint": "选择现有项目以在此节点上运行,并为每个项目提供特定于节点的绝对路径。", - "auth": { - "differ": "认证凭据不同", - "differProviders": "认证凭据不同: {{providers}}", - "match": "认证凭据匹配", - "notSynced": "认证未同步" - }, "authSync": { "differ": "凭据不同", "label": "认证同步: {{status}}", @@ -3517,9 +3555,9 @@ "containerLogs": "容器日志", "description": "通过提供连接详情和并发设置来注册现有的Fusion节点。", "discoverBeforeAdding": "在添加此节点之前发现远程项目。", - "discoverRemoteProjects": "发现远程项目", - "discoveredCount": "已发现{{count}}个远程项目{{plural}}。", + "discoveredCount_other": "", "discovering": "正在发现...", + "discoverRemoteProjects": "发现远程项目", "discoveryFailed": "无法发现远程项目", "dismissError": "关闭错误", "docker": "Docker", @@ -3553,8 +3591,8 @@ "dockerPidsLimit": "PID 限制", "dockerPort": "端口", "dockerResourceDefault": "默认", - "dockerResourceSizing": "资源规格", "dockerResources": "资源", + "dockerResourceSizing": "资源规格", "dockerRetainOnDelete": "删除时保留", "dockerStatusUnknown": "未知", "dockerTlsCaCert": "TLS CA 证书路径", @@ -3567,11 +3605,11 @@ "editButton": "编辑", "errorFetching": "获取节点失败", "errorPersistMappings": "持久化项目映射失败", - "errorUnregisterAfterMappingFailure": "映射失败后注销节点失败", "errors": { "connectFailed": "连接失败", "connectToNode": "无法连接到节点" }, + "errorUnregisterAfterMappingFailure": "映射失败后注销节点失败", "failedCreateDocker": "创建 Docker 节点失败", "failedRefresh": "刷新节点失败", "failedRemove": "删除节点失败", @@ -3582,10 +3620,6 @@ "fieldCreated": "创建时间", "fieldMaxConcurrent": "最大并发数", "fieldName": "名称", - "fieldStatus": "状态", - "fieldType": "类型", - "fieldUpdated": "更新时间", - "fieldUrl": "网址", "fields": { "authKey": "认证密钥", "host": "主机 / IP地址", @@ -3594,6 +3628,10 @@ "port": "端口", "url": "URL" }, + "fieldStatus": "状态", + "fieldType": "类型", + "fieldUpdated": "更新时间", + "fieldUrl": "网址", "heading": "节点", "healthCheckButton": "健康检查", "healthCheckComplete": "节点健康检查完成", @@ -3623,6 +3661,7 @@ "namePlaceholder": "构建机器", "nameRequired": "名称为必填项", "no": "否", + "nodeLabel": "{{name}}({{type}})— {{status}}", "noLogsAvailable": "暂无日志", "noMatch": "没有完全匹配的远程名称。手动输入此路径。", "noProjects": "目前没有注册任何项目。", @@ -3630,7 +3669,6 @@ "noProjectsDiscovered": "在远程节点上未发现任何项目。", "noProjectsRunning": "此节点上没有正在运行的项目。", "noRegistered": "尚未注册任何节点。", - "nodeLabel": "{{name}}({{type}})— {{status}}", "offline": "离线", "online": "在线", "pathDiscovered": "发现远程权威路径:{{path}}", @@ -3642,22 +3680,22 @@ "optional": "可选" }, "provideManually": "手动提供密钥", + "pulling": "拉取中…", "pullSettings": "拉取设置", "pullSettingsFailed": "拉取设置失败", "pullSettingsSuccess": "设置拉取成功", - "pulling": "拉取中…", + "pushing": "推送中…", "pushSettings": "推送设置", "pushSettingsFailed": "推送设置失败", "pushSettingsSuccess": "设置推送成功", - "pushing": "推送中…", "reachableUrl": "可访问的URL / 主机名", "readOnly": "只读", "refresh": "刷新", - "refreshStatus": "刷新状态", "refreshing": "刷新中…", - "registerFailed": "无法注册节点", + "refreshStatus": "刷新状态", "registered": "节点\"{{name}}\"已注册", - "registeredCount": "{{count}} 个已注册", + "registeredCount_other": "", + "registerFailed": "无法注册节点", "remote": "远程", "removeButton": "移除", "removed": "节点已删除", @@ -3674,18 +3712,6 @@ "sectionSettingsSync": "设置同步", "sectionSyncHistory": "同步历史", "startButton": "启动", - "status": { - "connecting": "连接中", - "creating": "创建中", - "deleting": "删除中", - "error": "错误", - "exited": "已退出", - "offline": "离线", - "online": "在线", - "recreating": "重新创建中", - "running": "运行中", - "stopped": "已停止" - }, "statusConnecting": "连接中", "statusError": "错误", "statusOffline": "离线", @@ -3699,10 +3725,10 @@ "syncAuthFailed": "认证同步失败", "syncAuthSuccess": "认证凭证同步成功", "syncDifferences": "差异:", - "syncLastSync": "上次同步:", - "syncNeverSynced": "从未同步", "synced": "已同步", "syncing": "同步中…", + "syncLastSync": "上次同步:", + "syncNeverSynced": "从未同步", "total": "总计", "type": { "local": "本地", @@ -3720,7 +3746,21 @@ "portRange": "端口必须在1到65535之间" }, "viewLogsButton": "查看日志", - "yes": "是" + "yes": "是", + "discoveredCount_one": "", + "registeredCount_one": "" + }, + "nodeStatus": { + "local": "本地" + }, + "nodeSync": { + "error": { + "authSyncFailed": "认证同步失败", + "failedToFetchStatus": "获取同步状态失败", + "pullFailed": "拉取设置失败", + "pushFailed": "推送设置失败", + "someRequestsFailed": "部分同步状态请求失败" + } }, "onboarding": { "authToken": "认证令牌(可选)", @@ -3736,8 +3776,8 @@ "remoteServer": "远程服务器", "resumeOnboarding": "继续入职", "saving": "保存中…", - "scanQr": "扫描二维码", "scanning": "扫描中…", + "scanQr": "扫描二维码", "serverUrl": "服务器 URL", "serverUrlPlaceholder": "https://your-fusion-host", "stepContinue": "步。继续您的工作以完成仪表板设置。", @@ -3797,10 +3837,10 @@ "companyHelp": "选择 Paperclip 公司。", "companyIdRequired": "需要公司 ID 才能铸造 Paperclip API 密钥。", "companyLabel": "公司", - "connectToPopulate": "连接以填充", "connected": "已连接。", "connectedAsAgent": "已连接为 {{agentName}}{{companyInfo}}。", "connectionModeAriaLabel": "Paperclip 连接模式", + "connectToPopulate": "连接以填充", "description": "在 Paperclip 公司中驱动一个 Paperclip 代理(员工)。每个提示都会发出一个任务形状的请求;Paperclip 强制执行治理、预算和批准。每轮预计延迟为秒到分钟。", "docsLink": "Paperclip 文档", "githubLink": "GitHub", @@ -3808,16 +3848,6 @@ "goalIdLabel": "目标 ID(可选)", "mintButton": "通过 paperclipai 铸造 API 密钥", "mintFailed": "铸造失败:{{reason}}。如果 CLI 未经过身份验证,请先运行 `paperclipai onboard`。", - "mode": { - "issue-per-prompt": "每个提示一个问题", - "rolling-issue": "滚动问题(默认)", - "wakeup-only": "仅唤醒(高级)" - }, - "modeHelp": { - "issue-per-prompt": "每个提示都会创建一个新的顶级 Paperclip 任务。最大程度明确;容易让看板变得杂乱。", - "rolling-issue": "每个 Fusion 会话对应一个 Paperclip 任务;后续提示作为评论添加。最接近聊天体验。", - "wakeup-only": "无任务副作用;提示仅通过唤醒载荷传递。需要代理的提示模板知道如何处理载荷驱动的唤醒。" - }, "modeLabel": "对话模式", "name": "Paperclip", "noAgentsDiscovered": "未发现代理", @@ -3860,13 +3890,13 @@ "filterSkills": "技能:", "filterThemes": "主题:", "installFailed": "无法安装包:{{error}}", - "installSuccess": "包安装成功", "installing": "安装中…", + "installSuccess": "包安装成功", "loadExtensionsFailed": "无法加载扩展:{{error}}", - "loadSettingsFailed": "无法加载 Pi 设置:{{error}}", "loading": "加载 Pi 设置中…", "loadingExtensions": "加载扩展中…", "loadingFailed": "无法加载 Pi 设置。", + "loadSettingsFailed": "无法加载 Pi 设置:{{error}}", "noExtensions": "未发现扩展。", "noPackages": "未配置任何包。", "noPackagesHelp": "在上方添加包源以开始。", @@ -3876,8 +3906,8 @@ "refreshExtensions": "刷新扩展", "reinstallButton": "重新安装 Fusion 技能", "reinstallFailed": "无法重新安装 Fusion 技能:{{error}}", - "reinstallSuccess": "Fusion 技能重新安装成功", "reinstalling": "重新安装 Fusion…", + "reinstallSuccess": "Fusion 技能重新安装成功", "removeFailed": "无法移除包:{{error}}", "removePackage": "移除包", "removePackageLabel": "移除包 {{label}}", @@ -3893,9 +3923,9 @@ "updateSettingsFailed": "无法更新设置:{{error}}" }, "planning": { - "addSubtask": "添加子任务", "additionalComments": "附加备注(可选)", "additionalCommentsPlaceholder": "添加任何额外的背景信息或方向…", + "addSubtask": "添加子任务", "advancedSettings": "高级规划设置", "aiThinking": "AI 正在思考…", "archiveSession": "归档会话", @@ -3910,10 +3940,10 @@ "branchNameRequired": "此分支策略需要提供分支名称。", "branchProjectDefault": "使用项目/默认分支", "branchStrategy": "分支策略", - "breakIntoTasks": "分解为任务", - "breakIntoTasksTitle": "将计划分解为多个有依赖关系的任务", "breakdownSubheading": "审查并编辑从您的计划生成的子任务。在创建之前调整标题、描述、规模、优先级和依赖关系。", "breakingDown": "分解中…", + "breakIntoTasks": "分解为任务", + "breakIntoTasksTitle": "将计划分解为多个有依赖关系的任务", "collapse": "收起", "continue": "继续", "createSingleTask": "创建单个任务", @@ -3977,11 +4007,11 @@ "questionsLabel": "问题数量", "reconnecting": "重新连接中…", "refineFurther": "进一步完善", - "relativeTimeDays": "{{count}}天前", - "relativeTimeHours": "{{count}}小时前", + "relativeTimeDays_other": "", + "relativeTimeHours_other": "", "relativeTimeJustNow": "刚刚", - "relativeTimeMinutes": "{{count}}分钟前", - "relativeTimeWeeks": "{{count}}周前", + "relativeTimeMinutes_other": "", + "relativeTimeWeeks_other": "", "remove": "移除", "retryFailed": "重试失败,请再试一次。", "retrying": "重试中…", @@ -4020,34 +4050,21 @@ "untitledSession": "无标题会话", "usingDefault": "使用默认", "whatToBuild": "您想构建什么?", - "whatToBuildPlaceholder": "例如,构建一个包含登录、注册和密码重置的用户认证系统..." + "whatToBuildPlaceholder": "例如,构建一个包含登录、注册和密码重置的用户认证系统...", + "relativeTimeDays_one": "", + "relativeTimeHours_one": "", + "relativeTimeMinutes_one": "", + "relativeTimeWeeks_one": "" }, "plugins": { "addItem": "添加项目", - "agentBrowser": { - "groupBrowser": "浏览器", - "groupGeneral": "常规", - "groupPromptContributions": "提示贡献", - "groupSkills": "技能", - "labelAllowedDomains": "允许的域名", - "labelCommandTimeoutMs": "命令超时 (毫秒)", - "labelEnabled": "启用代理浏览器", - "labelHeadlessMode": "无头模式", - "labelInstallChannel": "安装渠道", - "labelPromptExecutorSystem": "执行器系统提示", - "labelPromptExecutorTask": "执行器任务提示", - "labelPromptHeartbeat": "心跳提示", - "labelPromptReviewer": "审查员提示", - "labelPromptTriage": "分类提示", - "labelSkillExposure": "技能暴露" - }, "aiScanDisabled": "已禁用加载时 AI 扫描", "aiScanEnabled": "已启用加载时 AI 扫描", "aiScanHint": "开启此选项只会更新配置。使用「重新扫描并重新加载」立即运行。", "author": "作者:", "backToList": "返回插件列表", - "builtinInstallFailed": "安装 {{name}} 失败:{{error}}", "builtinInstalledGlobally": "{{name}} 已全局安装", + "builtinInstallFailed": "安装 {{name}} 失败:{{error}}", "builtinMetadataOnly": "仅内置元数据", "builtinNoPackage": "{{name}} 是内置功能,目前没有可安装的软件包", "builtinPluginRecommendations": "内置插件推荐", @@ -4057,34 +4074,34 @@ "checkingSetup": "正在检查设置…", "componentUnavailable": "插件组件不可用", "couldNotResolve": "仪表板无法从静态主机注册表解析此插件表面。", + "disabledForProject": "{{name}} 已为此项目禁用", "disableInProject": "在项目中禁用", "disablePlugin": "禁用 {{name}}", "disablePluginFailed": "禁用插件失败:{{error}}", - "disabledForProject": "{{name}} 已为此项目禁用", "droidOnboardingTip": "提示:启用 Droid CLI 可重复使用 Factory AI 订阅,无需添加 API 密钥。", "droidRecommendDesc": "在 Fusion 中使用本地 Droid CLI 会话作为 AI 提供程序。", "droidRecommendTitle": "启用 Droid CLI", "enableAiScanBeforeLoad": "在加载/重新加载前启用 AI 扫描", "enableAiSecurityScan": "在加载时启用 AI 安全扫描", + "enabledForProject": "{{name}} 已为此项目启用", "enableFailed": "启用 {{name}} 失败:{{error}}", "enableInProject": "在项目中启用", "enablePlugin": "启用 {{name}}", "enablePluginFailed": "启用插件失败:{{error}}", - "enabledForProject": "{{name}} 已为此项目启用", "experimental": "实验性", - "findings": "发现 ({{count}})", + "findings_other": "", "homepage": "主页:", "install": "安装", + "installedGlobally": "插件已全局安装", + "installedPlugins": "已安装插件", "installFailed": "安装插件失败:{{error}}", "installHint": "浏览到插件包根目录(包含 manifest.json)或构建好的 dist 目录。", + "installing": "安装中…", "installNamed": "安装 {{name}}", "installPathPlaceholder": "插件目录或 dist 文件夹的绝对路径", "installPathRequired": "请输入插件路径", "installPluginGlobally": "全局安装插件", "installSetup": "安装设置", - "installedGlobally": "插件已全局安装", - "installedPlugins": "已安装插件", - "installing": "安装中…", "loadFailed": "加载插件失败:{{error}}", "loading": "加载中…", "loadingPlugins": "加载插件中…", @@ -4098,8 +4115,8 @@ "refresh": "刷新", "refreshPluginList": "刷新插件列表", "reload": "重新加载", - "reloadFailed": "重新加载插件失败:{{error}}", "reloaded": "{{name}} 已重新加载", + "reloadFailed": "重新加载插件失败:{{error}}", "reloading": "重新加载中…", "removeItem": "删除项目", "rescanAndReload": "重新扫描并重新加载", @@ -4109,11 +4126,11 @@ "saveSettingsFailed": "保存设置失败:{{error}}", "securityScan": "安全扫描", "selectOption": "请选择…", - "settingUp": "设置中…", "settings": "设置", "settingsSaved": "设置已保存", - "setupInstallFailed": "安装 {{name}} 设置失败:{{error}}", + "settingUp": "设置中…", "setupInstalled": "{{name}} 设置已安装", + "setupInstallFailed": "安装 {{name}} 设置失败:{{error}}", "setupReady": "设置就绪", "setupRequired": "需要设置", "startPluginToCheckSetup": "启动插件以检查设置", @@ -4121,14 +4138,15 @@ "statusInstalled": "已安装", "statusNotInstalled": "未安装", "uninstallConfirm": "您确定要全局卸载\"{{name}}\"(所有项目)吗?", + "uninstalledGlobally": "{{name}} 已全局卸载", "uninstallFailed": "卸载插件失败:{{error}}", "uninstallGlobally": "全局卸载", "uninstallGloballyTitle": "全局卸载", "uninstallTitle": "全局卸载插件", - "uninstalledGlobally": "{{name}} 已全局卸载", "unknownError": "未知错误", "updateFailed": "更新插件失败:{{error}}", - "version": "版本:" + "version": "版本:", + "findings_one": "" }, "pr": { "authFail": "运行 gh auth login 并重试。", @@ -4151,7 +4169,6 @@ "createPr": "创建 PR", "createTitle": "创建拉取请求", "dismissError": "关闭 PR 错误", - "loadingMetadata": "正在加载 PR 元数据…", "noConflicts": "未检测到合并冲突。", "preflightChecks": "飞行前检查", "previewTitle": "差异和提交预览", @@ -4176,22 +4193,26 @@ "confirm": "确认", "confirmRemove": "确认删除", "confirmRemoveProject": "确认删除项目", - "daysAgo": "{{count}} 天前", - "hoursAgo": "{{count}} 小时前", + "daysAgo_other": "", + "hoursAgo_other": "", "justNow": "刚刚", "lastActivity": "最后活动:", - "minutesAgo": "{{count}} 分钟前", - "moreItems": "+{{count}} 个更多", + "minutesAgo_other": "", + "moreItems_other": "", "never": "从未", - "noHealthData": "没有可用的健康数据", "nodeAvailability": "项目节点可用性", + "noHealthData": "没有可用的健康数据", "open": "打开", "openProject": "打开项目", "pause": "暂停", "pauseProject": "暂停项目", "removeProject": "删除项目", "resume": "恢复", - "resumeProject": "恢复项目" + "resumeProject": "恢复项目", + "daysAgo_one": "", + "hoursAgo_one": "", + "minutesAgo_one": "", + "moreItems_one": "" }, "projectDetection": { "editName": "编辑名称", @@ -4199,20 +4220,13 @@ "emptyHint": "尝试不同的基本路径或手动添加项目", "noDbWarning": "未找到 fn 数据库 - 将初始化", "registerAll": "全部注册", - "registerSelected": "注册选中的项目 ({{count}})", "registering": "注册中...", - "selectAll": "全选 ({{count}})", - "selectedCount": "已选择 {{count}} 个" - }, - "projectSelector": { - "allProjects": "所有项目", - "ariaLabel": "选择项目", - "clearSearch": "清除搜索", - "noResults": "未找到匹配的项目", - "recent": "最近", - "searchPlaceholder": "搜索项目...", - "selectProject": "选择项目", - "viewAll": "查看所有项目" + "registerSelected_other": "", + "selectAll_other": "", + "selectedCount_other": "", + "registerSelected_one": "", + "selectAll_one": "", + "selectedCount_one": "" }, "projects": { "actions": { @@ -4237,9 +4251,9 @@ "filterByNode": "按节点筛选", "filterErrored": "错误", "filterPaused": "暂停", + "nodesLabel": "节点", "noMatch": "没有项目与当前筛选器匹配", "noProjectsFound": "未找到项目", - "nodesLabel": "节点", "setup": { "success": "项目 {{name}} 注册成功" }, @@ -4254,15 +4268,26 @@ "title": "项目", "totalLabel": "总计" }, + "projectSelector": { + "allProjects": "所有项目", + "ariaLabel": "选择项目", + "clearSearch": "清除搜索", + "noResults": "未找到匹配的项目", + "recent": "最近", + "searchPlaceholder": "搜索项目...", + "selectProject": "选择项目", + "viewAll": "查看所有项目" + }, "providers": { "actions": { "addModel": "+ 添加模型", + "detecting": "检测中…", "detectModels": "检测模型", "detectModelsTitle": "调用提供者的/models端点来发现可用模型", - "detecting": "检测中…", - "removeModel": "删除模型", + "removeModel_other": "", "save": "保存提供者", - "saving": "保存中..." + "saving": "保存中...", + "removeModel_one": "" }, "addCustom": "添加自定义提供程序", "apiKeyLabel": "API 密钥", @@ -4280,9 +4305,9 @@ "noModels": "未找到模型。提供者可能需要API密钥。", "urlRequired": "需要基础URL来检测模型。" }, + "detecting": "正在检测…", "detectModels": "检测模型", "detectTitle": "从提供程序的 /models 端点自动检测模型", - "detecting": "正在检测…", "editLabel": "编辑 {{name}}", "failedDelete": "删除提供程序失败。", "failedDetect": "检测模型失败", @@ -4297,6 +4322,7 @@ "maxTokens": "最大令牌数", "modelId": "模型ID", "modelName": "显示名称", + "modelNameLabel": "", "models": "模型", "name": "显示名称", "reasoning": "推理" @@ -4360,8 +4386,10 @@ "p95": "P95", "p95Raw": "P95 原始: {{value}} ms", "reason": "原因: {{reason}}", - "sampleCount": "样本数: {{count}}", - "samples": "样本: {{count}}" + "sampleCount_other": "", + "samples_other": "", + "sampleCount_one": "", + "samples_one": "" }, "failureRate": "失败率: {{rate}}", "heading": "可靠性", @@ -4370,12 +4398,14 @@ "insufficientData": "数据不足 — {{reason}}", "mergeAttempts": { "heading": "合并尝试", - "histogramTotal": "直方图总计: {{count}}", + "histogramTotal_other": "", "max": "最大值", "mean": "平均值", "moreStats": "更多统计", "reason": "原因: {{reason}}", - "tasksCounted": "计数的任务: {{count}}" + "tasksCounted_other": "", + "histogramTotal_one": "", + "tasksCounted_one": "" }, "reason": "原因: {{reason}}", "resetBaseline": "重置基线: {{date}}", @@ -4413,11 +4443,11 @@ "enrichTaskButton": "充实任务", "enrichTaskTitle": "充实现有任务", "enterTaskId": "输入任务 ID", + "exportedFile": "已导出 {{filename}}", "exportFailed": "导出失败", "exportHtml": "导出 HTML", "exportJson": "导出 JSON", "exportMd": "导出 MD", - "exportedFile": "已导出 {{filename}}", "findingLabel": "发现:", "loadingRuns": "加载研究运行中…", "loadingTasks": "加载任务…", @@ -4434,11 +4464,6 @@ "priorityLow": "低", "priorityNormal": "正常", "priorityUrgent": "紧急", - "providerGitHub": "GitHub", - "providerLlmSynthesis": "LLM 合成", - "providerLocalDocs": "本地文档", - "providerPageFetch": "页面抓取", - "providerWebSearch": "网络搜索", "providersLabel": "提供程序", "queryLabel": "查询", "runCancelled": "运行已取消", @@ -4461,7 +4486,7 @@ "viewLabel": "研究视图" }, "routine": { - "andMore": "…以及另外 {{count}} 个", + "andMore_other": "", "delete": "删除", "deleteMessage": "删除例程 {{name}}?此操作无法撤销。", "deleteName": "删除 {{name}}", @@ -4474,11 +4499,14 @@ "enableName": "启用 {{name}}", "resultFailed": "失败", "resultSuccess": "成功", - "runHistory": "运行历史记录 ({{count}})", + "runHistory_other": "", "runNameNow": "立即运行 {{name}}", - "runNow": "立即运行", "running": "运行中…", - "stepCount": "{{count}} 个步骤" + "runNow": "立即运行", + "stepCount_other": "", + "andMore_one": "", + "runHistory_one": "", + "stepCount_one": "" }, "routing": { "cannotChangeWhileActive": "任务处于活动状态时无法更改节点覆盖。", @@ -4494,11 +4522,6 @@ "overrideSection": "节点覆盖", "overrideSetTo": "覆盖设置为", "overrideUpdated": "节点覆盖已更新", - "policyLabel": { - "block": "阻止执行", - "fallback": "回退到本地", - "notConfigured": "未配置" - }, "selectLabel": "选择执行节点", "source": { "noRouting": "无路由", @@ -4532,11 +4555,11 @@ "advancedMode": "多步骤", "advancedModeHelp": "按顺序运行多个步骤(命令和 AI 提示)", "aiPromptType": "AI 提示词", - "andMore": "…以及另外 {{count}} 个", + "andMore_other": "", "apiEndpointHint": "触发此例程的 API 端点路径", "apiEndpointLabel": "API 端点", "apiEndpointPlaceholder": "/api/routine/my-routine", - "automationCount": "{{count}} 个自动化{{plural}}", + "automationCount_other": "", "cancelButton": "取消", "catchUpPolicyHint": "当计划运行被错过时的处理方式", "catchUpPolicyLabel": "补偿策略", @@ -4591,10 +4614,10 @@ "editTitle": "编辑日程", "emptyStateDescription": "使用计划、webhook、API 或手动触发器创建自动化。", "enable": "启用", - "enableName": "启用 {{name}}", "enabledHelp": "禁用时,日程将不会自动运行", "enabledHint": "禁用后,例程将不会自动运行", "enabledLabel": "已启用", + "enableName": "启用 {{name}}", "errorApiEndpointRequired": "API 端点为必填项", "errorCommandRequired": "命令为必填项", "errorCronInvalid": "无效的 cron 格式——需要 5 个字段(例如 '0 */6 * * *')", @@ -4607,9 +4630,9 @@ "errorStepCommandRequired": "步骤 {{n}}:命令为必填项", "errorStepNameRequired": "步骤 {{n}}:名称为必填项", "errorStepPromptRequired": "步骤 {{n}}:提示为必填项", - "errorStepTaskDescRequired": "步骤 {{n}}:任务描述为必填项", "errorStepsEditing": "请在保存例程之前保存或取消所有步骤的编辑", "errorStepsRequired": "至少需要一个步骤", + "errorStepTaskDescRequired": "步骤 {{n}}:任务描述为必填项", "errorTaskDescriptionRequired": "任务描述为必填项", "errorTimeoutMin": "超时必须至少为 1 秒(1000ms)", "errorWebhookPathRequired": "Webhook 路径为必填项", @@ -4626,13 +4649,13 @@ "frequencyLabel": "频率", "global": "全局", "globalScope": "全局(用户级)自动化", - "globalScopeTitle": "全球范围", "globalScoped": "此日程将在全球范围内创建。", + "globalScopeTitle": "全球范围", "loadRoutinesError": "加载例程失败", "manualTriggerInfo": "此例程将通过控制台或 API 手动触发。", "modeAriaLabel": "执行模式", - "modeLabel": "执行模式", "model": "模型", + "modeLabel": "执行模式", "modelConsistency": "模型提供者和模型 ID 必须都设置,或都必须为空", "modelDropdownLabel": "模型", "modelHelp": "此步骤的 AI 模型。如果未选择,则使用默认值。", @@ -4656,9 +4679,9 @@ "project": "项目", "projectRequired": "特定于项目的条目需要活跃的项目。", "projectScope": "项目范围的自动化", + "projectScoped": "此日程将限定于当前项目。", "projectScopeDisabled": "选择一个项目以启用项目范围", "projectScopeTitle": "项目范围", - "projectScoped": "此日程将限定于当前项目。", "prompt": "提示词", "promptHelp": "要执行的 AI 提示。为任务提供清晰的说明。", "promptHint": "要执行的 AI 提示。", @@ -4675,10 +4698,10 @@ "routineSuccess": "{{name}} 已成功完成", "routineUpdated": "例程已更新", "runError": "运行例程失败", - "runHistory": "运行历史记录 ({{count}})", + "runHistory_other": "", "runNameNow": "立即运行 {{name}}", - "runNow": "立即运行", "running": "运行中…", + "runNow": "立即运行", "saveChanges": "保存更改", "saveStep": "保存步骤", "saving": "保存中…", @@ -4695,15 +4718,15 @@ "simpleMode": "简单", "simpleModeHelp": "运行单个 shell 命令或 AI 提示", "stepCommandRequired": "步骤 {{index}}:命令为必填项", - "stepCount": "{{count}} 个步骤", + "stepCount_other": "", "stepName": "步骤名称", "stepNamePlaceholder": "例如:运行测试", "stepNameRequired": "需要步骤名称", "stepPromptRequired": "步骤 {{index}}:提示为必填项", - "stepType": "步骤类型", "steps": "步骤", "stepsEditing": "在保存日程之前,请保存或取消所有步骤编辑", "stepsRequired": "至少需要一步", + "stepType": "步骤类型", "targetColumn": "目标列", "targetColumnHelp": "将创建新任务的列", "targetColumnLabel": "目标列", @@ -4755,7 +4778,11 @@ "webhookPathPlaceholder": "/trigger/my-routine", "webhookSecretHint": "用于签名验证的 HMAC 密钥。无需身份验证的 webhook 请留空。", "webhookSecretLabel": "Webhook 密钥(可选)", - "webhookSecretPlaceholder": "可选——无需身份验证的 webhook 请留空" + "webhookSecretPlaceholder": "可选——无需身份验证的 webhook 请留空", + "andMore_one": "", + "automationCount_one": "", + "runHistory_one": "", + "stepCount_one": "" }, "scriptsModal": { "addScript": "添加脚本", @@ -4780,14 +4807,15 @@ "saving": "正在保存...", "scriptAlreadyExists": "已经存在同名脚本", "scriptCommandRequired": "需要脚本命令", - "scriptCount": "{{count}} 个脚本", + "scriptCount_other": "", "scriptCreated": "脚本已创建", "scriptDeleted": "脚本已删除", "scriptName": "脚本名称", "scriptNamePlaceholder": "例如,build、test、lint", "scriptNameRequired": "需要脚本名称", "scriptUpdated": "脚本已更新", - "title": "脚本" + "title": "脚本", + "scriptCount_one": "" }, "secrets": { "accessPolicyAuto": "自动", @@ -4852,20 +4880,17 @@ "failed": "失败", "headerAwaitingAndErrorPlural": "{{awaitingCount}} 个 AI 会话需要您的输入,{{errorCount}} 个失败", "headerAwaitingAndErrorSingular": "{{awaitingCount}} 个 AI 会话需要您的输入,{{errorCount}} 个失败", - "headerAwaitingPlural": "{{count}} 个 AI 会话需要您的输入", - "headerAwaitingSingular": "{{count}} 个 AI 会话需要您的输入", - "headerErrorPlural": "{{count}} 个 AI 会话失败", - "headerErrorSingular": "{{count}} 个 AI 会话失败", + "headerAwaitingPlural_other": "", + "headerAwaitingSingular_other": "", + "headerErrorPlural_other": "", + "headerErrorSingular_other": "", "regionLabel": "需要输入或已失败的 AI 会话", "resume": "继续", "retry": "重试", - "typeLabel": { - "milestoneInterview": "里程碑访谈", - "missionInterview": "任务访谈", - "planning": "规划", - "sliceInterview": "切片访谈", - "subtask": "子任务分解" - } + "headerAwaitingPlural_one": "", + "headerAwaitingSingular_one": "", + "headerErrorPlural_one": "", + "headerErrorSingular_one": "" }, "settings": { "actions": { @@ -4876,7 +4901,6 @@ "language": "语言", "languageAuto": "自动", "languageAutoHint": "跟随浏览器语言", - "languageHint": "选择 {{brand}} 界面的语言。", "title": "外观" }, "auth": { @@ -4930,8 +4954,8 @@ "general": { "learnMore": "了解更多", "settingsSaved": "设置已保存", - "upToDate": "您已是最新版本 ✓", - "updateAvailablePrefix": "v{{version}} 可用" + "updateAvailablePrefix": "v{{version}} 可用", + "upToDate": "您已是最新版本 ✓" }, "header": { "discord": "Discord" @@ -4941,8 +4965,8 @@ "exportBtn": "导出", "exportTitle": "将设置导出为 JSON 文件", "importBtn": "导入", - "importTitle": "导入设置", "importing": "导入中…", + "importTitle": "导入设置", "loadingFile": "加载中…", "reviewPrompt": "查看要导入的设置:" }, @@ -4951,17 +4975,17 @@ "keepRemote": "保留远程", "loading": "加载中…", "memory": { - "compactSelectedFile": "压缩选定文件", "compacting": "压缩中…", + "compactSelectedFile": "压缩选定文件", "dreamCompleted": "梦境处理完成", "dreamNow": "立即触发梦境", - "installQmd": "安装 qmd", "installing": "安装中…", + "installQmd": "安装 qmd", "memoryCompacted": "记忆文件已压缩", "memorySaved": "记忆已保存", "saveMemory": "保存记忆", - "testRetrieval": "测试检索", - "testing": "测试中…" + "testing": "测试中…", + "testRetrieval": "测试检索" }, "mergeManually": "手动合并", "mobileNav": { @@ -4976,45 +5000,14 @@ "savePreset": "保存预设" }, "nav": { - "accountHeader": "账户", - "agentPermissions": "代理权限", - "appearance": "外观", "aria": { "global": "全局设置", "project": "项目设置" }, - "authentication": "认证", - "backups": "备份", - "commands": "命令", - "experimental": "实验性功能", - "globalGeneral": "常规", - "globalHeader": "全局", - "globalModels": "模型", - "hermesRuntime": "Hermes", - "memory": "记忆", - "merge": "合并", - "nodeRouting": "节点路由", - "nodeSync": "节点同步", - "notifications": "通知", - "openclawRuntime": "OpenClaw", - "paperclipRuntime": "Paperclip", - "plugins": "插件", - "projectGeneral": "项目常规", - "projectHeader": "项目", - "projectModels": "项目模型", - "prompts": "提示词", - "remote": "远程访问", - "researchGlobal": "研究默认值", - "researchProject": "研究", - "runtimesHeader": "运行时", - "scheduledEvals": "定时评估", - "scheduling": "调度", - "secrets": "密钥", "tooltip": { "global": "在所有项目中共享", "project": "特定于此项目" - }, - "worktrees": "工作树" + } }, "notifications": { "sending": "发送中…", @@ -5028,10 +5021,10 @@ "restarting": "重启中…", "shortLivedTokenGenerated": "短期令牌已生成", "startFresh": "重新启动", - "startTunnel": "启动隧道", "starting": "启动中…", - "stopTunnel": "停止隧道", + "startTunnel": "启动隧道", "stopping": "停止中…", + "stopTunnel": "停止隧道", "tunnelRestarted": "远程隧道已重启", "tunnelStarted": "远程隧道已启动", "tunnelStopped": "远程隧道已停止", @@ -5039,8 +5032,8 @@ }, "resolveAllLocal": "全部解决:保留本地", "resolveAllRemote": "全部解决:保留远程", - "resolveFailed": "无法解决冲突", "resolvedSuccess": "设置冲突已成功解决", + "resolveFailed": "无法解决冲突", "resolving": "解决中...", "scheduling": { "selectCurrentDir": "选择当前目录", @@ -5068,46 +5061,11 @@ "aiSetupDescription": "Fusion 使用 AI 模型为您规划、编写和审查代码。在下方连接 AI 提供商以开始使用,您可以使用托管服务或输入 API 密钥。", "allProvidersShown": "所有当前可用的提供商已显示在上方。", "allSet": "全部准备就绪!", - "apiKeyFormatError": "{{providerName}} 密钥应遵循此格式:{{hint}}(例如 {{example}})", "apiKeyFormatHint": "格式:{{hint}}", "apiKeyHint": "密钥:{{keyHint}}", - "apiKeyLabel": { - "fallback": "API 密钥", - "kimiCoding": "Kimi API 密钥", - "minimax": "MiniMax API 密钥", - "ollama": "Ollama 端点", - "openai": "OpenAI API 密钥", - "openrouter": "OpenRouter API 密钥", - "zai": "智谱 AI API 密钥" - }, - "apiKeyPlaceholder": { - "fallback": "输入 API 密钥", - "kimiCoding": "输入您的 Kimi API 密钥", - "minimax": "输入您的 MiniMax API 密钥", - "zai": "输入您的智谱 AI API 密钥" - }, "apiKeyRemoved": "API 密钥已删除", - "apiKeyRequired": "API 密钥是必填项", "apiKeySaved": "✓ API 密钥已保存", "apiKeySavedToast": "API 密钥已保存", - "apiKeySetup": { - "fallback": "输入此提供商的 API 密钥。", - "kimiCoding": "在 Moonshot 平台账户设置中创建 API 密钥。", - "minimax": "从 MiniMax 平台开发者控制台生成 API 密钥。", - "ollama": "输入您的 Ollama 端点 URL(例如 http://localhost:11434)。", - "openai": "在 OpenAI 控制台的 API 密钥页面创建 API 密钥。", - "openrouter": "从 OpenRouter 账户密钥管理页面创建 API 密钥。", - "zai": "在智谱 AI 开放平台账户设置中创建 API 密钥。" - }, - "apiKeyUsage": { - "fallback": "Fusion 用于向此提供商验证请求", - "kimiCoding": "用于任务执行和规划中的 Kimi/Moonshot AI 模型", - "minimax": "用于任务执行中的 MiniMax 模型", - "ollama": "连接到您的本地 Ollama 实例", - "openai": "用于任务执行和规划中的 GPT 模型", - "openrouter": "通过单一密钥路由到多个 AI 模型提供商", - "zai": "用于任务执行中的 GLM 模型" - }, "ariaDismissRecommendations": "关闭建议", "ariaSetupRecommendations": "设置建议", "authCodeAlreadySubmitted": "该授权码已提交,等待登录完成…", @@ -5158,13 +5116,13 @@ "connectAiProvider": "连接 AI 提供商", "connectAiProviderDesc": "连接 AI 提供商以启用 AI 代理进行任务规划和代码生成", "connectAnyway": "仍然连接", + "connectedProviders": "已连接的提供商", "connectGitHub": "连接 GitHub", "connectGitHubAnytime": "如果您还没准备好也没关系——随时可以从设置 → 身份验证连接 GitHub。", "connectGitHubButton": "连接 GitHub", "connectGitHubDesc": "连接 GitHub 以导入问题并跟踪拉取请求", "connectOauthOptional": "连接 OAuth(可选)", "connectRemoteServer": "连接远程 Fusion 服务器", - "connectedProviders": "已连接的提供商", "continueToLogin": "继续登录", "continueWithGhCli": "使用 gh CLI 身份验证继续 →", "continueWithoutGitHub": "不使用 GitHub 继续 →", @@ -5229,10 +5187,10 @@ "githubSkipped": "已跳过 GitHub。您随时可以从设置 → 身份验证进行连接。", "goBackToStep": "返回{{label}}", "goToDashboard": "前往仪表板", - "howDoIChooseModel": "如何选择模型?", - "howDoIChooseModelBody": "模型在速度、能力和成本上各有差异。通常选择所连接提供商的最新模型作为默认值是个好选择。您可以随时在设置中更改。", "howDoesLoginWork": "登录是如何工作的?", "howDoesLoginWorkBody": "点击登录将在新标签页中打开提供商网站进行登录。授权 Fusion 后,此页面将自动检测连接。您的凭证不会存储在 Fusion 中。", + "howDoIChooseModel": "如何选择模型?", + "howDoIChooseModelBody": "模型在速度、能力和成本上各有差异。通常选择所连接提供商的最新模型作为默认值是个好选择。您可以随时在设置中更改。", "importFromGitHub": "从 GitHub 导入", "importFromGitHubSubtitle": "将 GitHub 问题转换为可在此处跟踪的任务", "inProcess": "进程内", @@ -5294,25 +5252,9 @@ "projectRequired": "在可以使用第一个任务操作之前,需要先有一个项目。", "projectSelected": "已选择项目——任务创建和导入功能已可用。", "projectSetupDescription": "在创建或导入任务之前,请选择您的第一个项目。您可以注册现有的本地目录,或通过设置向导克隆 GitHub 仓库 URL。", - "providerDesc": { - "anthropic": "Claude 模型——擅长推理、分析和编程", - "fallback": "AI 提供商——连接以开始使用 AI 模型", - "gemini": "Gemini 模型——多模态,推理能力强", - "google": "Gemini 模型——多模态,推理能力强", - "kimi": "Moonshot AI 的 Kimi——长上下文能力", - "kimiCoding": "Moonshot AI 的 Kimi——长上下文能力", - "minimax": "MiniMax 模型——高量使用时具有成本优势", - "moonshot": "Moonshot AI 的 Kimi——长上下文能力", - "ollama": "在您的机器上本地运行开源模型", - "openai": "GPT 模型——适用于各种任务", - "openaiCodex": "OpenAI Codex 模型——专为编程任务优化", - "openrouter": "OpenRouter——跨多个 AI 提供商路由请求", - "zai": "智谱 AI 的 GLM 模型——强大的多语言支持" - }, "providersConnectedSummary": "✓ {{total}} 个提供商中已连接 {{connected}} 个", - "providersSkippedSummary": "已跳过 {{count}} 个提供商", - "providersSkippedSummary_one": "已跳过 {{count}} 个提供商", - "providersSkippedSummary_other": "已跳过 {{count}} 个提供商", + "providersSkippedSummary_one_other": "", + "providersSkippedSummary_other_other": "", "quickStartProviders": "快速启动提供商", "readinessAiProviderConnected": "{{name}} 已连接——AI 代理可以处理任务", "readinessAiProviderLabel": "AI 提供商", @@ -5331,8 +5273,8 @@ "readinessSummaryHeader": "设置摘要", "recommended": "推荐", "recommendedNextSteps": "推荐的后续步骤", - "registerProject": "注册项目", "registering": "正在注册...", + "registerProject": "注册项目", "remoteServerNote": "您的本地 Shell 需要一个活跃的远程配置文件,才能完成仪表板移交。", "remoteServerProfileSaved": "远程服务器配置文件已保存", "removeKey": "删除密钥", @@ -5346,9 +5288,9 @@ "retry": "重试", "reviewStep": "查看{{label}}", "runtimeNode": "运行时节点", + "savedProfileButFailedToActivate": "配置文件已保存但激活失败", "saveKey": "保存", "saveRemoteServer": "保存远程服务器", - "savedProfileButFailedToActivate": "配置文件已保存但激活失败", "saving": "正在保存…", "savingKey": "正在保存…", "savingRemoteServer": "正在保存…", @@ -5361,9 +5303,9 @@ "setToken": "设置令牌", "setTokenContinue": "设置令牌并继续", "setUpAi": "设置 AI", - "setUpProject": "设置项目", "setupComplete": "设置完成!", "setupMode": "设置模式", + "setUpProject": "设置项目", "setupWizardHint": "在设置向导中,选择现有目录或粘贴 GitHub 克隆 URL。", "skip": "跳过", "skipForNow": "暂时跳过", @@ -5418,7 +5360,9 @@ "withoutGitHub1": "手动创建任务", "withoutGitHub2": "为 AI 代理描述工作", "withoutGitHub3": "在看板上跟踪进度", - "withoutGitHubHeading": "不使用 GitHub(现在可用):" + "withoutGitHubHeading": "不使用 GitHub(现在可用):", + "providersSkippedSummary_one_one": "", + "providersSkippedSummary_other_one": "" }, "shell": { "activePill": "活跃", @@ -5449,21 +5393,21 @@ "catalogUnavailable": "目录暂时不可用。请稍后重试。", "closeDetail": "关闭技能详情", "closeView": "关闭技能视图", - "disableSkill": "禁用 {{name}}", "disabled": "技能已禁用", + "disableSkill": "禁用 {{name}}", "discovered": "已发现", - "discoveredCount": "{{count}} 个已发现的技能", + "discoveredCount_other": "", "discoveredSection": "已发现的技能", - "enableSkill": "启用 {{name}}", "enabled": "技能已启用", + "enableSkill": "启用 {{name}}", "filesLabel": "文件", "install": "安装", "installError": "安装技能失败", "installFailed": "安装 {{name}} 失败: {{message}}", - "installSkill": "安装 {{name}}", - "installSuccess": "已安装 {{name}}", "installing": "正在安装…", "installsCount": "{{count}} 次安装", + "installSkill": "安装 {{name}}", + "installSuccess": "已安装 {{name}}", "loadCatalogError": "加载目录失败", "loadContentError": "加载技能内容失败", "loadDiscoveredError": "加载已发现的技能失败", @@ -5483,7 +5427,8 @@ "title": "技能", "toggleError": "切换技能失败", "toggleFailed": "切换技能失败: {{message}}", - "viewDetails": "查看 {{name}} 的详情" + "viewDetails": "查看 {{name}} 的详情", + "discoveredCount_one": "" }, "specEditor": { "edit": "编辑", @@ -5491,8 +5436,8 @@ "feedbackPlaceholder": "例如,'添加有关错误处理的更多详情'、'将其分为更小的步骤'、'包括 API 端点的测试'...", "keyboardHint": "按 Ctrl+Enter(或 Cmd+Enter)保存", "placeholder": "以 Markdown 格式输入任务说明...", - "requestRevision": "请求 AI 修订", "requesting": "请求中…", + "requestRevision": "请求 AI 修订", "revisionHelp": "为 AI 提供反馈以改进此说明。该任务将移至规划阶段进行重新规划。", "revisionTitle": "要求 AI 修订", "saving": "保存中…", @@ -5514,15 +5459,17 @@ "dropTitle": "删除孤立的隐藏?", "failedToLoadDiff": "加载差异失败", "failedToLoadOrphans": "加载孤立记录失败", - "fileCount": "{{count}} 个文件", + "fileCount_other": "", "inspectDiff": "检查差异", "loadingDiff": "正在加载差异…", "noDiffOutput": "没有可用的差异输出。", "noOrphans": "未发现孤立的合并自动隐藏。", - "orphanCount": "{{count}} 个孤立", + "orphanCount_other": "", "shaLabel": "SHA", "title": "储存恢复", - "unknownSource": "未知来源" + "unknownSource": "未知来源", + "fileCount_one": "", + "orphanCount_one": "" }, "stepType": { "aiPrompt": "AI 提示", @@ -5592,7 +5539,7 @@ "untitled": "无标题" }, "syncLog": { - "entryCount": "{{count}} 个条目", + "entryCount_other": "", "filterAll": "全部", "filterAllNodes": "全部节点", "filterDirection": "方向:", @@ -5603,7 +5550,8 @@ "noHistory": "没有同步历史可用", "resultConflict": "冲突", "resultError": "错误", - "resultSuccess": "成功" + "resultSuccess": "成功", + "entryCount_one": "" }, "systemStats": { "agentActive": "活跃", @@ -5624,11 +5572,11 @@ "errorLoadVitestSettings": "加载 vitest 设置失败", "errorSaveVitestSettings": "保存 vitest 设置失败", "footerRefreshFailed": "最新刷新失败:{{error}}", + "killedProcesses_other": "", "killThresholdInputAriaLabel": "终止阈值 (%)", "killThresholdLabel": "终止阈值 (%)", "killThresholdSliderAriaLabel": "终止阈值滑块 (%)", "killVitest": "终止 Vitest 进程", - "killedProcesses": "已终止 {{count}} 个进程", "lastAutoKill": "上次自动终止:{{time}}", "loading": "加载系统统计信息…", "notYet": "尚未", @@ -5667,23 +5615,19 @@ "title": "系统统计", "updatedAt": "已更新 {{time}}", "vitestProcesses": "Vitest 进程", - "waitingFirstUpdate": "等待首次更新" + "waitingFirstUpdate": "等待首次更新", + "killedProcesses_one": "" }, "taskChanges": { "attributionFailed": "已落地文件集可能包含外来提交(归因不可用)。", "disableWordWrap": "禁用自动换行", - "emptyWorktreeHint": "实时工作树差异为空。显示执行期间捕获的最后文件路径—补丁不可用。", "enableWordWrap": "启用自动换行", "error": "加载更改出错: {{error}}", - "executionFilesHint": "这些是在执行期间从工作树捕获的文件。它们可能与实际登陆主分支的文件不同。此任务的血统支持的差异不可用。", "expandDiff": "扩展为全屏差异视图", "expandDiffView": "扩展差异视图", - "fileCount": "{{count}} 个文件{{plural}}已更改。", - "filesChangedHeading": "已更改文件 ({{count}})", - "landedFilesHint": "这些是从合并提交元数据中捕获的文件。此任务的血统支持的差异不可用。", + "filesChangedHeading_other": "", "loadError": "加载任务更改失败", "loading": "正在加载更改...", - "merged": "合并于 {{date}}", "mergedAt": "已合并 {{date}}", "nextFile": "下一个文件", "noExecutionModifications": "代理在执行期间没有修改任何文件。", @@ -5694,15 +5638,28 @@ "noWorktree": "此任务没有可用的工作树。", "noWorktreeHint": "任务开始后将显示更改。", "previousFile": "上一个文件", - "statusUnknown": "状态未知", "summaryHint": "最终提交摘要: {{files}} 个文件{{plural}}已更改, +{{additions}} 次添加, -{{deletions}} 次删除。仅计算记录的合并/压缩提交,不计算完整的任务血统。", "toggleWordWrap": "切换自动换行", - "unavailable": "详细文件更改不可用。" + "unavailable": "详细文件更改不可用。", + "filesChangedHeading_one": "" }, "taskDetail": { "actions": { "menuBtn": "操作" }, + "agent": { + "assignBtn": "分配代理", + "assignedUpdated": "已更新分配的代理", + "assignFailed": "分配代理失败:{{error}}", + "label": "代理", + "loadFailed": "加载代理失败:{{error}}", + "loadingAgents": "正在加载代理...", + "noAgents": "没有可用的代理", + "unassigned": "代理已取消分配", + "unassignFailed": "取消分配代理失败:{{error}}", + "unassignTitle": "取消分配代理" + }, + "agentLink": "代理 {{id}}", "ageStaleness": { "active": "活跃", "age": "年龄", @@ -5713,24 +5670,11 @@ "title": "任务年龄陈旧度", "warning": "警告" }, - "agent": { - "assignBtn": "分配代理", - "assignFailed": "分配代理失败:{{error}}", - "assignedUpdated": "已更新分配的代理", - "label": "代理", - "loadFailed": "加载代理失败:{{error}}", - "loadingAgents": "正在加载代理...", - "noAgents": "没有可用的代理", - "unassignFailed": "取消分配代理失败:{{error}}", - "unassignTitle": "取消分配代理", - "unassigned": "代理已取消分配" - }, - "agentLink": "代理 {{id}}", "attachments": { "attachBtn": "附加截图", "attached": "截图已附加", - "deleteTitle": "删除附件", "deleted": "附件已删除", + "deleteTitle": "删除附件", "heading": "附件", "none": "(无附件)", "uploading": "正在上传…" @@ -5749,9 +5693,11 @@ "reattachBtn": "重新绑定分支", "reattached": "已为 {{id}} 重新绑定分支 ({{branch}})", "reattachedResult": "已重新绑定 {{branch}}(领先 {{base}} {{count}} 次提交)。", + "reattachedResult_other": "", "reattaching": "正在重新绑定…", "skipped": "{{id}} 的分支重新绑定已跳过:{{reason}}", - "skippedResult": "重新绑定已跳过:{{reason}}" + "skippedResult": "重新绑定已跳过:{{reason}}", + "reattachedResult_one": "" }, "cacheBreakdown": "(读取 {{read}} / 写入 {{write}} / 输入 {{input}})", "cacheHitRatio": "缓存命中率:", @@ -5764,21 +5710,21 @@ "actionLeft": "保留了", "allowRecreation": "允许稍后重新创建(操作员解锁)", "allowRecreationDesc": "允许代理在没有 --force-resurrect 的情况下重新创建此任务 ID。取消选中以保持此任务的墓碑状态。", + "archivedAfterUnlink": "在取消世系引用关联后已归档 {{id}}", "archiveInstead": "改为归档", "archiveUnlinkPrompt": "先取消这些引用的关联后归档?", - "archivedAfterUnlink": "在取消世系引用关联后已归档 {{id}}", "ariaLabel": "删除任务", "btn": "删除", "closeIssue": "关闭 Issue", "confirm": "删除", + "deletedAfterRemovingDeps": "在删除依赖引用后已删除 {{id}}", + "deletedAfterUnlinkLineage": "在取消世系引用关联后已删除 {{id}}", + "deletedToast": "已删除 {{id}}{{suffix}}", "deleteIssue": "删除 Issue", "deleteLinkedIssueMessage": "在 GitHub 上删除 {{issueRef}},还是保持不变?", "deleteLinkedIssueTitle": "删除关联的 GitHub Issue", "deleteUnlinkDepsPrompt": "先删除这些依赖引用后再删除?", "deleteUnlinkLineagePrompt": "先取消这些引用的关联后再删除?", - "deletedAfterRemovingDeps": "在删除依赖引用后已删除 {{id}}", - "deletedAfterUnlinkLineage": "在取消世系引用关联后已删除 {{id}}", - "deletedToast": "已删除 {{id}}{{suffix}}", "forceDeleteTitle": "强制删除任务", "issueSuffix": "并{{action}} issue {{ref}}", "leaveUnchanged": "保持不变", @@ -5816,8 +5762,8 @@ "autosaveHint": "编辑时自动保存更改", "autosaving": "正在自动保存…", "nodeOverrideLocked": "任务活跃/进行中时,执行节点覆盖已锁定。", - "saveFailed": "保存失败", "saved": "已保存", + "saveFailed": "保存失败", "saving": "正在保存…", "sourceExternalIdPlaceholder": "Issue 标识符", "sourceIssueHint": "将所有字段留空以清除来源 issue 元数据。", @@ -5892,7 +5838,8 @@ "activityHeading": "活动", "agentLog": "代理日志", "noActivity": "(无活动)", - "truncated": "显示最近 {{count}} 条活动记录。" + "truncated_other": "", + "truncated_one": "" }, "longestTimingEvent": "最长计时事件", "longestWorkflowStep": "最长工作流步骤", @@ -5908,8 +5855,8 @@ "backToInProgress": "返回进行中", "cancelMove": "取消移动", "keepProgress": "保留进度", - "moveTo": "移至 {{column}}", "movedTo": "已移至 {{column}}", + "moveTo": "移至 {{column}}", "preserveProgressMessage": "此任务有已完成的步骤。移动前保留进度?", "preserveProgressTitle": "保留进度?", "resetProgress": "重置进度", @@ -5920,9 +5867,9 @@ "actions": "选择「归档」将此任务归档,或选择「保留」继续使用此任务。", "archiveBtn": "归档", "archiveConfirm": "归档", + "archived": "已归档 {{id}}", "archiveMessage": "将 {{id}} 作为 {{duplicateOf}} 的重复归档?", "archiveTitle": "归档近似重复任务", - "archived": "已归档 {{id}}", "copy": "此任务看起来是以下任务的近似重复:", "headline": "检测到潜在重复", "keepBtn": "保留", @@ -5941,8 +5888,8 @@ "noSteps": "无步骤", "noTimedEvents": "暂无已记录的计时事件。", "noTokenUsage": "此任务暂无 Token 使用记录。", - "noWorkflowStepTimings": "暂无已完成的工作流步骤计时。", "notSet": "未设置", + "noWorkflowStepTimings": "暂无已完成的工作流步骤计时。", "outputTokens": "输出", "pause": { "pauseBtn": "暂停", @@ -5959,9 +5906,9 @@ "rebuildMessage": "重建此任务的计划?任务将进入规划阶段重新规划。", "rebuildTitle": "重建计划", "rejectBtn": "拒绝计划", + "rejected": "计划已拒绝 — {{id}} 已返回规划阶段重新规划", "rejectMessage": "拒绝此计划?规范将被丢弃并重新生成。", "rejectTitle": "拒绝计划", - "rejected": "计划已拒绝 — {{id}} 已返回规划阶段重新规划", "replanning": "正在为 {{id}} 重新规划…" }, "pr": { @@ -5983,31 +5930,17 @@ "progress": { "heading": "进度", "noSteps": "(未定义步骤)", - "stepCount": "{{count}}/{{total}} 步骤" + "stepCount_other": "", + "stepCount_one": "" }, "provenance": { - "agent": "代理", - "api": "API", - "automation": "自动化", - "chatSession": "聊天会话", - "cli": "CLI", "createdBy": "创建者:", - "createdVia": "通过…创建", - "dashboard": "仪表板", - "duplicate": "重复", - "githubImport": "GitHub 导入", - "openIssue": "未解决问题", - "quickChat": "快速聊天", - "recovery": "恢复", - "refinement": "细化", - "research": "研究", - "scheduledTask": "计划任务", - "workflowStep": "工作流步骤" + "createdVia": "通过…创建" }, "recoveryState": "恢复状态", "refine": { "btn": "细化", - "charCount": "{{count}}/2000 字符", + "charCount_other": "", "createBtn": "创建细化任务", "creating": "正在创建...", "feedbackRequired": "请输入描述需要细化内容的反馈", @@ -6015,7 +5948,8 @@ "help": "描述需要细化或改进的内容...", "modalTitle": "细化", "placeholder": "在此输入您的反馈...", - "taskCreated": "细化任务已创建:{{id}}" + "taskCreated": "细化任务已创建:{{id}}", + "charCount_one": "" }, "reset": { "btn": "重置", @@ -6082,8 +6016,8 @@ "loading": "正在加载规范…", "noPrompt": "(无提示词)", "placeholder": "以 Markdown 格式输入任务规范...", - "requestRevisionBtn": "请求 AI 修订", "requesting": "正在请求…", + "requestRevisionBtn": "请求 AI 修订", "revisionColumnError": "无法请求修订:任务必须在 triage、todo、in-progress 或 in-review 列中。", "revisionRequested": "已请求 AI 修订。任务已移至规划阶段。", "saving": "正在保存…", @@ -6129,8 +6063,8 @@ "wallClockSinceFirst": "自首次执行的实际时间", "workflow": { "loadFailed": "加载工作流结果失败:{{error}}", - "stepsUpdateFailed": "更新工作流步骤失败:{{error}}", - "stepsUpdated": "工作流步骤已更新" + "stepsUpdated": "工作流步骤已更新", + "stepsUpdateFailed": "更新工作流步骤失败:{{error}}" }, "workflowRuntime": "工作流运行时间", "workflowTimedSteps": "工作流计时步骤", @@ -6181,8 +6115,8 @@ "taskForm": { "addDependencies": "添加依赖", "attachHint": "您也可以粘贴图片或拖放", - "attachScreenshot": "附加截图", "attachmentsLabel": "附件", + "attachScreenshot": "附加截图", "autoMergeDefault": "默认(遵循项目设置)", "autoMergeDisabled": "禁用", "autoMergeEnabled": "启用", @@ -6205,7 +6139,7 @@ "branchStrategyLabel": "分支策略", "collapseDescription": "收起描述", "dependenciesLabel": "依赖关系", - "dependenciesSelected": "已选 {{count}} 项", + "dependenciesSelected_other": "", "descriptionLabel": "描述", "descriptionPlaceholder": "需要完成什么?", "descriptionRefinedToast": "AI 已优化描述", @@ -6228,17 +6162,11 @@ "moveDown": "下移", "moveUp": "上移", "noAvailableTasks": "没有可用任务", - "noModelsAvailable": "没有可用模型。请在设置中配置身份验证。", "nodeDefaultOption": "使用项目默认 / 本地", "nodeOverrideHint": "任务覆盖优先于项目默认节点路由。", "nodeOverrideLabel": "执行节点覆盖", - "nodeStatusConnecting": "连接中", - "nodeStatusError": "错误", - "nodeStatusOffline": "离线", - "nodeStatusOnline": "在线", + "noModelsAvailable": "没有可用模型。请在设置中配置身份验证。", "overridePreset": "覆盖", - "phasePostMerge": "合并后", - "phasePreMerge": "合并前", "planButton": "规划", "planningLabel": "规划", "planningModelLabel": "规划模型", @@ -6246,10 +6174,6 @@ "presetLabel": "预设", "presetUseDefault": "使用默认", "priorityLabel": "优先级", - "priority_high": "高", - "priority_low": "低", - "priority_normal": "普通", - "priority_urgent": "紧急", "refineAddDetailsDesc": "添加实现细节和背景信息", "refineAddDetailsTitle": "添加细节", "refineButton": "优化", @@ -6264,13 +6188,13 @@ "removeImage": "删除图片", "removeStep": "删除", "reviewDefault": "默认(自动 — 由分类决定)", + "reviewerLabel": "审阅器", + "reviewerModelLabel": "审阅器模型", "reviewLabel": "审查", "reviewLevel0": "0 — 无", "reviewLevel1": "1 — 仅计划", "reviewLevel2": "2 — 计划和代码", "reviewLevel3": "3 — 完整", - "reviewerLabel": "审阅器", - "reviewerModelLabel": "审阅器模型", "searchTasksPlaceholder": "搜索任务…", "sharedBranchPlaceholder": "例如 clionboarding", "sharedFeatureBranchLabel": "共享功能分支", @@ -6288,7 +6212,8 @@ "usingPreset": "使用预设:{{name}}", "workflowStepsDescription": "选择任务实现完成后要运行的步骤", "workflowStepsLabel": "工作流步骤", - "workingBranchLabel": "工作分支" + "workingBranchLabel": "工作分支", + "dependenciesSelected_one": "" }, "taskHandlers": { "githubImported": "从 GitHub 导入了 {{id}}" @@ -6297,96 +6222,91 @@ "autoMergeOff": "自动合并关闭", "autoMergeOn": "自动合并开启", "autoMergePreferenceUpdated": "按任务自动合并偏好已更新", - "completed": "已完成", "completedAtSep": " · 已完成:{{timestamp}}", "createPr": "创建拉取请求", "effective": "有效:{{label}}", "effectiveFrozen": "有效:{{label}} — 在进入审查时冻结", - "error": "错误", "errorSep": " · 错误:{{message}}", "followDefault": "跟随默认设置", - "lastRefreshed": "上次刷新", "loadError": "加载评审数据失败。", "loadingData": "加载评审数据中…", "markdown": "Markdown", - "never": "从未", "noCapturedFeedback": "尚未捕获任何评审反馈。", "noFeedbackDirect": "尚无评审反馈 — 此任务未在直接模式下生成评审代理反馈。", "noReviewItems": "尚无评审项目。", "perTaskAutoMerge": "按任务自动合并", "plain": "纯文本", - "prSummaryLine": "{{decision}} · {{count}} 条审查项", + "prSummaryLine_other": "", "queueing": "加入队列中…", "refresh": "刷新", "refreshDataFailed": "刷新评审数据失败。", - "refreshFailed": "刷新失败", - "refreshSourceBackground": "后台", - "refreshSourceInitialLoad": "初始加载", - "refreshSourceManual": "手动", - "refreshStatusLine": "{{status}} · 最后刷新:{{timestamp}} · {{source}}", "refreshed": "评审已刷新", + "refreshFailed": "刷新失败", "refreshing": "刷新中…", + "refreshStatusLine": "{{status}} · 最后刷新:{{timestamp}} · {{source}}", "requestRevision": "请求修订", - "reviewerSummaryLine": "{{reviewer}} · {{count}} 条审查项", + "reviewerSummaryLine_other": "", "revisionQueueFailed": "无法将修订加入队列", "revisionStarted": "从选定的评审反馈开始了同任务人工智能修订", - "selected": "已选择", "selectedAt": "已选择:{{timestamp}}", "showMarkdown": "显示格式化的 Markdown", "showRawText": "显示原始文本", - "started": "已开始", "startedAtSep": " · 已开始:{{timestamp}}", + "updateFailed": "更新 {{taskId}} 失败:{{error}}", "upToDate": "最新", - "updateFailed": "更新 {{taskId}} 失败:{{error}}" + "prSummaryLine_one": "", + "reviewerSummaryLine_one": "" }, "tasks": { "addTaskPlaceholder": "添加任务……", "agent": "智能体", "agentLabel": "智能体", "archive": "归档", + "archived": "已归档 {{taskId}}", + "archivedUnlinked": "已解除谱系引用并归档 {{taskId}}", "archiveFailed": "归档 {{taskId}} 失败:{{error}}", "archiveLineageConflict": "{{taskId}} 有子项({{children}})将其引用为来源父项。\n\n仍要先解除这些引用并归档吗?", "archiveTask": "归档任务", - "archived": "已归档 {{taskId}}", - "archivedUnlinked": "已解除谱系引用并归档 {{taskId}}", "assignedTo": "已分配给 {{name}}", "attach": "附件", - "attachCount": "附件 ({{count}})", - "attachFileFailed": "附加 {{fileName}} 失败:{{error}}", + "attachCount_other": "", "attachedFile": "已将 {{fileName}} 附加到 {{taskId}}", + "attachFileFailed": "附加 {{fileName}} 失败:{{error}}", "awaitingApproval": "等待审批", "baseBranch": "基础", "blockedByTooltip": "被 {{taskId}} 阻塞(文件冲突)", "branch": "分支", "branchMetadata": "分支元数据", + "branchProgress": "", + "branchProgressTitle": "", "cancelMove": "取消移动", "clearSelection": "清除选择", "closeIssue": "关闭 Issue", "collapse": "折叠", + "createdByAgent": "由智能体创建", + "createdByAgentNamed": "由智能体创建:{{name}}", + "createdPr": "已创建 PR #{{number}}", "createFailed": "创建任务失败", "createPr": "创建 PR", "createPrAriaLabel": "创建拉取请求", "createPrTitle": "为此任务创建 PR", "createTaskTitle": "创建任务", - "createdByAgent": "由智能体创建", - "createdByAgentNamed": "由智能体创建:{{name}}", - "createdPr": "已创建 PR #{{number}}", "creating": "创建中……", "decisionOnly": "仅决策", "decisionOnlyTitle": "纯决策任务", "deleteConfirm": "删除 {{taskId}}?", + "deleted": "已删除 {{taskId}}{{suffix}}", + "deletedRemovedDeps": "已移除依赖引用并删除 {{taskId}}", + "deletedUnlinked": "已解除谱系引用并删除 {{taskId}}", "deleteFailed": "删除 {{taskId}} 失败:{{error}}", "deleteIssue": "删除 Issue", "deleteLinkedIssueMessage": "在 GitHub 上删除 {{issueLabel}},还是保持不变?", "deleteLinkedIssueTitle": "删除关联的 GitHub Issue", "deleteTask": "删除任务", "deleteTitle": "删除任务", - "deleted": "已删除 {{taskId}}{{suffix}}", - "deletedRemovedDeps": "已移除依赖引用并删除 {{taskId}}", - "deletedUnlinked": "已解除谱系引用并删除 {{taskId}}", "dependencyConflict": "{{taskId}} 是 {{dependentList}} 的依赖项。\n\n仍要先移除这些依赖引用并删除吗?", "deps": "依赖", - "depsCount": "{{count}} 个依赖", + "depsCount_other": "", "descriptionPlaceholder": "任务描述", "descriptionRefined": "已用 AI 优化描述", "doneNoMerge": "完成(不合并)", @@ -6406,11 +6326,11 @@ "fanoutEscalated": "升级的重叠", "fanoutEscalationSuffix": " · 在阻塞列中 {{minutes}} 分钟后升级", "fanoutHighFanoutSuffix": "(重叠瓶颈阈值:{{threshold}})", - "fanoutStale": "{{count}} 过期", - "fanoutTooltip": "阻塞 {{count}} 个活跃任务;重叠阻塞队列:{{queueCount}} 待办{{highFanout}}{{escalation}}", + "fanoutStale_other": "", + "fanoutTooltip_other": "", "fast": "快速", "fastMode": "快速模式", - "filesChanged": "{{count}} 个文件已更改", + "filesChanged_other": "", "forceDeleteTitle": "强制删除任务", "githubTrackingDefaultOff": "关", "githubTrackingDefaultOn": "开", @@ -6438,23 +6358,24 @@ "loadAgentsFailed": "加载智能体失败:{{msg}}", "loadAgentsFailedGeneric": "加载智能体失败", "loadDependencyFailed": "加载依赖项 {{depId}} 失败", - "loadModelsFailed": "加载模型失败", "loadingAgents": "正在加载智能体……", + "loadModelsFailed": "加载模型失败", "missionBadgeTitle": "任务:{{name}}", "modelExecutor": "执行器", "modelPlan": "规划", "modelReviewer": "审阅者", "models": "模型", - "modelsCount": "{{count}} 个模型", + "modelsCount_other": "", "moreOptions": "更多选项", "move": "移动", + "moved": "已将 {{taskId}} 移动到 {{column}}", "moveFailed": "移动 {{taskId}} 失败:{{error}}", "moveTask": "移动任务", - "moved": "已将 {{taskId}} 移动到 {{column}}", "nearDuplicateTitle": "疑似与 {{id}} 重复", + "needsInput": "", "noAgentsAvailable": "无可用智能体", - "noExistingTasks": "暂无任务", "node": "节点", + "noExistingTasks": "暂无任务", "openRetryBreakdown": "查看重试详情", "paused": "已暂停", "pausedByAgent": "已被智能体暂停", @@ -6482,7 +6403,7 @@ "resetProgress": "重置进度", "resetProgressMessage": "移动此任务前重置所有步骤进度?", "resetProgressTitle": "重置进度?", - "retriesAriaLabel": "{{count}} 次重试", + "retriesAriaLabel_other": "", "retry": "重试", "retryFailed": "重试 {{taskId}} 失败:{{error}}", "retrying": "重试中…", @@ -6498,22 +6419,30 @@ "showSteps": "显示步骤", "stalled": "停滞", "statusMergingFix": "正在合并修复…", - "stepCount": "{{count}} 个步骤", + "stepCount_other": "", "stuck": "卡住", "subtask": "子任务", "subtaskButtonTitle": "拆分为 AI 生成的子任务", "toggleFastMode": "切换快速执行模式", "unarchive": "取消归档", + "unarchived": "已取消归档 {{taskId}}", "unarchiveFailed": "取消归档 {{taskId}} 失败:{{error}}", "unarchiveTask": "取消归档任务", - "unarchived": "已取消归档 {{taskId}}", - "updateFailed": "更新 {{taskId}} 失败:{{error}}", "updated": "已更新 {{taskId}}", + "updateFailed": "更新 {{taskId}} 失败:{{error}}", "uploadFailed": "上传失败:{{files}}", "usingDefault": "使用默认", "viewDependency": "点击查看 {{depId}}", "workflow": "工作流", - "workflowCheck": "工作流检查" + "workflowCheck": "工作流检查", + "attachCount_one": "", + "depsCount_one": "", + "fanoutStale_one": "", + "fanoutTooltip_one": "", + "filesChanged_one": "", + "modelsCount_one": "", + "retriesAriaLabel_one": "", + "stepCount_one": "" }, "terminal": { "clear": "清空", @@ -6539,37 +6468,14 @@ "statusReconnecting": "重新连接中..." }, "theme": { - "colorTheme": { - "default": "默认" - }, + "colorTheme": "", "colorThemeLabel": "颜色主题", "currentTheme": "当前主题", - "dark": "深色", - "darkMode": "深色模式", - "fontSize": { - "Default": "默认", - "Large": "大", - "Largest": "最大", - "Small": "小" - }, + "fontSize": "", "fontSizeLabel": "仪表板字体大小", - "light": "浅色", - "lightMode": "浅色模式", "modeLabel": "主题模式", "resetButton": "重置为默认值", - "resetLabel": "重置为默认主题", - "system": "系统", - "systemMode": "系统模式" - }, - "time": { - "daysAgo": "{{n}}天前", - "hoursAgo": "{{n}}小时前", - "inAMoment": "即将", - "inDays": "{{n}}天后", - "inHours": "{{n}}小时后", - "inMinutes": "{{n}}分钟后", - "justNow": "刚刚", - "minutesAgo": "{{n}}分钟前" + "resetLabel": "重置为默认主题" }, "todo": { "addItemPlaceholder": "添加待办事项", @@ -6593,6 +6499,7 @@ "failedDeleteItemToast": "删除待办事项失败", "failedDeleteList": "删除列表失败", "failedDeleteListToast": "删除待办事项列表失败", + "failedLoadLists": "", "failedRenameList": "重命名列表失败", "failedRenameListToast": "重命名待办事项列表失败", "failedReorderItems": "重新排序项失败", @@ -6653,19 +6560,20 @@ "resetsInDaysHours": "在 {{days}} 天 {{hours}} 小时后重置", "resetsInHours": "在 {{hours}} 小时后重置", "resetsInMinutes": "在 {{mins}} 分钟后重置", - "showHidden": "显示隐藏的 ({{count}})", + "showHidden_other": "", "statusError": "错误", "statusNotConfigured": "未配置", "title": "使用情况", "viewModeLabel": "使用情况视图模式", "viewModeRemaining": "剩余", - "viewModeUsed": "已使用" + "viewModeUsed": "已使用", + "showHidden_one": "" }, "workflow": { "add": "添加", + "adding": "添加中...", "addTemplate": "添加模板", "addWorkflowStep": "添加工作流步骤", - "adding": "添加中...", "advisoryExplanation": "建议性工作流步骤标记了非阻塞改进:", "agentPromptLabel": "Agent 提示", "agentPromptPlaceholder": "留空以使用 AI 自动优化", @@ -6719,6 +6627,7 @@ "gateModeAdvisoryHint": "失败将被记录为建议性,不会阻止合并。", "gateModeGate": "拦截", "gateModeGateHint": "失败会阻止合并并请求修复。", + "graphEditor": "", "hideOutput": "隐藏输出", "loadingBuiltInTemplates": "正在加载内置模板...", "loadingResults": "加载工作流结果…", @@ -6727,12 +6636,12 @@ "modalAriaLabel": "工作流步骤", "modalTitle": "工作流步骤", "modeAiPrompt": "AI 提示", - "modeScript": "运行脚本", "modelHintCustom": "正在使用 {{provider}}/{{modelId}}", "modelHintDefault": "正在使用全局默认模型", "modelOverrideDropdownLabel": "此工作流步骤的模型覆盖", "modelOverrideLabel": "模型覆盖", "modelOverridePlaceholder": "选择模型覆盖…", + "modeScript": "运行脚本", "moveDown": "向下移动", "moveUp": "向上移动", "needsReview": "需要后续审查。", @@ -6749,8 +6658,6 @@ "phasePreMergeHint": "合并前运行——失败时可阻止合并", "plain": "纯文本", "polishNotes": "打磨笔记", - "postMerge": "合并后", - "preMerge": "合并前", "promptRefined": "提示已通过 AI 优化", "refineWithAi": "使用 AI 优化", "refineWithAiAriaLabel": "使用 AI 优化提示", @@ -6761,31 +6668,87 @@ "selectStepsDescription": "选择任务实现完成后要运行的步骤", "showOutput": "显示输出", "started": "开始于:", - "statusAdvisory": "建议性失败", - "statusFailed": "失败", - "statusPassed": "通过", - "statusRunning": "运行中…", - "statusSkipped": "跳过", - "stepCount": "{{count}} 个步骤", + "stepCount_other": "", "stepCreated": "工作流步骤已创建", "stepDefinitionNotFound": "未找到步骤定义。", "stepDeleted": "工作流步骤已删除", - "stepUpdated": "工作流步骤已更新", "steps": "工作流步骤", "stepsExplanation": "合并前步骤在实现后、合并前运行。合并后步骤在合并成功后运行。", - "summaryAdvisory": "{{count}} 建议", - "summaryFailed": "{{count}} 失败", - "summaryPassed": "{{count}} 通过", - "summaryRunning": "{{count}} 运行中", + "stepUpdated": "工作流步骤已更新", + "summaryAdvisory_other": "", + "summaryFailed_other": "", + "summaryPassed_other": "", + "summaryRunning_other": "", "summarySeparator": " · ", - "summarySkipped": "{{count}} 已跳过", + "summarySkipped_other": "", + "summaryStepCount_other": "", "switchToMarkdown": "切换到 Markdown", "switchToPlain": "切换到纯文本", - "tabMySteps": "我的工作流步骤({{count}})", - "tabTemplates": "模板({{count}})", + "tabMySteps_other": "", + "tabTemplates_other": "", "templateAdded": "已添加工作流步骤「{{name}}」", "useDefault": "使用默认", - "waitingForOutput": "等待代理输出…" + "stepCount_one": "", + "summaryAdvisory_one": "", + "summaryFailed_one": "", + "summaryPassed_one": "", + "summaryRunning_one": "", + "summarySkipped_one": "", + "summaryStepCount_one": "", + "tabMySteps_one": "", + "tabTemplates_one": "" + }, + "workflowColumns": { + "add": "", + "compositionBlocked": "", + "empty": "", + "moveDown": "", + "moveUp": "", + "nameLabel": "", + "newColumnName": "", + "nodeUnplaced": "", + "readOnlyHint": "", + "remove": "", + "title": "", + "traits": "", + "traitsLoadFailed": "", + "unplacedCount_other": "", + "unplacedCount_one": "" + }, + "workflowNodes": { + "advisory": "", + "failureCollect": "", + "failureFailFast": "", + "failurePolicy": "", + "gateBlocks": "", + "gateMode": "", + "joinAll": "", + "joinAny": "", + "joinMode": "", + "joinQuorum": "", + "mergeBoundaryNote": "", + "quorumN": "", + "releaseCapacity": "", + "releaseCondition": "", + "releaseDependency": "", + "releaseExternal": "", + "releaseManual": "", + "releaseTimer": "", + "splitNote": "" + }, + "workflows": { + "duplicateToCustomize": "", + "readOnlyBuiltin": "", + "saved": "", + "savedNotCompilable": "", + "saveFailed": "", + "selectOrCreate": "" + }, + "workflowSelector": { + "switchActiveMessage": "", + "switchActiveTitle": "", + "switchCancel": "", + "switchConfirm": "" }, "workspace": { "projectRoot": "项目根目录", diff --git a/packages/i18n/locales/zh-CN/cli.json b/packages/i18n/locales/zh-CN/cli.json index e6609864c7..3e985e03d2 100644 --- a/packages/i18n/locales/zh-CN/cli.json +++ b/packages/i18n/locales/zh-CN/cli.json @@ -20,11 +20,11 @@ "agentRunId": "ID:", "agentRunLogsBackHint": "[Esc/q] 返回运行列表", "agentRunLogsTitle": "运行日志({{index}})", + "agentsFooterHints": "[s] 启动 [x] 停止 [D] 删除 [r] 刷新 [Tab] 焦点 ↑↓ 选择", + "agentsListTitle_other": "", + "agentsNoAgents": "未找到代理。", "agentStarted": "代理已启动", "agentStopped": "代理已停止", - "agentsFooterHints": "[s] 启动 [x] 停止 [D] 删除 [r] 刷新 [Tab] 焦点 ↑↓ 选择", - "agentsListTitle": "代理({{count}})", - "agentsNoAgents": "未找到代理。", "boardCreateTaskHints": "Enter 创建 · Esc 取消", "boardCreateTaskNoProject": "未选择项目", "boardCreateTaskTitleEmpty": "标题不能为空", @@ -33,6 +33,7 @@ "boardNewTaskProject": "项目:{{name}}", "boardNewTaskTitle": "新建任务", "boardNewTaskTitleLabel": "标题", + "boardOtherReadOnlyHint": "", "copiedSuccess": "✓ 已复制!", "copyFailed": "✗ 复制失败", "expandedLogHeader": "条目 {{index}}/{{total}} · [Enter/Esc] 关闭 · [c] 复制", @@ -43,26 +44,26 @@ "filesEmpty": "(空)", "filesEmptyFile": "(空文件)", "filesFooterHints": "[Tab] 切换面板 [↑↓/jk] 移动 [Enter] 打开 [←/→] 折叠/展开 [.] 隐藏文件 [w] 换行 [p] 项目 [r] 重载", - "filesMoreLines": "… 还有 {{count}} 行", + "filesMoreLines_other": "", "filesSelectProject": "选择项目", "filesSelectToPreview": "选择文件以预览", "filesTooLarge": "{{size}} — [文件过大,无法预览]", "filesUnableToRead": "无法读取文件", - "gitFetchFailed": "拉取失败:{{output}}", "gitFetched": "已拉取", + "gitFetchFailed": "拉取失败:{{output}}", "gitFetching": "正在拉取…", "gitFooterHints": "[r] 刷新 {{push}}[F] 拉取 [↑↓] 行 [←→] 状态▸分支{{worktrees}}▸提交▸变更 [p] 项目 [Esc/s] 返回", "gitNoCommits": "无提交", "gitNoProject": "无项目", "gitPushDismissHint": "[Esc] 关闭", "gitPushFailed": "推送失败", + "gitPushingToOrigin": "正在推送到 origin/{{branch}}", "gitPushModalAhead": "领先", "gitPushModalBranch": "分支:", "gitPushModalCommits": "待推送提交(从旧到新):", "gitPushModalHints": "[Enter] 推送 [Esc] 取消", "gitPushModalTitle": "推送到远程", "gitPushSuccessful": "推送成功", - "gitPushingToOrigin": "正在推送到 origin/{{branch}}", "gitRefreshing": "刷新中", "gitWorkingTreeClean": "工作区干净", "headerHelpQuitHint": "[?] 帮助 [q] 退出", @@ -117,14 +118,13 @@ "projectSelectorChangeHint": "[p] 切换", "projectSelectorLabel": "项目:", "projectSelectorNavHints": "↑↓ 导航 · Enter 选择 · Esc 取消", - "projectSelectorNoProjects": "(无已注册项目)", "projectSelectorNone": "(无)", + "projectSelectorNoProjects": "(无已注册项目)", "projectSelectorPickTitle": "选择项目", "qrCloseHint": "[Esc] 关闭", "qrGenerating": "正在生成 QR 码…", "qrNoTunnelRunning": "没有运行中的远程隧道。请在设置(g)中启动。", "qrOverlayTitle": "远程访问 — 扫码连接", - "quit": "退出", "readyIn": "就绪,耗时 {{secs}} 秒", "runLogNone": "此次运行未捕获日志。", "runLogResult": "结果:", @@ -137,16 +137,6 @@ "runStatusFailed": "失败", "runStatusTerminated": "已终止", "runStatusUnknown": "未知", - "settingAutoMerge": "自动合并", - "settingEnginePaused": "引擎已暂停", - "settingGlobalPause": "全局暂停", - "settingMaxConcurrent": "最大并发数", - "settingMaxWorktrees": "最大工作树数", - "settingMergeStrategy": "合并策略", - "settingPollIntervalMs": "轮询间隔(毫秒)", - "settingRemoteActiveProvider": "远程提供商", - "settingRemoteShortLivedEnabled": "短期令牌", - "settingRemoteShortLivedTtlMs": "短期令牌 TTL(毫秒)", "settingsActivatedProvider": "已激活提供商:{{provider}}", "settingsAdjust1": "[+/-] 调整 1", "settingsAdjust5000ms": "[+/-] 调整 5000ms", @@ -161,7 +151,7 @@ "settingsFooterHints": "[Tab] 切换面板 ↑↓ 选择设置 [Space] 切换布尔 [+/-] 调整数值 [←/→] 循环枚举 [C/V/X/P/L/U/K/R] 远程操作", "settingsInteractivePanelTitle": "设置", "settingsLoadingSettings": "正在加载设置…", - "settingsMoreModels": "… 还有 {{count}} 个", + "settingsMoreModels_other": "", "settingsPanelTitle": "设置", "settingsPersistentTokenRegenerated": "持久令牌已重新生成", "settingsQrFetched": "QR 数据已获取", @@ -228,6 +218,9 @@ "utilitiesKillVitest": "终止 Vitest 进程", "utilitiesPanelTitle": "工具", "utilitiesRefreshStats": "刷新统计", - "utilitiesToggleEnginePause": "切换引擎暂停" + "utilitiesToggleEnginePause": "切换引擎暂停", + "agentsListTitle_one": "", + "filesMoreLines_one": "", + "settingsMoreModels_one": "" } } diff --git a/packages/i18n/locales/zh-CN/common.json b/packages/i18n/locales/zh-CN/common.json index 1f0b11761d..b75b3a1f75 100644 --- a/packages/i18n/locales/zh-CN/common.json +++ b/packages/i18n/locales/zh-CN/common.json @@ -4,8 +4,65 @@ "close": "关闭", "save": "保存" }, + "agents": { + "ratings": { + "trendDeclining": "", + "trendImproving": "", + "trendInsufficient": "", + "trendStable": "" + }, + "reflections": { + "triggerManual": "", + "triggerPeriodic": "", + "triggerPostTask": "", + "triggerUserRequested": "" + }, + "time": { + "daysAgo_one": "", + "daysAgo_other": "", + "hoursAgo_one": "", + "hoursAgo_other": "", + "inAMoment": "", + "inDays_one": "", + "inDays_other": "", + "inHours_one": "", + "inHours_other": "", + "inMinutes_one": "", + "inMinutes_other": "", + "justNow": "", + "minutesAgo_one": "", + "minutesAgo_other": "" + } + }, "archive": "归档", + "board": { + "rejection": { + "capacityExhausted": "", + "guardRejected": "", + "mergeBlocked": "", + "unknownColumn": "", + "workflowMismatch": "" + } + }, "cancel": "取消", + "chat": { + "failedToGetResponse": "", + "failureReferenceId": "", + "failureReferenceKind": "", + "failureReferenceLabel": "", + "failureReferenceMetaLabel": "", + "openMailboxMessage": "", + "toolCallArgsPrefix": "", + "toolCallResultPrefix": "", + "toolCallStatusCompleted": "", + "toolCallStatusError": "", + "toolCallStatusErrors": "", + "toolCallStatusRunning": "", + "toolCallsCount_one": "", + "toolCallsCount_other": "", + "toolCallsHeader": "", + "viewFailureDetails": "" + }, "close": "关闭", "columns": { "archived": "已归档", @@ -16,8 +73,162 @@ "triage": "规划" }, "delete": "删除", + "health": { + "anomaly": { + "duplicateActiveId": "", + "idInBothStorages": "", + "sequenceOverlap": "", + "unknownPrefix": "" + } + }, + "inline": { + "connecting": "", + "error": "", + "offline": "", + "online": "" + }, + "merge": { + "unknown": "" + }, + "missions": { + "autopilotStateActivating": "", + "autopilotStateCompleting": "", + "autopilotStateInactive": "", + "autopilotStateUnknown": "", + "autopilotStateWatching": "", + "interviewStatusAwaitingInput": "", + "interviewStatusComplete": "", + "interviewStatusError": "", + "interviewStatusGenerating": "", + "runHelperActive": "", + "runHelperBlocked": "", + "runHelperPlanning": "" + }, + "models": { + "messages": { + "modelSetTo": "", + "modelSetToDefault": "" + } + }, + "nodeStatus": { + "connecting": "", + "error": "", + "offline": "", + "online": "", + "unknown": "" + }, + "nodes": { + "auth": { + "differ": "", + "differProviders": "", + "match": "", + "notSynced": "" + }, + "status": { + "connecting": "", + "creating": "", + "deleting": "", + "error": "", + "exited": "", + "offline": "", + "online": "", + "recreating": "", + "running": "", + "stopped": "" + } + }, "refresh": "刷新", + "research": { + "providerGitHub": "", + "providerLlmSynthesis": "", + "providerLocalDocs": "", + "providerPageFetch": "", + "providerWebSearch": "" + }, "retry": "重试", + "routing": { + "policyLabel": { + "block": "", + "fallback": "", + "notConfigured": "" + } + }, + "setup": { + "apiKeyFormatError": "", + "apiKeyLabel": { + "fallback": "", + "kimiCoding": "", + "minimax": "", + "ollama": "", + "openai": "", + "openrouter": "", + "zai": "" + }, + "apiKeyPlaceholder": { + "fallback": "", + "kimiCoding": "", + "minimax": "", + "zai": "" + }, + "apiKeyRequired": "", + "apiKeySetup": { + "fallback": "", + "kimiCoding": "", + "minimax": "", + "ollama": "", + "openai": "", + "openrouter": "", + "zai": "" + }, + "apiKeyUsage": { + "fallback": "", + "kimiCoding": "", + "minimax": "", + "ollama": "", + "openai": "", + "openrouter": "", + "zai": "" + }, + "providerDesc": { + "anthropic": "", + "fallback": "", + "gemini": "", + "google": "", + "kimi": "", + "kimiCoding": "", + "minimax": "", + "moonshot": "", + "ollama": "", + "openai": "", + "openaiCodex": "", + "openrouter": "", + "zai": "" + } + }, "skip": "跳过", - "tryAgain": "重试" + "taskForm": { + "nodeStatusConnecting": "", + "nodeStatusError": "", + "nodeStatusOffline": "", + "nodeStatusOnline": "", + "phasePostMerge": "", + "phasePreMerge": "" + }, + "taskReview": { + "never": "", + "refreshSourceBackground": "", + "refreshSourceInitialLoad": "", + "refreshSourceManual": "" + }, + "tryAgain": "重试", + "workflow": { + "postMerge": "", + "preMerge": "", + "statusAdvisory": "", + "statusFailed": "", + "statusPassed": "", + "statusRunning": "", + "statusSkipped": "", + "waitingForOutput": "" + } } diff --git a/packages/i18n/locales/zh-CN/errors.json b/packages/i18n/locales/zh-CN/errors.json index f1c41d95fb..0967ef424b 100644 --- a/packages/i18n/locales/zh-CN/errors.json +++ b/packages/i18n/locales/zh-CN/errors.json @@ -1,4 +1 @@ -{ - "fetchProjectsFailed": "获取项目失败", - "openTaskLogsFailed": "打开任务日志失败:{{detail}}" -} +{} diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index ea4a2a7ee6..984e8699cd 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -16,7 +16,6 @@ "dismissOAuth": "關閉 OAuth 重新登入橫幅", "done": "完成", "edit": "編輯", - "generateInsights": "生成新洞察", "no": "否", "openSettings": "開啟設定", "pull": "拉取", @@ -66,10 +65,13 @@ "notMerged": "未合併", "refresh": "重新整理", "time": { - "daysAgo": "{{count}}天前", - "hoursAgo": "{{count}}小時前", + "daysAgo_other": "", + "hoursAgo_other": "", "justNow": "剛剛", - "minutesAgo": "{{count}}分鐘前" + "minutesAgo_other": "", + "daysAgo_one": "", + "hoursAgo_one": "", + "minutesAgo_one": "" }, "title": "活動日誌" }, @@ -95,29 +97,33 @@ "hideToolCallsResults": "隱藏工具呼叫和結果", "hideToolOutput": "隱藏工具輸出", "live": "即時", - "loadMore": "載入更多", "loading": "載入代理日誌中…", "loadingMore": "載入中…", + "loadMore": "載入更多", "markdown": "Markdown", "plain": "純文字", "planning": "規劃", "reviewer": "審查者", "showFormattedMarkdown": "顯示格式化的 markdown", + "showing": "顯示 {{visible}} 條,共 {{total}} 條項目", "showOutput": "顯示輸出", "showRawText": "顯示原始文字", "showToolCallsResults": "顯示工具呼叫和結果", "showToolOutput": "顯示工具輸出", - "showing": "顯示 {{visible}} 條,共 {{total}} 條項目", "switchMarkdown": "切換至 markdown 模式", "switchPlainText": "切換至純文字模式", - "timeDaysAgo": "{{count}} 天前", - "timeHoursAgo": "{{count}} 小時前", + "timeDaysAgo_other": "", + "timeHoursAgo_other": "", "timeJustNow": "剛才", - "timeMinutesAgo": "{{count}} 分鐘前", - "toolEntriesHidden": "{{count}} 條工具項目已隱藏", + "timeMinutesAgo_other": "", + "toolEntriesHidden_other": "", "toolsOff": "工具:關閉", "toolsOn": "工具:開啟", - "usingDefault": "使用預設值" + "usingDefault": "使用預設值", + "timeDaysAgo_one": "", + "timeHoursAgo_one": "", + "timeMinutesAgo_one": "", + "toolEntriesHidden_one": "" }, "agentMention": { "membersOf": "#{{roomName}} 的成員", @@ -239,15 +245,6 @@ "promptDefault": "預設值:{{preview}}", "templateName": "例如 我的自訂執行器" }, - "roles": { - "custom": "自訂代理", - "engineer": "工程代理", - "executor": "執行代理", - "merger": "合併代理", - "reviewer": "審查代理", - "scheduler": "排程代理", - "triage": "分類代理" - }, "sections": { "builtinTemplates": "內建範本", "customTemplates": "自訂範本" @@ -281,7 +278,7 @@ }, "agents": { "activate": "啟動", - "activeAgents": "活躍代理 ({{count}})", + "activeAgents_other": "", "activePrefix": "活躍:", "advancedSettingsDesc": "此代理的底層配置選項。", "advancedSettingsTitle": "進階設定", @@ -291,15 +288,15 @@ "agentMail": "代理郵件", "agentModelLabel": "代理模型", "agentPlural": "代理人", + "agentsFound_other": "", "agentSingular": "代理人", - "agentSoulLabel": "代理靈魂", - "agentsFound": "找到{{count}}個代理{{plural}}", "agentsLabel": "代理", + "agentSoulLabel": "代理靈魂", "aiInterview": "AI 訪談", "allChangesSaved": "所有變更已儲存", - "allTime": "全部時間", "allowParallelExecution": "允許並行執行", "allowParallelExecutionHint": "允許此代理並發執行多個心跳。", + "allTime": "全部時間", "alreadyOnDefault": "已是預設", "applyPreset": "套用預設", "assignedSkills": "已分配技能", @@ -330,12 +327,11 @@ "bulkActions": "批量操作", "bulkActionsLoadFailed": "載入批次智能體操作失敗:{{error}}", "bulkAgentActions": "批次智能體操作", - "bulkConfirmMessage": "{{action}} {{count}} 個代理?", + "bulkConfirmMessage_other": "", "bulkNoEligible": "沒有符合條件的代理", - "bulkResult": "已{{action}} {{count}} 個代理", - "bulkResultWithFailures": "已{{action}} {{count}} 個代理,{{failed}} 個失敗", "bulkResult_one": "{{action}} {{successCount}} 個{{agentWord}};略過 {{skippedCount}} 個", "bulkResult_other": "{{action}} {{successCount}} 個{{agentWord}};略過 {{skippedCount}} 個", + "bulkResultWithFailures": "已{{action}} {{count}} 個代理,{{failed}} 個失敗", "bundleDescription": "配置此代理程式碼套件的管理方式。", "bundleEntryFileHint": "託管套件的入口檔案。", "bundleEntryFileLabel": "入口檔案", @@ -381,9 +377,9 @@ "copyId": "複製 ID", "create": "建立", "createAgent": "建立代理", + "created": "代理「{{name}}」已建立", "createError": "建立代理程式失敗", "createSuccess": "智能代理「{{name}}」已建立", - "created": "代理「{{name}}」已建立", "creating": "正在建立…", "creatingAgent": "正在建立代理...", "currentAgent": "目前代理", @@ -400,12 +396,12 @@ "delete": "刪除", "deleteAgent": "刪除代理", "deleteConfirm": "刪除智能體「{{name}}」?此操作無法撤銷。", + "deleted": "智能體「{{name}}」已刪除", "deleteError": "刪除智能代理失敗:{{error}}", "deleteFailed": "刪除智能體失敗:{{error}}", "deleteMessage": "刪除智能代理「{{name}}」?此操作無法復原。", "deleteSuccess": "智能代理「{{name}}」已刪除", "deleteTitle": "刪除智能體", - "deleted": "智能體「{{name}}」已刪除", "deletionNotAvailable": "代理執行時無法刪除。", "deletionPermanent": "這將永久刪除代理及其所有相關資料。", "details": "詳情", @@ -473,6 +469,7 @@ "healthError": "錯誤", "heartbeat": "心跳:", "heartbeatAndHealth": "心跳與健康", + "heartbeatClampedToMin_other": "", "heartbeatCustom": "自訂心跳執行", "heartbeatEnabled": "啟用心跳", "heartbeatEnabledHint": "允許此代理按排程心跳執行。", @@ -483,12 +480,12 @@ "heartbeatFileLoadFailed": "載入心跳檔案失敗", "heartbeatFilePlaceholder": "心跳程序內容...", "heartbeatFilePreviewMode": "預覽模式", - "heartbeatFileSaveFailed": "儲存心跳檔案失敗", "heartbeatFileSaved": "心跳檔案已儲存", + "heartbeatFileSaveFailed": "儲存心跳檔案失敗", "heartbeatIntervalHint": "心跳執行的頻率(秒)。", "heartbeatIntervalLabel": "心跳間隔(秒)", - "heartbeatIntervalUpdateFailed": "更新心跳間隔失敗:{{error}}", "heartbeatIntervalUpdated": "{{name}} 的心跳間隔已更新為 {{interval}}", + "heartbeatIntervalUpdateFailed": "更新心跳間隔失敗:{{error}}", "heartbeatMustBeNumber": "心跳間隔必須是有效數字", "heartbeatMustBePositive": "心跳間隔必須大於 0", "heartbeatOverdue": "心跳超期 {{elapsed}}", @@ -512,8 +509,8 @@ "heartbeatSpeedPreset": "心跳速度預設", "heartbeatSpeedSaveFailed": "儲存心跳倍數失敗:{{error}}", "heartbeatSpeedSet": "心跳速度已設為 ×{{value}}", - "heartbeatStartFailed": "啟動心跳失敗", "heartbeatStarted": "心跳已啟動", + "heartbeatStartFailed": "啟動心跳失敗", "heartbeatTimeoutHint": "心跳執行在被終止前的最長時間(秒)。", "heartbeatTimeoutLabel": "心跳逾時(秒)", "heartbeatUpgradeFailed": "升級心跳程序失敗", @@ -527,16 +524,16 @@ "importButton": "匯入{{label}}", "importComplete": "匯入完成", "importDescription": "從 Agent Companies 套件匯入代理。瀏覽 companies.sh 目錄以探索已發佈的代理、上傳 AGENTS.md 檔案、選取目錄或貼上資訊清單內容。", - "importingAgents": "正在匯入 {{count}} 個 Agent...", + "importingAgents_other": "", "importingAgentsAndSkills": "正在匯入 {{agentCount}} 個 Agent 和 {{skillCount}} 個技能...", - "importingSkills": "正在匯入 {{count}} 個技能...", - "inProgress": "進行中", + "importingSkills_other": "", "inbox": "收件匣", - "inheritProjectDefault": "繼承專案預設", "inheritingProjectDefault": "繼承專案預設值", + "inheritProjectDefault": "繼承專案預設", "inlineMemoryFieldHint": "此記憶直接嵌入代理的上下文中。", "inlineMemoryHint": "每次心跳時注入的簡短記憶。", "inlineMemoryLabel": "內聯記憶", + "inProgress": "進行中", "input": "輸入", "inputTokens": "輸入令牌", "installs": "安裝", @@ -544,15 +541,15 @@ "instructionsEmptyPreview": "尚無指令——切換到編輯模式以新增。", "instructionsFileEditorDesc": "直接編輯連結的指令檔案。", "instructionsFileEditorTitle": "指令檔案", - "instructionsFileSaveFailed": "儲存指令檔案失敗", "instructionsFileSaved": "指令檔案已儲存", + "instructionsFileSaveFailed": "儲存指令檔案失敗", "instructionsHint": "這些指令會被添加到此代理收到的每個提示詞之前。", "instructionsPathHint": "包含此代理指令的 Markdown 檔案路徑。", "instructionsPathLabel": "指令檔案路徑", "instructionsPathPlaceholder": "例如:.fusion/agents/reviewer.md", "instructionsPlaceholder": "輸入此代理的指令...", - "instructionsSaveFailed": "儲存指令失敗", "instructionsSaved": "指令已儲存", + "instructionsSaveFailed": "儲存指令失敗", "instructionsTextPlaceholder": "新增自訂行為指令…", "instructionsTitle": "指令", "intentPrompt": "您希望此代理執行什麼操作?", @@ -574,7 +571,6 @@ "liveLogs": "實時日誌", "liveRun": "即時執行", "loadError": "載入智能代理失敗:{{error}}", - "loadTasksFailed": "載入任務失敗", "loading": "正在載入代理...", "loadingAgents": "正在載入智能體…", "loadingCompanies": "正在載入公司…", @@ -594,15 +590,16 @@ "loadingRuntimes": "正在載入執行階段…", "loadingSkillContent": "正在載入技能內容...", "loadingTasks": "正在載入任務...", - "logEntries": "條日誌", + "loadTasksFailed": "載入任務失敗", + "logEntries_other": "", "logsWillAppear": "代理開始執行後,日誌將顯示在此處。", "logsWillAppearActive": "日誌將顯示在此處。", + "mailboxLoadFailed": "載入郵箱失敗", "mailFrom": "寄件人", "mailSent": "發送時間", "mailTo": "收件人", "mailToLabel": "收件人", "mailType": "類型", - "mailboxLoadFailed": "載入郵箱失敗", "manifestContent": "資訊清單內容", "manifestPlaceholder": "---\nname: CEO\ntitle: 執行長\nreportsTo: null\nskills:\n - review\n---\n在此處填寫 Agent 指令...", "maxConcurrentRunsHint": "可以同時執行的最大心跳數。", @@ -616,8 +613,8 @@ "memoryFileMeta": "{{size}} 位元組 · 更新於 {{date}}", "memoryFilePlaceholder": "記憶檔案內容...", "memoryFilePreviewMode": "預覽模式", - "memoryFileSaveFailed": "儲存記憶檔案失敗", "memoryFileSaved": "記憶檔案已儲存", + "memoryFileSaveFailed": "儲存記憶檔案失敗", "memoryFilesHint": "存儲在代理記憶層中的檔案。", "memoryFilesHintSuffix": "選擇一個檔案以查看或編輯其內容。", "memoryFilesLabel": "記憶檔案", @@ -630,8 +627,8 @@ "memoryLayerLongTermDesc": "跨會話保留的持久事實和知識。", "memoryPlaceholder": "僅此代理程式可見——持久偏好設定、操作習慣及跨任務應保留的上下文…", "memoryReadOnly": "唯讀", - "memorySaveFailed": "儲存記憶失敗", "memorySaved": "記憶已儲存", + "memorySaveFailed": "儲存記憶失敗", "memoryTitle": "記憶", "memoryTooLong": "記憶內容太長", "messageResponseModeHint": "此代理回應傳入訊息的時機。", @@ -672,6 +669,7 @@ "noLogsForRun": "此次執行沒有日誌", "noManager": "無管理員", "noMemoryFiles": "沒有記憶檔案", + "noneUsingBuiltIn": "無(使用內建)", "noOutboxMessages": "寄件匣中沒有郵件", "noOutputCaptured": "未擷取輸出", "noPausedEligible": "沒有符合條件可恢復的已暫停代理", @@ -684,11 +682,9 @@ "noSkillsInPackage": "套件中沒有技能", "noTasksAssigned": "未分配任務", "noTokenUsageYet": "尚未記錄令牌使用情況。代理執行後,令牌總計將顯示在此處。", - "noneUsingBuiltIn": "無(使用內建)", "notScheduled": "未排程", "notSelected": "未選擇", "off": "關閉", - "onHeartbeat": "心跳時", "onboarding": { "applyDraftAgent": "將草稿套用至代理程式表單", "applyDraftSettings": "將草稿套用至設定表單", @@ -729,9 +725,9 @@ "updatedDraftReady": "更新後的草稿已準備好供審閱", "yes": "是" }, + "onHeartbeat": "心跳時", "openDetails": "開啟 {{name}} 的詳情", "optional": "(選填)", - "orPasteManifest": "或貼上資訊清單內容", "orgChartCanvas": "組織架構圖畫布", "orgChartCenter": "置中組織架構圖", "orgChartEmployees": "{{name}} 的下屬", @@ -740,6 +736,7 @@ "orgChartView": "組織架構圖檢視", "orgChartZoomIn": "放大組織架構圖", "orgChartZoomOut": "縮小組織架構圖", + "orPasteManifest": "或貼上資訊清單內容", "outbox": "寄件匣", "output": "輸出", "outputTokens": "輸出令牌", @@ -750,13 +747,16 @@ "pauseAgentsFailed": "暫停智能體失敗:{{error}}", "pauseAll": "暫停全部", "pauseAllAgents": "暫停所有智能體", + "pauseAllConfirm_other": "", "pauseAllTitle": "暫停所有智能體", - "pauseCountHint": "將暫停 {{count}} 個活動代理", "pauseCountHint_one": "暫停 {{count}} 個活躍/執行中的代理人", "pauseCountHint_other": "暫停 {{count}} 個活躍/執行中的代理人", + "pauseCountHint_one_other": "", + "pauseCountHint_other_other": "", "pausedPast": "已暫停", + "pausedSummary_other": "", "pendingApprovals": "待審批", - "pendingApprovalsCount": "{{count}} 待處理", + "pendingApprovalsCount_other": "", "performance": { "avgDuration": "平均時長", "noData": "尚無效能資料", @@ -774,6 +774,7 @@ "preview": "預覽", "promptSize": "提示詞大小", "promptSizeChart": "提示詞大小圖表", + "provideManifest": "", "ratings": { "addError": "新增評分失敗:{{error}}", "addRating": "新增評分", @@ -786,7 +787,7 @@ "categorySelect": "選擇分類...", "categorySpeed": "速度", "commentPlaceholder": "選填備註...", - "count": "{{count}} 個評分", + "count_other": "", "deleteError": "刪除評分失敗:{{error}}", "deleteRating": "刪除評分", "deleteSuccess": "評分已刪除", @@ -794,14 +795,12 @@ "loadError": "載入評分失敗:{{error}}", "loading": "載入評分中...", "noRatings": "尚無評分", - "starCount": "{{count}} 星", + "starCount_other": "", "submitRating": "提交評分", "submitting": "提交中...", "title": "使用者評分", - "trendDeclining": "↓ 下降中", - "trendImproving": "↑ 改善中", - "trendInsufficient": "資料不足", - "trendStable": "→ 穩定" + "count_one": "", + "starCount_one": "" }, "recentRuns": "最近執行", "reflections": { @@ -818,18 +817,14 @@ "metricAvgDuration": "平均時長:", "metricErrors": "錯誤:", "metricFailed": "失敗:", - "metricTasks": "任務:", "metrics": "指標", + "metricTasks": "任務:", "noReflections": "尚無反思", + "reflecting": "反思中...", "reflectNow": "立即反思", "reflectNowTitle": "手動產生反思", - "reflecting": "反思中...", "sectionTitle": "效能、反思與評分", - "suggestedImprovements": "改善建議", - "triggerManual": "手動", - "triggerPeriodic": "定期", - "triggerPostTask": "任務後", - "triggerUserRequested": "使用者請求" + "suggestedImprovements": "改善建議" }, "refresh": "重新整理", "removeAvatar": "移除頭像", @@ -842,19 +837,22 @@ "resetDayWeekly": "星期幾(0=週日)", "resetting": "正在重置...", "result": "結果", - "resultCreated": "{{count}}已建立", - "resultErrors": "{{count}}個錯誤{{plural}}", - "resultSkipped": "{{count}}個已略過(已存在)", + "resultCreated_other": "", + "resultErrors_other": "", + "resultSkipped_other": "", "resume": "恢復", "resumeAction": "恢復", "resumeAgentsFailed": "恢復智能體失敗:{{error}}", "resumeAll": "恢復全部", "resumeAllAgents": "恢復所有智能體", + "resumeAllConfirm_other": "", "resumeAllTitle": "恢復所有智能體", - "resumeCountHint": "將恢復 {{count}} 個已暫停代理", "resumeCountHint_one": "恢復 {{count}} 個已暫停的代理人", "resumeCountHint_other": "恢復 {{count}} 個已暫停的代理人", + "resumeCountHint_one_other": "", + "resumeCountHint_other_other": "", "resumedPast": "已恢復", + "resumedSummary_other": "", "retry": "重試", "reviewConfiguration": "檢閱生成的設定", "reviewHint": "建立前請確認代理程式設定。", @@ -868,20 +866,18 @@ "roleReviewer": "審查者", "roleScheduler": "排程者", "roleTriage": "分流", + "roleUpdated": "智能體角色已更新為 {{role}}", "roleUpdateError": "更新角色失敗:{{error}}", "roleUpdateFailed": "更新角色失敗:{{error}}", "roleUpdateSuccess": "智能代理角色已更新為 {{role}}", - "roleUpdated": "智能體角色已更新為 {{role}}", "runAriaLabel": "執行 {{id}}", "runDetailsFailed": "載入執行詳情失敗", "runMissedHeartbeat": "執行錯過的心跳", "runMissedHeartbeatHint": "如果代理錯過排程心跳,觸發一次執行。", + "running": "執行中", "runNow": "立即執行", "runNowAria": "立即執行 {{name}}", "runNowFor": "立即為 {{name}} 執行", - "runStarted": "執行已啟動", - "runStopped": "執行已停止", - "running": "執行中", "runs": { "empty": "還沒有執行", "loading": "加載執行…", @@ -890,9 +886,11 @@ "stopMessage": "停止此執行?", "stopTitle": "停止執行" }, - "runsCount": "{{count}} 次執行", + "runsCount_other": "", "runsSuccessRate": "{{rate}}% 成功率", + "runStarted": "執行已啟動", "runsToday": "今日執行次數", + "runStopped": "執行已停止", "runtime": "執行時", "runtimeEmpty": "沒有可用的外掛程式執行階段", "runtimeLabel": "執行環境", @@ -925,29 +923,30 @@ "selectAllAgents": "選取所有代理", "selectAllSkills": "選取所有技能", "selectAnAgent": "選取一個智能體", + "selectCompany": "", "selectDirectory": "選取目錄", + "selected": "已選取:", + "selectedAgentLabel_other": "", + "selectedSkillLabel_other": "", "selectMemoryFile": "選擇一個記憶檔案", "selectModel": "模型", "selectModelPlaceholder": "選擇模型...", "selectRuntime": "選擇執行環境", "selectSkill": "選取技能{{name}}", - "selected": "已選取:", - "selectedAgentLabel": "{{count}} 個 Agent", - "selectedSkillLabel": "{{count}} 個技能", "setHeartbeatAria": "設定 {{name}} 的心跳間隔", - "settingsSaveFailed": "儲存設定失敗", "settingsSaved": "設定已儲存", + "settingsSaveFailed": "儲存設定失敗", "setupModeAriaLabel": "代理程式設定模式", "showSystemAgents": "顯示系統智能體", "skills": "技能", "skillsDescription": "管理此代理可用的技能。", - "skillsErrors": "{{count}}個技能{{plural}}錯誤{{pluralError}}", - "skillsFound": "找到{{count}}個技能{{plural}}", + "skillsErrors_other": "", + "skillsFound_other": "", "skillsHint": "可選擇指派給此代理程式的技能", - "skillsImported": "{{count}}個技能{{plural}}已匯入", + "skillsImported_other": "", "skillsNone": "未分配技能", - "skillsSelected": "已選擇 {{count}} 個技能", - "skillsSkipped": "{{count}}個技能{{plural}}已略過(已存在)", + "skillsSelected_other": "", + "skillsSkipped_other": "", "skillsTitle": "技能", "skipHeartbeatWhenIdle": "閒置時跳過心跳", "skipHeartbeatWhenIdleHint": "當代理沒有任務時,避免執行心跳。", @@ -955,23 +954,23 @@ "soulEmptyPreview": "尚無靈魂——切換到編輯模式以新增。", "soulHint": "描述此代理是誰——其性格、語氣和價值觀。", "soulPlaceholder": "描述代理程式的個性和溝通風格…", - "soulSaveFailed": "儲存靈魂失敗", "soulSaved": "靈魂已儲存", + "soulSaveFailed": "儲存靈魂失敗", "soulTitle": "靈魂", "soulTooLong": "靈魂內容太長", "start": "啟動", - "startOnboarding": "開始入職", "starting": "啟動中...", + "startOnboarding": "開始入職", "stateActive": "活躍", "stateAll": "所有狀態", "stateError": "錯誤", "stateIdle": "閒置", "statePaused": "已暫停", "stateRunning": "執行中", + "stateUpdated": "智能體狀態已更新為 {{state}}", "stateUpdateError": "更新狀態失敗:{{error}}", "stateUpdateFailed": "更新狀態失敗:{{error}}", "stateUpdateSuccess": "智能代理狀態已更新為 {{state}}", - "stateUpdated": "智能體狀態已更新為 {{state}}", "status": "狀態", "statusCount": "{{activeCount}} 活躍 · {{runningCount}} 執行中", "step": "步 {{number}}{{total}}: {{name}}", @@ -1012,16 +1011,6 @@ "thinkingMinimal": "最低", "thinkingOff": "關閉", "throughput": "吞吐量", - "time": { - "daysAgo": "{{count}}天前", - "hoursAgo": "{{count}}小時前", - "inAMoment": "馬上", - "inDays": "{{count}}天後", - "inHours": "{{count}}小時後", - "inMinutes": "{{count}}分鐘後", - "justNow": "剛剛", - "minutesAgo": "{{count}}分鐘前" - }, "title": "智能體", "titleLabel": "標題", "titlePlaceholder": "例如:資深程式碼審查員", @@ -1060,7 +1049,34 @@ "weekly": "每週", "workingOn": "正在處理:", "zoomIn": "放大", - "zoomOut": "縮小" + "zoomOut": "縮小", + "activeAgents_one": "", + "agentsFound_one": "", + "bulkConfirmMessage_one": "", + "heartbeatClampedToMin_one": "", + "importingAgents_one": "", + "importingSkills_one": "", + "logEntries_one": "", + "pauseAllConfirm_one": "", + "pauseCountHint_one_one": "", + "pauseCountHint_other_one": "", + "pausedSummary_one": "", + "pendingApprovalsCount_one": "", + "resultCreated_one": "", + "resultErrors_one": "", + "resultSkipped_one": "", + "resumeAllConfirm_one": "", + "resumeCountHint_one_one": "", + "resumeCountHint_other_one": "", + "resumedSummary_one": "", + "runsCount_one": "", + "selectedAgentLabel_one": "", + "selectedSkillLabel_one": "", + "skillsErrors_one": "", + "skillsFound_one": "", + "skillsImported_one": "", + "skillsSelected_one": "", + "skillsSkipped_one": "" }, "app": { "backendError": { @@ -1070,11 +1086,12 @@ }, "approval": { "dismissBanner": "關閉審批通知橫幅", - "needAttention": "{{count}} 個審批{{noun}}需要您的注意", + "needAttention_other": "", "openMailbox": "開啟郵箱", "requestPlural": "請求", + "requests": "審批請求", "requestSingular": "請求", - "requests": "審批請求" + "needAttention_one": "" }, "auth": { "clearAndRetry": "清除令牌並重試", @@ -1100,9 +1117,9 @@ "confirmMessage": "此工作階段在另一個標籤頁中處於活躍狀態。仍然開啟?", "confirmTitle": "開啟活躍工作階段", "dismissButton": "關閉", - "pillLabel": "AI {{count}}", - "pillTitle": "{{count}} 個背景 AI 任務", - "pillTitleWithInput": "{{count}} 個背景 AI 任務({{needsInput}} 個需要輸入)", + "pillLabel_other": "", + "pillTitle_other": "", + "pillTitleWithInput_other": "", "popoverHeader": "背景任務", "status": { "activeElsewhere": "在另一個標籤頁中活躍", @@ -1116,17 +1133,29 @@ "planning": "規劃", "sliceInterview": "切片訪談", "subtask": "子任務分解" - } + }, + "pillLabel_one": "", + "pillTitle_one": "", + "pillTitleWithInput_one": "" }, "board": { "archived": "已歸檔", "done": "完成", "inProgress": "進行中", "inReview": "審查中", + "rejection": { + "capacityExhausted": "", + "guardRejected": "", + "mergeBlocked": "", + "promoteRejected": "", + "unknownColumn": "", + "workflowMismatch": "" + }, "todo": "待辦", "triage": "分診" }, "branchGroup": { + "abandonGroup": "", "autoMergeEnabled": "自動合併已啟用", "collapseLabel": "摺疊分支群組", "completionText": "{{landed}} 個成員完成,共 {{total}} 個", @@ -1185,13 +1214,8 @@ "failedToCreateSession": "建立對話失敗", "failedToDeleteConversation": "刪除對話失敗", "failedToDeleteRoom": "刪除頻道失敗", - "failedToGetResponse": "無法取得回應", "failedToSendRoomMessage": "傳送房間訊息失敗", "failureDetails": "失敗詳情", - "failureReferenceId": "ID", - "failureReferenceKind": "類型", - "failureReferenceLabel": "參考", - "failureReferenceMetaLabel": "標籤", "helpMessageContent": "可用指令:\n- `/new` 或 `/clear` — 清除對話並重新開始\n- `/skill:{name}` — 使用特定技能\n- `/help` — 顯示此說明", "jumpToLatest": "最新", "latest": "最新", @@ -1222,14 +1246,13 @@ "noRoomsYet": "目前無頻道。", "noSkillsAvailable": "暫無可用技能", "noSkillsFound": "找不到技能", - "openMailboxMessage": "開啟信箱訊息", "openQuickChat": "開啟快速聊天", "queuedMessage": "已排隊:{{preview}}", "quickChatTitle": "快速聊天", - "relativeTimeDays": "{{count}}天前", - "relativeTimeHours": "{{count}}小時前", + "relativeTimeDays_other": "", + "relativeTimeHours_other": "", "relativeTimeJustNow": "剛才", - "relativeTimeMinutes": "{{count}}分鐘前", + "relativeTimeMinutes_other": "", "removeAttachment": "移除 {{name}}", "resizePanelBottom": "從底部調整面板大小", "resizePanelBottomLeft": "從左下角調整面板大小", @@ -1242,7 +1265,7 @@ "resizeSidebar": "調整側邊欄大小", "responseCopied": "已複製回覆", "responseFailed": "回應失敗", - "roomMemberCount": "{{count}} 位成員", + "roomMemberCount_other": "", "roomsGroupLabel": "頻道", "scopeDirect": "直接", "scopeRooms": "頻道", @@ -1269,20 +1292,17 @@ "thinking": "思考中", "thinkingLabel": "思考", "thinkingStatus": "思考中……", - "toolCallArgsPrefix": "參數", - "toolCallResultPrefix": "結果", - "toolCallStatusCompleted": "已完成", - "toolCallStatusError": "錯誤", - "toolCallStatusErrors": "錯誤", - "toolCallStatusRunning": "執行中", "toolCalls": "工具呼叫", - "toolCallsCount": "{{count}} 個工具呼叫", - "toolCallsHeader": "工具呼叫", + "toolCallsCount_other": "", "typeMessage": "輸入訊息...", "unreadMessages": "未讀訊息", "untitledSession": "未命名", - "viewFailureDetails": "查看失敗詳情", - "you": "你" + "you": "你", + "relativeTimeDays_one": "", + "relativeTimeHours_one": "", + "relativeTimeMinutes_one": "", + "roomMemberCount_one": "", + "toolCallsCount_one": "" }, "chatRooms": { "error": { @@ -1295,8 +1315,8 @@ "cli": { "installBody": "在你的終端上取得 {{fn}} 和 {{fusion}} 指令,這樣你就可以從任何地方驅動 Fusion。下面一鍵點擊或複製指令到你的 shell。", "installButton": "使用 npm 安裝", - "installTitle": "安裝 Fusion CLI", "installing": "安裝中…", + "installTitle": "安裝 Fusion CLI", "openSettings": "開啟設定", "updateButton": "使用 npm 更新", "updateTitle": "更新 Fusion CLI", @@ -1312,8 +1332,8 @@ "failedExit": "安裝失敗(結束代碼 {{code}})", "heading": "CLI 二進制檔", "help": "安裝全域 CLI 可讓您從任何終端執行 fn 和 fusion。自動化和指令可以通過 npx 運作而不需要它,但全域安裝更快更方便。", - "installWithNpm": "使用 npm 安裝", "installing": "安裝中…", + "installWithNpm": "使用 npm 安裝", "notOnPath": "在 PATH 上找不到 fn 或 fusion。", "orCopyLabel": "或自行複製並執行:", "refresh": "重新整理", @@ -1329,9 +1349,9 @@ "actionsTitle": "欄操作", "archiveAllDoneAriaLabel": "存檔所有已完成的工作", "archiveAllDoneTitle": "存檔所有已完成的工作", - "archiveAllMessage": "存檔所有 {{count}} 個已完成的工作?", + "archiveAllMessage_other": "", "archiveAllTitle": "全部存檔已完成", - "archivedTasks": "已存檔 {{count}} 個工作", + "archivedTasks_other": "", "autoMerge": "自動合併", "autoMergeDisabled": "自動合併已停用", "autoMergeEnabled": "自動合併已啟用", @@ -1342,26 +1362,28 @@ "expandArchivedTitle": "展開已存檔的工作", "failedToArchive": "存檔工作失敗", "keepProgress": "保留進度", - "loadMore": "載入 {{count}} 個更多(剩餘 {{remaining}} 個)", + "loadMore_other": "", "moveAllToTodo": "全部移至待辦", - "moveAllToTodoMessage": "將所有 {{count}} 個 {{columnLabel}} 工作{{plural}}移至待辦?", + "moveAllToTodoMessage_other": "", "moveAllToTodoTitle": "全部移至待辦", + "movedToPlanning_other": "", + "movedToTodo_other": "", "movePartialFailure": "已移動 {{total}} 個工作中的 {{moved}} 個;{{failed}} 個失敗", - "moveToTodoHint": "將 {{count}} 個工作{{plural}}移至待辦", + "moveToTodoHint_other": "", "moveToTodoPartialFailure": "已將 {{total}} 個工作中的 {{moved}} 個移至待辦;{{failed}} 個失敗", - "movedToPlanning": "已將 {{count}} 個工作{{plural}}移至規劃以重新規劃", - "movedToTodo": "已將 {{count}} 個工作{{plural}}移至待辦", "newTask": "新工作", "noManuallyPausableTasks": "沒有可手動暫停的工作", "noTasks": "沒有工作", "noTasksInColumn": "此欄中沒有工作", - "pauseHint": "暫停 {{count}} 個活動的未指派工作{{plural}}", + "pauseHint_other": "", "preserveProgressMessage": "此工作已完成步驟。在移動前保留進度?", "preserveProgressMoveTodoMessage": "某些工作已完成步驟。在移至待辦前保留進度?", "preserveProgressTitle": "保留進度?", + "promote": "", + "promoting": "", "replanAll": "全部重新規劃", - "replanAllHint": "將 {{count}} 個工作{{plural}}移至規劃", - "replanAllMessage": "將所有 {{count}} 個待辦工作{{plural}}移回規劃以重新規劃?", + "replanAllHint_other": "", + "replanAllMessage_other": "", "replanAllTitle": "重新規劃所有工作", "resetProgress": "重設進度", "resetProgressConfirm": "重設進度", @@ -1369,10 +1391,22 @@ "resetProgressMoveTodoMessage": "在移至待辦前重設工作的步驟進度?", "resetProgressTitle": "重設進度?", "stopAll": "全部停止", - "stopAllMessage": "停止所有 {{count}} 個 {{columnLabel}} 工作{{plural}}?", + "stopAllMessage_other": "", "stopAllTitle": "停止所有工作", "stopPartialFailure": "已停止 {{total}} 個工作中的 {{paused}} 個;{{failed}} 個失敗", - "stoppedTasks": "已停止 {{count}} 個工作{{plural}}" + "stoppedTasks_other": "", + "archiveAllMessage_one": "", + "archivedTasks_one": "", + "loadMore_one": "", + "moveAllToTodoMessage_one": "", + "movedToPlanning_one": "", + "movedToTodo_one": "", + "moveToTodoHint_one": "", + "pauseHint_one": "", + "replanAllHint_one": "", + "replanAllMessage_one": "", + "stopAllMessage_one": "", + "stoppedTasks_one": "" }, "comments": { "addButton": "新增評論", @@ -1387,7 +1421,8 @@ "updatedSuccess": "評論已更新" }, "commit": { - "filesChanged": "已變更檔案 ({{count}})" + "filesChanged_other": "", + "filesChanged_one": "" }, "commitDiff": { "error": "載入提交差異出錯:{{error}}", @@ -1398,6 +1433,7 @@ "noSha": "沒有可用的提交 SHA。" }, "common": { + "archive": "", "back": "返回", "cancel": "取消", "close": "關閉", @@ -1419,11 +1455,13 @@ "save": "保存", "saveAndTest": "保存並測試", "saving": "保存中…", + "skip": "", "somethingWentWrong": "載入此檢視時出錯。", "stop": "停止", "test": "測試", "testing": "測試中…", "total": "總計", + "tryAgain": "", "unableToLoadData": "無法載入資料", "unknown": "未知", "unsavedChanges": "未儲存的變更", @@ -1436,8 +1474,8 @@ "messagePlaceholder": "輸入您的訊息…", "newMessageTitle": "新訊息", "noAgentsAvailable": "沒有可用的代理", - "replyTitle": "回覆", "replyingToLabel": "正在回覆:", + "replyTitle": "回覆", "selectAgent": "選擇代理…", "sendingButton": "傳送中…", "toLabel": "收件人:", @@ -1464,30 +1502,19 @@ "createRoom": { "create": "建立房間", "creating": "建立中...", - "duplicate": "已存在同名的房間。", "failedCreate": "建立房間失敗。", "failedLoadAgents": "載入代理失敗。", "loadingAgents": "載入代理中...", - "lowercase": "只使用小寫字母。", - "maxLength": "房間名稱最多 80 個字符。", "members": "成員", "nameLabel": "房間名稱", - "nameRequired": "房間名稱為必填。", "noAgents": "此專案中尚無代理。", - "noEdgeChars": "房間名稱不能以連字號或底線開頭或結尾。", "noMatch": "沒有代理符合您的搜尋。", "searchAgents": "搜尋代理", "selectMember": "至少選擇一個成員。", - "title": "建立房間", - "validChars": "只使用小寫字母、數字、連字號或底線。" + "title": "建立房間" }, "dashboard": { "initializingDashboard": "初始化儀表板...", - "loaderSteps": { - "project": "選擇項目中", - "projects": "載入項目中", - "tasks": "獲取任務中" - }, "loadingMessage": "載入 Fusion 儀表板", "loadingProgress": "儀表板加載進度", "updatingMessage": "更新 Fusion 儀表板", @@ -1537,15 +1564,16 @@ "filterBySeverity": "按嚴重程度篩選日誌", "info": "資訊", "lines": "行", - "loadOlderLogs": "載入較早的日誌", + "lines_other": "", "loading": "載入中...", "loadingConfig": "載入開發伺服器設定...", "loadingLogs": "載入日誌中…", "loadingOlderLogs": "載入較早的日誌中…", + "loadOlderLogs": "載入較早的日誌", "logs": "日誌", "lostConnection": "日誌串流連線已斷開。", "manual": "手動", - "matchCount": "{{count}} 個比對", + "matchCount_other": "", "newLogs": "新日誌", "noLogsYet": "暫無日誌。啟動開發伺服器以查看輸出。", "noMatchesSearch": "沒有日誌行符合您的搜尋。", @@ -1593,7 +1621,9 @@ "started": "開發伺服器已啟動。", "stopped": "開發伺服器已停止。" }, - "warn": "警告" + "warn": "警告", + "lines_one": "", + "matchCount_one": "" }, "dirPicker": { "ariaLabel": "目錄瀏覽器", @@ -1712,7 +1742,7 @@ "clearSearch": "清除搜尋", "collapse": "摺疊", "collapseContent": "摺疊內容", - "docCount": "{{count}} 個文件", + "docCount_other": "", "documentsCreatedIn": "文件在工作詳細資訊標籤中建立。", "expand": "展開", "expandContent": "展開內容", @@ -1732,7 +1762,7 @@ "plain": "純文字", "projectFiles": "專案檔案", "projectFilesTab": "專案檔案", - "resultCount": "{{count}} 個結果", + "resultCount_other": "", "retry": "重試", "retryLoading": "重試載入文件", "searchProjectFiles": "搜尋專案 Markdown 檔案…", @@ -1748,7 +1778,9 @@ "taskDocuments": "工作文件", "taskDocumentsTab": "工作文件", "title": "文件", - "untitled": "未命名" + "untitled": "未命名", + "docCount_one": "", + "resultCount_one": "" }, "droidCli": { "active": "活躍", @@ -1811,28 +1843,33 @@ }, "executor": { "blocked": "已封鎖", - "daysAgo": "{{count}}天前", + "daysAgo_other": "", "escalated": "已升級", "escalatedSuffix": " (已升級)", "hideProjectDir": "隱藏專案目錄", - "hoursAgo": "{{count}}小時前", + "hoursAgo_other": "", "inReview": "審查中", "justNow": "剛剛", "loading": "載入中...", - "minutesAgo": "{{count}}分鐘前", + "minutesAgo_other": "", "noActivity": "無活動", - "overlapBottleneck": "{{status}}重疊瓶頸{{blockerId}}:{{count}}個待辦事項通過blockedBy被封鎖(閾值{{threshold}})", + "overlapBottleneck_other": "", "overlapQueue": "重疊隊列", "queued": "已排隊", "running": "執行中", - "secondsAgo": "{{count}}秒前", + "secondsAgo_other": "", "showProjectDir": "顯示專案目錄", "stateIdle": "閒置", "statePaused": "已暫停", "stateRunning": "執行中", "status": "執行器狀態", "stuck": "卡住", - "temporary": "暫時" + "temporary": "暫時", + "daysAgo_one": "", + "hoursAgo_one": "", + "minutesAgo_one": "", + "overlapBottleneck_one": "", + "secondsAgo_one": "" }, "fileBrowser": { "back": "返回檔案清單", @@ -1906,8 +1943,8 @@ "advancesHelpItem2": "reachable / subsumed / orphaned / superseded — 已處理(包括等效內容已落地、原始 SHA 已消失或 HEAD 已與重寫的整合提示對齊的歷史重寫情況)。", "advancesHelpItem3": "pending + off / not run — 設定中停用了自動同步;分支參照已移動,但工作樹未跟進。", "advancesHelpItem4": "pending + stash-failed / would-conflict / 類似情況 — 自動同步嘗試了但無法調和(通常是本機編輯與新提交衝突)。", - "advancesNeedAction": "{{count}} 個需要處理", - "aheadOfUpstream": "領先上游 {{count}} 個提交", + "advancesNeedAction_other": "", + "aheadOfUpstream_other": "", "aligned": "已對齊", "apply": "套用", "applyStashKeep": "套用儲藏(保留)", @@ -1923,7 +1960,7 @@ "backToIssuesList": "返回 Issue 清單", "backToPullsList": "返回 PR 清單", "baseHead": "基於:HEAD", - "behindUpstream": "落後上游 {{count}} 個提交", + "behindUpstream_other": "", "branchLabel": "分支:", "cancel": "取消", "capturedAt": "擷取時間:", @@ -1936,16 +1973,16 @@ "commentLast": "最後:", "commit": "提交", "commitMessagePlaceholder": "提交訊息……", - "commitStagedChanges": "提交已暫存變更", "commitsOnBranch": "{{name}} 上的提交", - "commitsToPull": "{{count}} 個待拉取", - "commitsToPush": "{{count}} 個待推送", - "commitsToPushHeader": "待推送提交({{count}})", + "commitStagedChanges": "提交已暫存變更", + "commitsToPull_other": "", + "commitsToPush_other": "", + "commitsToPushHeader_other": "", "committedHash": "已提交:{{hash}}", + "conflictedCount_other": "", "conflictReclaimFailed": "新增衝突修復任務失敗", "conflictReclaimQueued": "衝突修復任務已加入佇列", "conflictReclaimUnavailable": "衝突修復不可用", - "conflictedCount": "{{count}} 個衝突", "conflictsButton": "衝突", "copiedButton": "已複製", "copiedLabel": "已複製 {{label}}", @@ -1962,9 +1999,9 @@ "couldNotLoadIssues": "無法載入 Issue", "couldNotLoadPulls": "無法載入 PR", "create": "建立", + "createdBranch": "已建立分支 {{name}}", "createPrButton": "建立 PR", "createPrTitle": "為此任務建立 PR", - "createdBranch": "已建立分支 {{name}}", "defaultBadge": "預設", "deleteBranch": "刪除", "deleteBranchMessage": "刪除分支「{{name}}」?", @@ -1972,10 +2009,10 @@ "deletedBranch": "已刪除分支 {{name}}", "detectingRemotes": "偵測中……", "diffColon": "差異:", - "discardChangesMessage": "捨棄 {{count}} 個檔案的變更?此操作無法復原。", + "discardChangesMessage_other": "", "discardChangesTitle": "捨棄變更", + "discardedFiles_other": "", "discardSelected": "捨棄選取", - "discardedFiles": "已捨棄 {{count}} 個檔案的變更", "dismiss": "忽略", "dismissPrError": "關閉 PR 錯誤", "dropStash": "刪除暫存", @@ -2011,9 +2048,9 @@ "fetch": "擷取", "fetchCompleted": "擷取完成", "fetchFailed": "擷取失敗", + "fetchingFromGitHub": "正在從 GitHub 擷取最新清單。", "fetchLabel": "擷取:", "fetchUrlLabel": "擷取 URL", - "fetchingFromGitHub": "正在從 GitHub 擷取最新清單。", "filterBranches": "篩選分支……", "filterByLabelsLabel": "依標籤篩選", "filterByLabelsPlaceholder": "篩選:bug,enhancement……", @@ -2025,24 +2062,22 @@ "forceDeletedBranch": "已強制刪除分支 {{name}}", "fullShaAbbrev": "完整", "ghAuthLoginHint": "執行 {{code}} 以啟用 PR 建立。", - "headAheadOfIntegration": "HEAD 有 {{count}} 個提交不在 {{branch}} 上", - "headAheadOfOriginIntegration": "HEAD 有 {{count}} 個提交不在 origin/{{branch}} 上", + "headAheadOfIntegration_other": "", + "headAheadOfOriginIntegration_other": "", "headVsIntegration": "HEAD 與 {{branch}} 比較", "headVsOriginIntegration": "HEAD 與 origin/{{branch}} 比較", "hide": "隱藏", "hideExplanation": "隱藏說明", "import": "匯入", + "imported": "已匯入", + "importedCount_other": "", "importFromGitHub": "從 GitHub 匯入", "importSubtitle": "選擇偵測到的遠端,載入開放中的 Issue 或 PR,並匯入看板。", "importTypeAriaLabel": "匯入類型", - "imported": "已匯入", - "importedCount": "已匯入 {{count}} 個", - "integrationAheadOfHead": "{{branch}} 有 {{count}} 個 HEAD 沒有的提交", - "issueCount": "{{count}} 個 Issue", + "integrationAheadOfHead_other": "", + "issueCount_other": "", "load": "載入", "loadFromRepoAriaLabel": "從儲存庫載入 {{tab}}", - "loadMoreCommits": "載入更多提交", - "loadTabTitle": "載入 {{tab}}", "loading": "載入中……", "loadingAriaLabel": "正在載入 {{tab}}", "loadingCommits": "載入提交中……", @@ -2051,23 +2086,25 @@ "loadingPulls": "正在載入開放中的 PR……", "loadingStashDiff": "載入儲藏差異中……", "loadingTitle": "載入中……", - "localAheadOfOriginIntegration": "本地 {{branch}} 領先 origin/{{branch}} {{count}} 個提交", - "localBehindOriginIntegration": "本地 {{branch}} 落後 origin/{{branch}} {{count}} 個提交", + "loadMoreCommits": "載入更多提交", + "loadTabTitle": "載入 {{tab}}", + "localAheadOfOriginIntegration_other": "", + "localBehindOriginIntegration_other": "", "localVsOrigin": "本地 {{branch}} 與 origin 比較", "manualPrFlowHint": "使用底部操作為此任務執行 PR 優先完成流程。", "mergeBadge": "合併", "mergeConflictDetected": "偵測到合併衝突,請手動解決。", + "mergedTaskDone": "已合併 — 任務已移至完成", "mergeLabel": "合併", "mergePrButton": "合併 pull request", "mergeStrategyMerge": "合併", "mergeStrategyRebase": "重整基底", "mergeStrategySquash": "壓縮合併", - "mergedTaskDone": "已合併 — 任務已移至完成", "mergingPrHint": "正在合併 pull request……", "mergingStatus": "合併中……", "modalTitle": "Git 管理員", "modified": "已修改", - "modifiedCount": "{{count}} 個已修改", + "modifiedCount_other": "", "newBranchName": "新分支名稱", "noAheadCommitsFound": "未找到領先提交(可能需要先 Fetch)", "noBranchesFound": "未找到分支", @@ -2081,9 +2118,7 @@ "noMatchingBranches": "無符合的分支", "noMatchingCommits": "無符合的提交", "noOpenIssues": "找不到開放中的 Issue", - "noOpenIssuesFound": "找不到開放中的 Issue", "noOpenPulls": "找不到開放中的 PR", - "noOpenPullsFound": "找不到開放中的 PR", "noOriginTracking": "無 origin 追蹤", "noPullSelected": "未選擇 PR", "noPullSelectedHint": "從清單中選擇一個 PR 以檢視其詳細資料。", @@ -2097,14 +2132,14 @@ "noStagedChanges": "無已暫存變更", "noStagedChangesToCommit": "沒有可提交的已暫存變更", "noStashes": "無儲藏", - "noUnstagedChanges": "無未暫存變更", + "nothingLoadedInstructions": "選擇儲存庫並按一下載入,開始審閱可匯入的內容。", + "nothingLoadedYet": "尚未載入", "notOnIntegrationBranch": "(不在 {{branch}} 上)", "notOnIntegrationBranchBtn": "不在整合分支({{branch}})上", "notOnIntegrationBranchTitle": "目前位於非整合分支", - "nothingLoadedInstructions": "選擇儲存庫並按一下載入,開始審閱可匯入的內容。", - "nothingLoadedYet": "尚未載入", + "noUnstagedChanges": "無未暫存變更", "openPullsFrom": "來自 {{remote}} 的開放 PR", - "originIntegrationAheadOfHead": "origin/{{branch}} 有 {{count}} 個 HEAD 沒有的提交", + "originIntegrationAheadOfHead_other": "", "pop": "彈出", "popStashTitle": "彈出儲藏(套用並刪除)", "prAuthUnavailable": "PR 授權不可用 — 請執行 'gh auth login'", @@ -2116,34 +2151,34 @@ "summary": "{{passing}} 通過,{{failing}} 失敗,{{pending}} 待處理", "viewDetails": "查看詳情" }, - "prMergeFailed": "合併 pull request 失敗", + "previewHeading": "預覽", + "previewIssueMeta": "Issue #{{number}}", + "previewPullMeta": "PR #{{number}}", "prMerged": "Pull request 已合併", + "prMergeFailed": "合併 pull request 失敗", + "projectRootNotAvailable": "專案根目錄路徑不可用", "prRefreshFailed": "重新整理 PR 失敗", "prStatusRefreshed": "PR 狀態已更新", "prUnlinkConfirm": "取消此任務與 PR #{{number}} 的關聯?PR 不會被關閉。", "prUnlinked": "已取消關聯 PR #{{number}}", - "previewHeading": "預覽", - "previewIssueMeta": "Issue #{{number}}", - "previewPullMeta": "PR #{{number}}", - "projectRootNotAvailable": "專案根目錄路徑不可用", "pull": "Pull", "pullCompleted": "Pull 完成", - "pullCount": "{{count}} 個 PR", + "pullCount_other": "", "pullFailed": "Pull 失敗", "pullOptions": "Pull 選項", "pullOptionsMenu": "Pull 選項選單", "pullRebase": "Pull --rebase", "pullRebaseCompleted": "Pull --rebase 完成", "pullRequestHeading": "Pull Request", - "pullRequestsCount": "{{count}} 個 pull request", + "pullRequestsCount_other": "", "push": "Push", "pushCompleted": "Push 完成", "pushFailed": "Push 失敗", "pushLabel": "推送:", "pushUrlLabel": "推送 URL", - "reCheckConflicts": "重新檢查衝突", "recentCommitsOnRemote": "{{remote}} 上的近期提交", "recentIntegrationAdvances": "近期整合分支推進", + "reCheckConflicts": "重新檢查衝突", "refresh": "重新整理", "refreshPrStatus": "重新整理 PR 狀態", "refreshToCheckMerge": "重新整理 PR 狀態以檢查合併準備度", @@ -2176,24 +2211,24 @@ "sectionStashes": "儲藏", "sectionStatus": "狀態", "sectionWorktrees": "工作樹", + "selectedRemote": "所選遠端", "selectFileToViewDiff": "選取檔案以查看差異", "selectIssueAriaLabel": "選擇 Issue #{{number}}", "selectPullAriaLabel": "選擇 PR #{{number}}", "selectRemoteAriaLabel": "選擇 Git 遠端", "selectRemotePlaceholder": "選擇遠端……", "selectRemoteToViewDetails": "選擇遠端以查看詳情", - "selectedRemote": "所選遠端", "sidebarAriaLabel": "Git 管理員各區塊", "stageAll": "全部暫存", "stageAllAndCommit": "全部暫存並提交", "stageAllAndCommitTitle": "全部暫存並提交", - "stageCount": "暫存({{count}})", + "stageCount_other": "", + "staged": "已暫存", + "stagedChanges_other": "", + "stagedCount_other": "", + "stagedFiles_other": "", "stageFile": "暫存檔案", "stageSelected": "暫存選取", - "staged": "已暫存", - "stagedChanges": "已暫存變更({{count}})", - "stagedCount": "{{count}} 個已暫存", - "stagedFiles": "已暫存 {{count}} 個檔案", "staleIndexWarning": "偵測到過時的索引。 HEAD 已前進(通常是因為 Fusion 的合併器更新了整合分支參照),但索引仍反映之前的提示 — `git status` 會將新提交反向顯示為「已暫存的變更」。在設定中啟用 mergeAdvanceAutoSync 讓合併器自動調和,或執行 git reset --hard HEAD 手動追上。", "stash": "儲藏", "stashApplied": "已套用儲藏", @@ -2220,17 +2255,17 @@ "statusLabelWorkingTree": "工作樹", "switchedToBranch": "已切換至 {{name}}", "sync": "同步", + "synced": "已同步", + "syncedWithOrigin": "已與 origin 同步(pull --rebase + push)", + "syncedWorktreeToIntegrationTip": "已將工作樹同步到本地整合分支頂端", "syncFailed": "同步失敗", + "syncing": "同步中……", "syncLocalTip": "同步本地頂端", "syncLocalTipTitle": "將工作樹同步到本地整合分支頂端(與橫幅 Pull 相同)", "syncOriginTitle": "從 origin pull --rebase,然後推送目前分支", "syncWithOriginFailed": "與 origin 同步失敗", "syncWorkingTree": "同步工作樹", "syncWorkingTreeTitle": "將整合分支拉取到工作樹(自動儲藏未提交的編輯並還原)", - "synced": "已同步", - "syncedWithOrigin": "已與 origin 同步(pull --rebase + push)", - "syncedWorktreeToIntegrationTip": "已將工作樹同步到本地整合分支頂端", - "syncing": "同步中……", "tabIssues": "Issues", "tabPullRequests": "Pull Requests", "tip": "最新", @@ -2239,14 +2274,14 @@ "unlinkButton": "取消關聯", "unresolvedMergeConflicts": "未解決的合併衝突", "unstageAll": "全部取消暫存", - "unstageCount": "取消暫存({{count}})", + "unstageCount_other": "", + "unstaged": "未暫存", + "unstagedChanges_other": "", + "unstagedFiles_other": "", "unstageFile": "取消暫存檔案", "unstageSelected": "取消暫存選取", - "unstaged": "未暫存", - "unstagedChanges": "未暫存變更({{count}})", - "unstagedFiles": "已取消暫存 {{count}} 個檔案", "untracked": "未追蹤", - "untrackedCount": "{{count}} 個未追蹤", + "untrackedCount_other": "", "upToDate": "已是最新", "view": "查看", "viewOnGithub": "在 GitHub 上檢視", @@ -2256,18 +2291,48 @@ "workingTreeModified": "已修改", "worktreeBadgeBare": "裸庫", "worktreeBadgeMain": "主", - "worktreesInUse": "{{count}} 個使用中", - "worktreesTotal": "共 {{count}} 個" + "worktreesInUse_other": "", + "worktreesTotal_other": "", + "advancesNeedAction_one": "", + "aheadOfUpstream_one": "", + "behindUpstream_one": "", + "commitsToPull_one": "", + "commitsToPush_one": "", + "commitsToPushHeader_one": "", + "conflictedCount_one": "", + "discardChangesMessage_one": "", + "discardedFiles_one": "", + "headAheadOfIntegration_one": "", + "headAheadOfOriginIntegration_one": "", + "importedCount_one": "", + "integrationAheadOfHead_one": "", + "issueCount_one": "", + "localAheadOfOriginIntegration_one": "", + "localBehindOriginIntegration_one": "", + "modifiedCount_one": "", + "originIntegrationAheadOfHead_one": "", + "pullCount_one": "", + "pullRequestsCount_one": "", + "stageCount_one": "", + "stagedChanges_one": "", + "stagedCount_one": "", + "stagedFiles_one": "", + "unstageCount_one": "", + "unstagedChanges_one": "", + "unstagedFiles_one": "", + "untrackedCount_one": "", + "worktreesInUse_one": "", + "worktreesTotal_one": "" }, "goals": { - "activeCount": "{{count}} 個活躍目標", + "activeCount_other": "", "addGoal": "新增目標", "archive": "封存", "capError": "無法激活超過 5 個目標。激活另一個之前,請解決一個活躍目標。", "capWarning": "接近 5 個活躍目標上限。保持活躍目標集中。", "createError": "現在無法建立目標。請重試。", - "draftWithAi": "使用 AI 起草", "drafting": "起草中…", + "draftWithAi": "使用 AI 起草", "emptyState": "還沒有目標。新增一個以開始追蹤策略結果。", "labelDescription": "說明", "labelTitle": "標題", @@ -2278,9 +2343,11 @@ "title": "目標", "titleRequired": "標題為必需。", "unarchive": "取消封存", - "updateError": "現在無法更新目標狀態。請重試。" + "updateError": "現在無法更新目標狀態。請重試。", + "activeCount_one": "" }, "groupTask": { + "abandonGroup": "", "ariaLabel": "分支群組詳情", "autoMergeEnabled": "自動合併已啟用", "completionText": "{{landed}} 位成員中有 {{total}} 位已完成", @@ -2290,12 +2357,15 @@ "mergeIntoMain": "將群組合併到主分支", "openPR": "開啟 PR", "openTask": "開啟工作", + "prClosed": "", + "prMerged": "", "sharedBranch": "共用分支", "status": "狀態", "title": "分支群組 {{id}}", "unavailable": "分支群組不可用" }, "header": { + "activePlanningSessions_other": "", "addFirstScript": "新增第一個指令碼", "additionalHeaderActions": "更多頁首動作", "agentsView": "代理人檢視", @@ -2321,7 +2391,7 @@ "localNode": "本機", "mailbox": "信箱", "mailboxView": "信箱檢視", - "mailboxWithCount": "信箱({{count}})", + "mailboxWithCount_other": "", "manageProjects": "管理專案", "manageScripts": "管理指令碼…", "memoryView": "記憶", @@ -2330,10 +2400,10 @@ "moreHeaderActions": "更多頁首動作", "moreViews": "更多檢視", "noBaseBranch": "無基礎分支", + "nodes": "節點", "noScriptsAddOne": "尚無指令碼,請新增…", "noScriptsConfigured": "未設定指令碼", "noWorkingBranch": "無工作分支", - "nodes": "節點", "openSearch": "開啟搜尋", "openTerminal": "開啟終端機", "pauseTriage": "暫停分流", @@ -2343,7 +2413,7 @@ "reliabilityView": "可靠性", "researchView": "研究", "resumePlanningSession": "恢復規劃工作階段", - "resumePlanningSessionCount": "恢復規劃工作階段({{count}})", + "resumePlanningSessionCount_other": "", "resumeScheduling": "恢復排程", "scripts": "指令碼", "scriptsSubmenu": "指令碼子選單", @@ -2364,21 +2434,19 @@ "terminal": "終端機", "todosView": "待辦事項", "unreadChatResponse": "未讀聊天回覆", - "unreadMessages": "{{count}} 則未讀訊息", + "unreadMessages_other": "", "viewActivityLog": "查看活動記錄", "viewProjects": "查看專案", "viewUsage": "查看用量", "workflowSteps": "工作流程步驟", - "workingBranch": "工作分支" + "workingBranch": "工作分支", + "activePlanningSessions_one": "", + "mailboxWithCount_one": "", + "resumePlanningSessionCount_one": "", + "unreadMessages_one": "" }, "health": { "activeTasks": "活躍任務", - "anomaly": { - "duplicateActiveId": "重複的活躍任務 ID", - "idInBothStorages": "任務 ID 同時出現在活躍和封存儲存體中", - "sequenceOverlap": "分配器下一個序列與現有任務 ID 重疊", - "unknownPrefix": "任務列使用分配器狀態外的字首" - }, "anomalyBody": "Fusion 發現分配器狀態可能導致任務 ID 被重複使用或覆寫活躍任務記錄。", "anomalyDetected": "偵測到任務 ID 完整性異常", "completed": "已完成", @@ -2436,26 +2504,22 @@ "collapse": "折疊", "collapseDescription": "折疊說明", "collapseTaskOptions": "折疊進階工作選項", - "connecting": "連接中", "creating": "正在建立...", "custom": "自訂", "deps": "相依性", "editingDescription": "編輯說明", "enableBrowserVerification": "啟用瀏覽器驗證工作流程步驟", "enterDescriptionFirst": "先輸入說明", - "error": "錯誤", "expand": "展開", "expandDescription": "展開說明", "expandTaskOptions": "展開進階工作選項", "hintEnterEsc": "按 Enter 建立 · Esc 取消", "loadingAgents": "正在加載代理...", - "model": "模型", + "model_other": "", "models": "模型", "noAgentsAvailable": "沒有可用的代理", - "noExistingTasks": "沒有現有工作", "node": "節點", - "offline": "離線", - "online": "線上", + "noExistingTasks": "沒有現有工作", "openPlanningMode": "以目前說明開啟規劃模式", "plan": "計劃", "preset": "預設", @@ -2469,45 +2533,29 @@ "selectExecutionNode": "選擇執行節點", "subtask": "子工作", "useDefault": "使用預設值", - "whatNeedsToBeDone": "需要做什麼?" + "whatNeedsToBeDone": "需要做什麼?", + "model_one": "" }, "insights": { "allInsights": "所有洞察", "alreadyRunning": "洞察生成已在執行。顯示活動執行。", "alreadyRunningShort": "洞察生成已在執行", - "archiveLabel": "存檔此洞察", - "archiveTitle": "存檔此洞察", "archived": "已存檔\"{{title}}\"", "archivedMsg": "已存檔的洞察:{{title}}", + "archiveLabel": "存檔此洞察", + "archiveTitle": "存檔此洞察", "archiving": "正在存檔\"{{title}}\"...", "backlogHealth": "待辦事項健康狀況", - "category": { - "architecture": "架構", - "competitive_analysis": "競爭分析", - "dependency": "相依性", - "documentation": "文件", - "features": "功能", - "other": "其他", - "performance": "效能", - "quality": "品質", - "reliability": "可靠性", - "research": "研究", - "security": "安全性", - "testability": "可測試性", - "trends": "趨勢", - "ux": "使用者體驗", - "workflow": "工作流程" - }, "configureModel": "設定洞察生成模型", "configureModelTitle": "設定模型", "createTaskLabel": "從此洞察建立任務", "createTaskTitle": "從此洞察建立任務", "creatingTask": "正在從\"{{title}}\"建立任務...", - "dismissLabel": "關閉此洞察", - "dismissTitle": "關閉此洞察", "dismissed": "已關閉\"{{title}}\"", "dismissedMsg": "已關閉的洞察:{{title}}", "dismissing": "正在關閉\"{{title}}\"...", + "dismissLabel": "關閉此洞察", + "dismissTitle": "關閉此洞察", "failedToArchive": "存檔洞察失敗", "failedToCreateTask": "建立任務失敗", "failedToDismiss": "關閉洞察失敗", @@ -2516,6 +2564,7 @@ "failedToUnarchive": "取消存檔洞察失敗", "generateDescription": "生成洞察以獲得專案的 AI 驅動建議。", "generateFirst": "生成第一個洞察", + "generateInsights": "", "generateInsightsBtn": "生成洞察", "generating": "生成中...", "generatingInsights": "生成洞察中...", @@ -2531,18 +2580,19 @@ "runCompleted": "已建立 {{created}} 個,已更新 {{updated}} 個", "showAllInsights": "顯示所有洞察", "showArchived": "顯示已存檔的洞察", - "showArchivedLabel": "顯示已存檔 ({{count}})", + "showArchivedLabel_other": "", "showBacklogHealth": "僅顯示待辦事項健康狀況洞察", "taskCreated": "從\"{{title}}\"建立的任務", "taskCreatedMsg": "已建立的任務:{{title}}", "taskCreationUnavailable": "在此檢視中無法建立任務", "title": "洞察", - "unarchiveLabel": "取消存檔此洞察", - "unarchiveTitle": "取消存檔此洞察", "unarchived": "已取消存檔\"{{title}}\"", "unarchivedMsg": "已取消存檔的洞察:{{title}}", + "unarchiveLabel": "取消存檔此洞察", + "unarchiveTitle": "取消存檔此洞察", "unarchiving": "正在取消存檔\"{{title}}\"...", - "usePlanningDefault": "使用規劃預設值" + "usePlanningDefault": "使用規劃預設值", + "showArchivedLabel_one": "" }, "interview": { "addContextDirection": "添加任何額外的上下文或方向...", @@ -2570,8 +2620,8 @@ "preparingQuestion": "準備下一個問題...", "progressText": "問題{{progress}}/約6個", "reconnecting": "重新連接中…", - "refineScope": "用AI精煉{{label}}範圍", "refinedScope": "精煉範圍", + "refineScope": "用AI精煉{{label}}範圍", "sendToBackground": "傳送到背景", "sessionActiveAnotherTab": "工作階段在另一個標籤頁中處於活躍狀態。", "showThinking": "顯示思考", @@ -2586,6 +2636,10 @@ "verificationCriteria": "驗證標準", "yes": "是" }, + "lane": { + "collapse": "", + "expand": "" + }, "listView": { "apply": "套用", "applying": "套用中…", @@ -2593,16 +2647,15 @@ "archiveSelectedTitle": "封存選取的已完成任務", "archiveUnavailable": "封存操作無法使用", "archiveViaButton": "任務只能透過封存按鈕封存", - "bulkArchiveDone": "封存 {{count}} 個已完成", - "bulkArchiveMessage": "封存 {{count}} 個選取的任務?", + "bulkArchiveDone_other": "", + "bulkArchiveMessage_other": "", "bulkArchiveNoTasks": "沒有可以封存的選取任務(只有已完成的任務)", "bulkArchiveSummary": "已封存 {{archived}} · {{skipped}} 已略過 · {{failed}} 失敗", "bulkArchiveTitle": "封存選取的任務", "bulkDeleteAll": "全部刪除", "bulkDeleteArchiveSummary": "已封存 {{archived}},已刪除 {{deleted}},失敗 {{failed}}", - "bulkDeleteMessage": "刪除 {{count}} 個選取的任務?", + "bulkDeleteMessage_other": "", "bulkDeleteNoTasks": "沒有可刪除的選取任務(已封存的任務除外)", - "bulkDeleteSummary": "已刪除 {{deleted}} 個任務 · {{skipped}} 個已封存已略過 · {{failed}} 個失敗", "bulkDeleteSummary_one": "已刪除 {{count}} 個任務 · 跳過 {{skipped}} 個已封存 · {{failed}} 個失敗", "bulkDeleteSummary_other": "已刪除 {{count}} 個任務 · 跳過 {{skipped}} 個已封存 · {{failed}} 個失敗", "bulkDeleteTitle": "刪除選取的任務", @@ -2616,7 +2669,7 @@ "bulkUnpauseSummary": "已恢復 {{unpaused}} · {{skipped}} 已略過 · {{failed}} 失敗", "bulkUpdateFailed": "更新模型失敗", "bulkUpdateNoTasks": "沒有可更新的有效任務(已封存的任務無法修改)", - "bulkUpdateSuccess": "已更新 {{count}} 個任務", + "bulkUpdateSuccess_other": "", "cancelMove": "取消移動", "clear": "清除", "clearColumnFilter": "清除欄篩選器", @@ -2636,7 +2689,7 @@ "filterChip": "篩選:{{column}}", "forceDelete": "強制刪除", "forceDeleteTitle": "強制刪除任務", - "hidden": "已隱藏 {{count}}", + "hidden_other": "", "hideDone": "隱藏已完成", "hideDoneTitle": "隱藏已完成的任務", "keepProgress": "保留進度", @@ -2646,18 +2699,18 @@ "listControlsLabel": "清單控制項", "newTask": "+ 新增任務", "noChange": "不更改", - "noTasks": "暫無任務", - "noTasksMatch": "沒有任務符合您的篩選條件", - "noTasksYet": "尚無任務", "nodeOverrideLabel": "節點覆寫", "nodeStatusConnecting": "連接中", "nodeStatusError": "錯誤", "nodeStatusOffline": "離線", "nodeStatusOnline": "線上", + "noTasks": "暫無任務", + "noTasksMatch": "沒有任務符合您的篩選條件", + "noTasksYet": "尚無任務", + "pausedByAgent": "已被代理暫停", "pauseSelected": "暫停選取", "pauseSelectedTitle": "暫停所有未暫停的選取任務", "pauseUnavailable": "暫停操作無法使用", - "pausedByAgent": "已被代理暫停", "preserveProgressMessage": "此任務有已完成的步驟。移動前保留進度?", "preserveProgressTitle": "保留進度?", "resetProgress": "重設進度", @@ -2666,9 +2719,9 @@ "resizeSidebar": "調整任務清單側邊欄大小", "reviewerModel": "審查器模型", "selectAll": "選取所有可見任務", + "selectedCount_other": "", "selectTask": "選取 {{taskId}}", "selectTaskPrompt": "選取一個任務以查看詳情", - "selectedCount": "已選取 {{count}}", "showAll": "顯示全部", "showAllTitle": "顯示所有任務", "showDone": "顯示已完成", @@ -2677,8 +2730,8 @@ "staleOnlyTitle": "僅顯示過期任務", "stalePausedReview": "過期暫停審核", "stalePausedReviewTitle": "僅顯示過期暫停審核任務", - "stats": "{{total}} 個任務中的 {{count}} 個", - "statsInColumn": "{{column}} 中 {{total}} 個任務裡的 {{count}} 個", + "stats_other": "", + "statsInColumn_other": "", "statusMergingFix": "合併修復中…", "stuck": "卡住", "taskCreationUnavailable": "任務建立無法使用", @@ -2686,12 +2739,18 @@ "unpauseSelectedTitle": "恢復目前已暫停的選取任務", "unpauseUnavailable": "恢復操作無法使用", "useProjectDefault": "使用專案預設", - "viewOptions": "檢視選項" + "viewOptions": "檢視選項", + "bulkArchiveDone_one": "", + "bulkArchiveMessage_one": "", + "bulkDeleteMessage_one": "", + "bulkUpdateSuccess_one": "", + "hidden_one": "", + "selectedCount_one": "", + "stats_one": "", + "statsInColumn_one": "" }, "mailbox": { "agent": "代理", - "agentById": "代理人:{{id}}", - "agentByName": "代理人:{{name}}", "agents": "代理", "agentsTab": "代理", "ago": "前", @@ -2702,8 +2761,8 @@ "approvalDeny": "拒絕", "approvalRequested": "請求時間", "approvalRequester": "請求者", - "approvalTask": "任務", "approvals": "批准", + "approvalTask": "任務", "back": "返回", "backButton": "← 返回", "closeAriaLabel": "關閉", @@ -2732,8 +2791,8 @@ "markAllRead": "全部已讀", "markAllReadButton": "全部標記為已讀", "markAllReadTitle": "全部標記為已讀", + "markedAsRead_other": "", "markReadFailed": "無法將訊息標記為已讀", - "markedAsRead": "標記 {{count}} 條訊息為已讀", "messageDeleted": "訊息已刪除", "messageSent": "訊息已發送", "noAgentMessages": "沒有代理間訊息", @@ -2753,15 +2812,15 @@ "refreshTitle": "重新整理", "reply": "回覆", "replyButton": "回復", - "replyLoadFailed": "載入回覆訊息失敗。點擊重試。", "replyingTo": "回復 {{preview}}", "replyingToMessage": "回覆訊息", + "replyLoadFailed": "載入回覆訊息失敗。點擊重試。", "selectMessageToRead": "選擇要閱讀的訊息", "system": "系統", - "timeDaysAgo": "{{count}} 天前", - "timeHoursAgo": "{{count}} 小時前", + "timeDaysAgo_other": "", + "timeHoursAgo_other": "", "timeJustNow": "剛剛", - "timeMinsAgo": "{{count}} 分鐘前", + "timeMinsAgo_other": "", "title": "郵箱", "to": "至", "toLabel": "至:", @@ -2771,8 +2830,11 @@ "typeSystem": "系統", "typeUserToAgent": "你 → 代理", "user": "使用者", - "userLabel": "使用者:{{id}}", - "you": "你" + "you": "你", + "markedAsRead_one": "", + "timeDaysAgo_one": "", + "timeHoursAgo_one": "", + "timeMinsAgo_one": "" }, "memory": { "auditChecksTitle": "稽核檢查", @@ -2789,32 +2851,32 @@ "capReadable": "可讀", "capWritable": "可寫", "categories": "分類", - "charCount": "{{count}} 個字元", + "charCount_other": "", "compactFailed": "壓縮記憶失敗", - "compactSelectedFile": "壓縮所選檔案", "compacting": "正在壓縮…", "compactionThresholdHint": "當記憶超過此字元數時將自動壓縮", "compactionThresholdLabel": "壓縮閾值(字元數)", + "compactSelectedFile": "壓縮所選檔案", "currentBackendTitle": "目前後端", "description": "工作記憶、長期洞察與引擎狀態", "disabledMessage": "記憶目前已停用。請在設定中啟用記憶工具以編輯這些自動化。", + "dreaming": "正在夢境處理…", "dreamNow": "立即處理夢境", "dreamNowHint": "立即手動觸發夢境處理。", "dreamProcessingComplete": "夢境處理已完成", "dreamProcessingFailed": "執行夢境處理失敗", - "dreaming": "正在夢境處理…", "dreamsEnabledHint": "將每日筆記轉換為 DREAMS.md,並將可重用的經驗提升至 MEMORY.md。", "dreamsEnabledLabel": "從每日記憶中處理夢境", "dreamsScheduleHint": "夢境處理的 Cron 表達式。", "dreamsScheduleLabel": "夢境排程", - "editRaw": "編輯原始內容", "editorDefaultDescription": "編輯所選記憶檔案。", "editorLabel": "記憶編輯器", - "extractInsightsFailed": "提取洞察失敗", - "extractNow": "立即提取", + "editRaw": "編輯原始內容", "extracting": "正在提取…", + "extractInsightsFailed": "提取洞察失敗", "extractionFailed": "失敗", "extractionSuccess": "成功", + "extractNow": "立即提取", "fileCompacted": "記憶檔案已壓縮", "fileLabel": "記憶檔案", "fileSummary": "{{size}} 位元組 · 更新於 {{updatedAt}}", @@ -2824,13 +2886,13 @@ "healthIssues": "發現問題", "healthStatusTitle": "健康狀態", "healthWarning": "警告", - "insightCount": "{{count}} 條洞察", - "insightsExtracted": "已提取 {{count}} 條洞察", + "insightCount_other": "", + "insightsExtracted_other": "", "insightsMemoryLabel": "洞察記憶", "insightsSaved": "洞察已儲存", + "installing": "正在安裝…", "installQmd": "安裝 qmd", "installQmdFailed": "安裝 qmd 失敗", - "installing": "正在安裝…", "lastExtractionLabel": "最後提取", "lastUpdated": "最後更新", "layerDaily": "每日", @@ -2854,9 +2916,9 @@ "qmdAvailableOnPath": "qmd 已在 PATH 中可用。", "qmdChecking": "檢查中", "qmdCheckingAvailability": "正在檢查 qmd 是否可用…", + "qmdInstalled": "已安裝", "qmdInstallSuccess": "qmd 安裝成功", "qmdInstallUnavailable": "qmd 安裝已完成,但 qmd 仍不可用", - "qmdInstalled": "已安裝", "qmdIntegrationTitle": "QMD 整合", "qmdNotInstalled": "qmd 未安裝。搜尋將使用本地檔案。安裝索引檢索:", "qmdPathUsed": "已使用 qmd 路徑", @@ -2875,7 +2937,7 @@ "saveSettingsFailed": "儲存記憶設定失敗", "saving": "正在儲存…", "searchPlaceholder": "使用 qmd 搜尋記憶", - "sectionCount": "{{count}} 個章節", + "sectionCount_other": "", "settingsNote": "注意:在以下位置變更後端類型:", "settingsNoteLink": "設定 → 記憶", "settingsNoteToast": "開啟「設定 → 記憶」以變更後端類型", @@ -2884,15 +2946,20 @@ "tabEngines": "引擎", "tabInsights": "洞察", "tabWorking": "工作記憶", + "testing": "正在測試…", "testMemorySearchTitle": "測試記憶搜尋", - "testResultCount": "「{{query}}」的 {{count}} 個結果", + "testResultCount_other": "", "testResultStatus": "qmd {{qmdStatus}} · {{fallbackStatus}}", "testRetrieval": "測試檢索", "testSearchHint": "執行與代理使用的相同 qmd 支援的 memory_search 路徑。", - "testing": "正在測試…", "title": "記憶", "totalInsights": "洞察總數", - "workingMemoryLabel": "工作記憶" + "workingMemoryLabel": "工作記憶", + "charCount_one": "", + "insightCount_one": "", + "insightsExtracted_one": "", + "sectionCount_one": "", + "testResultCount_one": "" }, "merge": { "advanced": "進階", @@ -2913,16 +2980,16 @@ "pr": "PR", "pulling": "拉取中…", "pushForceWithLease": "推送 (force-with-lease)", - "pushHeading": "推送 {{branch}} 到源 — 領先 {{count}} 筆提交{{plural}}。", + "pushHeading_other": "", + "pushing": "推送中…", "pushSuccess": "已推送到 origin/{{branch}} @ {{sha}}。", "pushToOrigin": "推送到源", - "pushing": "推送中…", "recordedNoConfirm": "已記錄,但未進行本機合併確認", "shortstatTitle": "最終提交簡統計;如需查看所有工作提交的完整著陸差異,請參閱「變更」分頁。", "smartPull": "智慧拉取", "status": "狀態", "title": "合併詳情", - "unknown": "未知" + "pushHeading_one": "" }, "mesh": { "ariaLabel": "節點網格拓撲可視化", @@ -2947,23 +3014,23 @@ "addAssertion": "新增斷言", "addContext": "添加任何額外的背景或方向...", "addFeature": "添加功能", + "additionalComments": "其他評論(選擇性)", "addMilestone": "新增里程碑", "addSlice": "新增切片", - "additionalComments": "其他評論(選擇性)", "aiThinking": "AI 正在思考...", "aiValidatedAtRuntime": "執行時由 AI 驗證", "aiValidatedMissionGate": "AI 驗證的任務門控", "allFeaturesLinked": "所有功能已關聯", "approvePlan": "批准計畫", - "assertionCreateFailed": "建立斷言失敗", "assertionCreated": "斷言已建立", + "assertionCreateFailed": "建立斷言失敗", "assertionFieldsRequired": "標題和斷言文字不能為空", "assertionTextEditPlaceholder": "斷言文字", "assertionTextPlaceholder": "斷言文字(完成時應為真的內容)", "assertionTitlePlaceholder": "斷言標題", - "assertionUpdateFailed": "更新斷言失敗", "assertionUpdated": "斷言已更新", - "attemptRetries": "第 {{attempt}} 次嘗試 · 剩餘 {{count}} 次{{label}}", + "assertionUpdateFailed": "更新斷言失敗", + "attemptRetries_other": "", "autopilotActivatingSlice": "正在啟動切片", "autopilotCompleting": "完成中", "autopilotDescription": "開啟後,Fusion 會在工作完成時自動啟動下一個切片並規劃其功能。", @@ -2973,11 +3040,6 @@ "autopilotLabel": "自動駕駛", "autopilotLastActivation": "上次啟用 {{time}}", "autopilotOff": "關閉", - "autopilotStateActivating": "正在啟用切片", - "autopilotStateCompleting": "正在完成", - "autopilotStateInactive": "關閉", - "autopilotStateUnknown": "未知", - "autopilotStateWatching": "監視中", "autopilotUpdateFailed": "更新自動駕駛失敗", "autopilotWatching": "自動駕駛監控中", "autopilotWatchingSince": "自 {{time}} 起監視", @@ -3009,20 +3071,20 @@ "confirmSlicePlaceholder": "如何確認該切片已完成...", "contractAssertions": "合約斷言(AI 驗證)", "createButton": "建立", - "createTask": "建立任務", "created": "任務已建立", "createdFromInterview": "任務已從 AI 訪談建立", + "createTask": "建立任務", "creatingMission": "正在建立任務...", "defaultInterviewTitle": "任務訪談", "deleteAssertion": "刪除斷言", "deleteButton": "刪除", "deleteConfirm": "刪除此 {{type}}?此操作無法復原。", + "deleted": "任務已刪除", "deleteFailed": "刪除任務失敗", "deleteFeature": "刪除功能", "deleteMilestone": "刪除里程碑", "deleteMission": "刪除任務", "deleteSlice": "刪除切片", - "deleted": "任務已刪除", "describeGoal": "描述您想要建置的東西。AI 將採訪您以了解範圍、約束條件和需求,然後產生一個包含里程碑、切片和功能的結構化計畫。", "descriptionLabel": "任務描述", "descriptionOptional": "說明(選填)", @@ -3047,23 +3109,23 @@ "failedLoadModels": "無法載入模型", "featureCreated": "功能已建立", "featureCriteriaAwaitingSync": "等待斷言同步的功能標準", - "featureDeleteFailed": "刪除功能失敗", "featureDeleted": "功能已刪除", - "featureLinkFailed": "連結功能失敗", - "featureLinkTaskFailed": "將功能連結到任務失敗", + "featureDeleteFailed": "刪除功能失敗", "featureLinkedToAssertion": "功能已連結到斷言", "featureLinkedToTask": "功能已連結到任務", + "featureLinkFailed": "連結功能失敗", + "featureLinkTaskFailed": "將功能連結到任務失敗", "featureSaveFailed": "儲存功能失敗", + "featuresCount_other": "", "featureTitlePlaceholder": "功能標題", "featureTitleRequired": "功能標題不能為空", - "featureTriageFailed": "分類功能失敗", "featureTriaged": "功能已分類 — 任務已建立", - "featureUnlinkFailed": "取消功能連結失敗", - "featureUnlinkFromAssertionFailed": "取消功能連結失敗", + "featureTriageFailed": "分類功能失敗", "featureUnlinkedFromAssertion": "功能已從斷言取消連結", "featureUnlinkedFromTask": "功能已從任務取消連結", + "featureUnlinkFailed": "取消功能連結失敗", + "featureUnlinkFromAssertionFailed": "取消功能連結失敗", "featureUpdated": "功能已更新", - "featuresCount": "{{count}} 個功能", "filterAll": "所有事件", "filterAutopilot": "自動駕駛事件", "filterErrors": "錯誤和警告", @@ -3074,9 +3136,6 @@ "generatedFixFeatures": "已生成修復功能:", "generatedFixFeaturesTitle": "已生成修復功能", "generatedFromFeature": "從功能生成:{{id}}", - "helperTextActive": "停止將暫停關聯任務並將任務標記為已阻塞。", - "helperTextBlocked": "恢復將重新啟動任務並繼續執行。", - "helperTextPlanning": "啟動將啟動第一個切片以便工作開始。", "hideDetails": "隱藏詳情", "hideMetadata": "隱藏中繼資料", "hideThinking": "隱藏思考", @@ -3090,42 +3149,36 @@ "interviewErrored": "訪談遇到錯誤。從此列表項重試。", "interviewGenerating": "正在從訪談上下文生成任務層次結構。", "interviewInProgress": "訪談進行中", - "interviewStatusAwaitingInput": "等待輸入", - "interviewStatusComplete": "計畫已就緒", - "interviewStatusError": "需要重試", - "interviewStatusGenerating": "正在生成計畫", - "interviewStatusNeedsRetry": "需要重試", - "interviewStatusPlanReady": "計畫已就緒", "interviewWaiting": "訪談正在等待您的下一個回覆。", "lastValidatorStatus": "最近 {{status}}", "linkAFeature": "關聯功能", "linkButton": "連結", - "linkFeatureButton": "關聯功能", - "linkFeatureToTask": "將功能連結到任務:", - "linkToTask": "連結到任務", - "linkedCount": "{{count}} 個已關聯", - "linkedFeaturesCount": "{{count}} 個已關聯功能", + "linkedCount_other": "", + "linkedFeaturesCount_other": "", "linkedFeaturesLabel": "關聯功能", "linkedGoals": "關聯目標", "linkedGoalsTitle": "關聯目標", + "linkFeatureButton": "關聯功能", + "linkFeatureToTask": "將功能連結到任務:", + "linkToTask": "連結到任務", "loadActivityFailed": "載入任務活動失敗", "loadDetailFailed": "載入任務詳情失敗", "loadFailed": "載入任務失敗", - "loadMore": "載入更多", "loadingActivity": "正在載入任務活動…", "loadingMissionDetails": "正在載入任務詳情…", "loadingMissions": "正在載入任務…", "loadingModels": "正在載入模型…", + "loadMore": "載入更多", "loopState": "迴圈狀態:{{state}}", "milestoneCreated": "里程碑已建立", - "milestoneDeleteFailed": "刪除里程碑失敗", "milestoneDeleted": "里程碑已刪除", + "milestoneDeleteFailed": "刪除里程碑失敗", "milestoneDescriptionPlaceholder": "里程碑描述...", "milestoneSaveFailed": "儲存里程碑失敗", + "milestonesCount_other": "", "milestoneTitlePlaceholder": "里程碑標題", "milestoneTitleRequired": "里程碑標題不能為空", "milestoneUpdated": "里程碑已更新", - "milestonesCount": "{{count}} 個里程碑", "missionHealthAriaLabel": "任務健康狀態:{{state}}", "missionInterviewInProgressDesc": "任務訪談仍在進行中。開啟此任務以繼續規劃。", "missionList": "任務列表", @@ -3143,44 +3196,41 @@ "noMilestonesYet": "暫無里程碑。新增一個以開始。", "noMissionsYetBody": "任務是將里程碑、切片和功能整合到單一計畫中的大型計畫。規劃一個任務,將目標端到端分解,讓代理以自動駕駛方式執行。", "noMissionsYetTitle": "暫無任務", + "none": "無", "noSlicesYet": "暫無切片", "noValidationRunsYet": "暫無驗證執行。", - "none": "無", "openMissionAriaLabel": "開啟任務 {{title}}", "orSelect": "或選擇:", "planMilestone": "規劃里程碑", "planNewMission": "規劃新任務", + "planningModel": "規劃模型", "planReady": "任務計畫已準備好", "planSlice": "規劃切片", "planStateNeedsUpdate": "需要更新", "planStateNotPlanned": "未規劃", "planStatePlanned": "已規劃", "planTitle": "用 AI 規劃任務", - "planningModel": "規劃模型", "prepareQuestion": "準備下一個問題...", - "progressText": "第 {{count}} 個問題,共 ~6 個", + "progressText_other": "", "reconnecting": "正在重新連接…", - "relativeTimeDays": "{{count}} 天前", - "relativeTimeHours": "{{count}} 小時前", + "relativeTimeDays_other": "", + "relativeTimeHours_other": "", "relativeTimeJustNow": "剛剛", - "relativeTimeMinutes": "{{count}} 分鐘前", + "relativeTimeMinutes_other": "", "removeFeature": "刪除功能", "removeMilestone": "刪除里程碑", "removeSlice": "刪除切片", "resizeSidebar": "調整任務側欄大小", + "resumed": "任務已恢復", "resumeFailed": "恢復任務失敗", "resumeInterviewAriaLabel": "恢復訪談 {{title}}", "resumeMission": "恢復任務", - "resumed": "任務已恢復", "retries": "次重試", "retry": "次重試", "retryBudgetTitle": "實作嘗試次數和剩餘重試次數", "retrying": "正在重試...", "roadmapLabel": "路線圖", "run": "執行:", - "runHelperActive": "停止將暫停關聯任務並將任務標記為已封鎖。", - "runHelperBlocked": "繼續將重新啟用任務並繼續執行。", - "runHelperPlanning": "啟動後將啟用第一個切片以開始工作。", "runSettings": "任務執行設定", "runSettingsTitle": "任務執行設定", "saveButton": "儲存", @@ -3194,25 +3244,25 @@ "showMetadata": "顯示中繼資料", "showThinking": "顯示思考", "showValidationRounds": "顯示驗證輪次", - "sliceActivateFailed": "啟動切片失敗", "sliceActivated": "切片已啟動", + "sliceActivateFailed": "啟動切片失敗", "sliceCreated": "切片已建立", - "sliceDeleteFailed": "刪除切片失敗", "sliceDeleted": "切片已刪除", + "sliceDeleteFailed": "刪除切片失敗", "sliceSaveFailed": "儲存切片失敗", + "slicesCount_other": "", "sliceTitlePlaceholder": "切片標題", "sliceTitleRequired": "切片標題不能為空", + "sliceTriaged_other": "", "sliceTriageFailed": "分類切片功能失敗", - "sliceTriaged": "已分類 {{count}} 個功能", "sliceUpdated": "切片已更新", "sliceVerification": "切片驗證", - "slicesCount": "{{count}} 個切片", "source": "來源:", + "started": "任務已啟動 — 第一個切片已啟動", "startFailed": "啟動任務失敗", "startInterview": "開始採訪", "startMission": "啟動任務", "startOver": "重新開始", - "started": "任務已啟動 — 第一個切片已啟動", "statusActive": "進行中", "statusArchived": "已封存", "statusBlocked": "已阻塞", @@ -3227,9 +3277,9 @@ "statusTriaged": "已分類", "stopFailed": "停止任務失敗", "stopMission": "停止任務", - "stopped": "任務已停止(已暫停 {{count}} 個任務)", + "stopped_other": "", "summaryStats": "{{milestones}} 個里程碑,{{features}} 個功能。批准前請審查和編輯。", - "tabActivity": "活動({{count}})", + "tabActivity_other": "", "tabStructure": "結構", "takeControl": "接管", "takingControl": "正在接管...", @@ -3237,7 +3287,7 @@ "targetBranchPlaceholder": "例如 main", "taskIdPlaceholder": "任務 ID(例如 FN-001)", "taskIdRequired": "任務 ID 不能為空", - "tasksFailed": "{{count}} 個失敗", + "tasksFailed_other": "", "title": "任務", "titleLabel": "任務標題", "titleRequired": "任務標題不能為空", @@ -3247,25 +3297,41 @@ "triageCreateTask": "分類 — 建立任務", "tryExample": "試試例子:", "typeAnswer": "在此輸入您的答案...", + "unlinkedBadge": "未關聯", "unlinkFeature": "取消關聯功能", "unlinkTask": "取消連結任務", - "unlinkedBadge": "未關聯", "untitled": "無標題", "updateButton": "更新", "updated": "任務已更新", "validateFeature": "驗證功能", - "validationRoundsCount": "{{count}} 輪", - "validationRoundsLabel": "驗證輪次({{count}})", + "validationRoundsCount_other": "", + "validationRoundsLabel_other": "", "validationRuns": "驗證執行", "validationState": "驗證狀態", "validationStateNotStarted": "未開始", "validationTelemetry": "驗證遙測", - "validationTriggerFailed": "觸發驗證失敗", "validationTriggered": "已觸發驗證", + "validationTriggerFailed": "觸發驗證失敗", "verification": "驗證:", "verificationCriteria": "驗證標準", "viewMissionFailures": "查看任務失敗", - "whatToBuild": "您想要建置什麼?" + "whatToBuild": "您想要建置什麼?", + "attemptRetries_one": "", + "featuresCount_one": "", + "linkedCount_one": "", + "linkedFeaturesCount_one": "", + "milestonesCount_one": "", + "progressText_one": "", + "relativeTimeDays_one": "", + "relativeTimeHours_one": "", + "relativeTimeMinutes_one": "", + "slicesCount_one": "", + "sliceTriaged_one": "", + "stopped_one": "", + "tabActivity_one": "", + "tasksFailed_one": "", + "validationRoundsCount_one": "", + "validationRoundsLabel_one": "" }, "modalManager": { "createdFromPlanning": "在規劃模式中創建了 {{id}}", @@ -3276,26 +3342,12 @@ "noChange": "不變更", "selectPlaceholder": "選擇模型…" }, - "modelSelection": { - "choose": "為此工作選擇模型。如果未選擇,將使用預設模型。", - "custom": "自訂", - "executorModel": "執行模型", - "executorPlaceholder": "選擇執行模型…", - "loading": "載入模型中…", - "noModels": "沒有可用的模型。在設定中設定身份驗證以啟用模型選擇。", - "preset": "預設", - "reviewerModel": "審查模型", - "reviewerPlaceholder": "選擇審查模型…", - "title": "選擇模型", - "useDefault": "使用預設", - "usingDefault": "使用預設" - }, "models": { "addProviderToFavoritesAriaLabel": "將 {{provider}} 添加到收藏", "addToFavorites": "添加到收藏", "addToFavoritesAriaLabel": "將 {{name}} 添加到收藏", "clearFilter": "清除篩選", - "count": "{{count}} 個模型", + "count_other": "", "descriptions": { "executor": "用於實現此任務的 AI 模型。", "override": "覆寫用於此任務的 AI 模型。如果未指定,則使用專案或全域預設值。", @@ -3320,8 +3372,6 @@ "thinkingLevel": "思維等級" }, "messages": { - "modelSetTo": "{{label}} 模型已設為 {{provider}}/{{modelId}}", - "modelSetToDefault": "{{label}} 模型已設為預設", "thinkingLevelSet": "思維等級設置為 {{level}}", "thinkingLevelSetDefault": "思維等級設置為預設值 ({{level}})", "upToDate": "模型設定已是最新的。", @@ -3348,15 +3398,25 @@ "loading": "正在加載可用模型…", "usingDefault": "使用預設值" }, - "targetLabels": { - "executor": "執行器", - "planning": "規劃", - "validator": "審查者" - }, "titles": { "configuration": "模型設定" }, - "useDefault": "使用預設值" + "useDefault": "使用預設值", + "count_one": "" + }, + "modelSelection": { + "choose": "為此工作選擇模型。如果未選擇,將使用預設模型。", + "custom": "自訂", + "executorModel": "執行模型", + "executorPlaceholder": "選擇執行模型…", + "loading": "載入模型中…", + "noModels": "沒有可用的模型。在設定中設定身份驗證以啟用模型選擇。", + "preset": "預設", + "reviewerModel": "審查模型", + "reviewerPlaceholder": "選擇審查模型…", + "title": "選擇模型", + "useDefault": "使用預設", + "usingDefault": "使用預設" }, "nav": { "activityLog": "活動紀錄", @@ -3380,8 +3440,8 @@ "missions": "任務集", "more": "更多", "moreSheetTitle": "導覽", - "noScriptsAddOne": "無腳本 — 新增一個…", "nodes": "節點", + "noScriptsAddOne": "無腳本 — 新增一個…", "planning": "規劃", "primaryNavAriaLabel": "主導覽", "projects": "專案", @@ -3417,27 +3477,11 @@ "noAvailableTasks": "沒有可用的任務", "searchTasks": "搜尋任務…", "selectAgent": "選擇代理", - "selectedCount": "已選取 {{count}} 個", + "selectedCount_other": "", "taskCreated": "已建立 {{taskId}}", "title": "新任務", - "unsavedChanges": "您有未保存的變更。要放棄嗎?" - }, - "nodeStatus": { - "connecting": "連接中", - "error": "錯誤", - "local": "本機", - "offline": "離線", - "online": "線上", - "unknown": "未知" - }, - "nodeSync": { - "error": { - "authSyncFailed": "認證同步失敗", - "failedToFetchStatus": "取得同步狀態失敗", - "pullFailed": "拉取設定失敗", - "pushFailed": "推送設定失敗", - "someRequestsFailed": "部分同步狀態請求失敗" - } + "unsavedChanges": "您有未保存的變更。要放棄嗎?", + "selectedCount_one": "" }, "nodes": { "actions": { @@ -3481,22 +3525,16 @@ "addDockerNode": "新增 Docker 節點", "addDockerNodeTitle": "新增受管 Docker 節點", "addFirstNode": "新增第一個節點", + "adding": "正在新增...", "addMountButton": "新增掛載", "addNode": "新增節點", "addVariableButton": "新增變數", - "adding": "正在新增...", "apiKey": "API金鑰", "apiKeyMode": "API金鑰模式", "apiKeyNotConfigured": "未設定", "apiKeyPlaceholder": "留空以保持不變", "attachProjects": "附加現有專案", "attachProjectsHint": "選擇現有專案在此節點上執行,並為每個專案提供節點特定的絕對路徑。", - "auth": { - "differ": "認證憑證不同", - "differProviders": "認證憑證不同: {{providers}}", - "match": "認證憑證相符", - "notSynced": "認證未同步" - }, "authSync": { "differ": "憑證不同", "label": "認證同步: {{status}}", @@ -3517,9 +3555,9 @@ "containerLogs": "容器日誌", "description": "通過提供連線詳情和並行設定來註冊現有的Fusion節點。", "discoverBeforeAdding": "在新增此節點之前探索遠端專案。", - "discoverRemoteProjects": "探索遠端專案", - "discoveredCount": "已探索{{count}}個遠端專案{{plural}}。", + "discoveredCount_other": "", "discovering": "正在探索...", + "discoverRemoteProjects": "探索遠端專案", "discoveryFailed": "無法探索遠端專案", "dismissError": "關閉錯誤", "docker": "Docker", @@ -3553,8 +3591,8 @@ "dockerPidsLimit": "PID 限制", "dockerPort": "連接埠", "dockerResourceDefault": "預設", - "dockerResourceSizing": "資源規格", "dockerResources": "資源", + "dockerResourceSizing": "資源規格", "dockerRetainOnDelete": "刪除時保留", "dockerStatusUnknown": "未知", "dockerTlsCaCert": "TLS CA 憑證路徑", @@ -3567,11 +3605,11 @@ "editButton": "編輯", "errorFetching": "無法取得節點", "errorPersistMappings": "無法儲存專案對應", - "errorUnregisterAfterMappingFailure": "對應失敗後無法取消註冊節點", "errors": { "connectFailed": "連接失敗", "connectToNode": "無法連接到節點" }, + "errorUnregisterAfterMappingFailure": "對應失敗後無法取消註冊節點", "failedCreateDocker": "建立 Docker 節點失敗", "failedRefresh": "重新整理節點失敗", "failedRemove": "刪除節點失敗", @@ -3582,10 +3620,6 @@ "fieldCreated": "建立時間", "fieldMaxConcurrent": "最大並發數", "fieldName": "名稱", - "fieldStatus": "狀態", - "fieldType": "類型", - "fieldUpdated": "更新時間", - "fieldUrl": "網址", "fields": { "authKey": "認證密鑰", "host": "主機 / IP位址", @@ -3594,6 +3628,10 @@ "port": "埠", "url": "URL" }, + "fieldStatus": "狀態", + "fieldType": "類型", + "fieldUpdated": "更新時間", + "fieldUrl": "網址", "heading": "節點", "healthCheckButton": "健康檢查", "healthCheckComplete": "節點健康檢查完成", @@ -3623,6 +3661,7 @@ "namePlaceholder": "建構機器", "nameRequired": "名稱為必填項", "no": "否", + "nodeLabel": "{{name}}({{type}})— {{status}}", "noLogsAvailable": "目前沒有日誌", "noMatch": "沒有完全相符的遠端名稱。手動輸入此路徑。", "noProjects": "目前未註冊任何專案。", @@ -3630,7 +3669,6 @@ "noProjectsDiscovered": "在遠端節點上未探索到任何專案。", "noProjectsRunning": "此節點上沒有正在執行的專案。", "noRegistered": "尚未註冊任何節點。", - "nodeLabel": "{{name}}({{type}})— {{status}}", "offline": "離線", "online": "線上", "pathDiscovered": "發現遠端權威路徑:{{path}}", @@ -3642,22 +3680,22 @@ "optional": "選用" }, "provideManually": "手動提供金鑰", + "pulling": "拉取中…", "pullSettings": "拉取設定", "pullSettingsFailed": "拉取設定失敗", "pullSettingsSuccess": "設定拉取成功", - "pulling": "拉取中…", + "pushing": "推送中…", "pushSettings": "推送設定", "pushSettingsFailed": "推送設定失敗", "pushSettingsSuccess": "設定推送成功", - "pushing": "推送中…", "reachableUrl": "可連線的URL / 主機名", "readOnly": "唯讀", "refresh": "重新整理", - "refreshStatus": "重新整理狀態", "refreshing": "重新整理中…", - "registerFailed": "無法註冊節點", + "refreshStatus": "重新整理狀態", "registered": "節點「{{name}}」已註冊", - "registeredCount": "{{count}} 個已註冊", + "registeredCount_other": "", + "registerFailed": "無法註冊節點", "remote": "遠端", "removeButton": "移除", "removed": "節點已刪除", @@ -3674,18 +3712,6 @@ "sectionSettingsSync": "設定同步", "sectionSyncHistory": "同步歷史", "startButton": "啟動", - "status": { - "connecting": "連接中", - "creating": "建立中", - "deleting": "刪除中", - "error": "錯誤", - "exited": "已離開", - "offline": "離線", - "online": "上線", - "recreating": "重新建立中", - "running": "執行中", - "stopped": "已停止" - }, "statusConnecting": "連線中", "statusError": "錯誤", "statusOffline": "離線", @@ -3699,10 +3725,10 @@ "syncAuthFailed": "驗證同步失敗", "syncAuthSuccess": "驗證憑證同步成功", "syncDifferences": "差異:", - "syncLastSync": "上次同步:", - "syncNeverSynced": "從未同步", "synced": "已同步", "syncing": "同步中…", + "syncLastSync": "上次同步:", + "syncNeverSynced": "從未同步", "total": "總計", "type": { "local": "本地", @@ -3720,7 +3746,21 @@ "portRange": "埠必須在1到65535之間" }, "viewLogsButton": "查看日誌", - "yes": "是" + "yes": "是", + "discoveredCount_one": "", + "registeredCount_one": "" + }, + "nodeStatus": { + "local": "本機" + }, + "nodeSync": { + "error": { + "authSyncFailed": "認證同步失敗", + "failedToFetchStatus": "取得同步狀態失敗", + "pullFailed": "拉取設定失敗", + "pushFailed": "推送設定失敗", + "someRequestsFailed": "部分同步狀態請求失敗" + } }, "onboarding": { "authToken": "驗證令牌(選用)", @@ -3736,8 +3776,8 @@ "remoteServer": "遠端伺服器", "resumeOnboarding": "繼續入職", "saving": "保存中…", - "scanQr": "掃描 QR 碼", "scanning": "掃描中…", + "scanQr": "掃描 QR 碼", "serverUrl": "伺服器 URL", "serverUrlPlaceholder": "https://your-fusion-host", "stepContinue": "步。繼續您的工作以完成儀表板設定。", @@ -3797,10 +3837,10 @@ "companyHelp": "選擇 Paperclip 公司。", "companyIdRequired": "需要公司 ID 才能鑄造 Paperclip API 金鑰。", "companyLabel": "公司", - "connectToPopulate": "連線以填充", "connected": "已連線。", "connectedAsAgent": "已連線為 {{agentName}}{{companyInfo}}。", "connectionModeAriaLabel": "Paperclip 連線模式", + "connectToPopulate": "連線以填充", "description": "在 Paperclip 公司中驅動一個 Paperclip 代理(員工)。每個提示都會發出一個任務形狀的請求;Paperclip 強制執行治理、預算和批准。每輪預計延遲為秒到分鐘。", "docsLink": "Paperclip 文件", "githubLink": "GitHub", @@ -3808,16 +3848,6 @@ "goalIdLabel": "目標 ID(選擇性)", "mintButton": "透過 paperclipai 鑄造 API 金鑰", "mintFailed": "鑄造失敗:{{reason}}。如果 CLI 未驗證,請先執行 `paperclipai onboard`。", - "mode": { - "issue-per-prompt": "每個提示一個問題", - "rolling-issue": "滾動問題(預設)", - "wakeup-only": "僅喚醒(進階)" - }, - "modeHelp": { - "issue-per-prompt": "每個提示都會建立一個新的頂層 Paperclip 任務。最大程度明確;容易讓看板變得雜亂。", - "rolling-issue": "每個 Fusion 工作階段對應一個 Paperclip 任務;後續提示以留言形式新增。最接近聊天體驗。", - "wakeup-only": "無任務副作用;提示僅透過喚醒酬載傳遞。需要代理的提示範本知道如何處理酬載驅動的喚醒。" - }, "modeLabel": "對話模式", "name": "Paperclip", "noAgentsDiscovered": "未發現代理", @@ -3860,13 +3890,13 @@ "filterSkills": "技能:", "filterThemes": "佈景主題:", "installFailed": "無法安裝套件:{{error}}", - "installSuccess": "套件安裝成功", "installing": "安裝中…", + "installSuccess": "套件安裝成功", "loadExtensionsFailed": "無法加載擴充功能:{{error}}", - "loadSettingsFailed": "無法加載 Pi 設定:{{error}}", "loading": "載入 Pi 設定中…", "loadingExtensions": "載入擴充功能中…", "loadingFailed": "無法加載 Pi 設定。", + "loadSettingsFailed": "無法加載 Pi 設定:{{error}}", "noExtensions": "未發現擴充功能。", "noPackages": "未設定任何套件。", "noPackagesHelp": "在上方新增套件來源以開始。", @@ -3876,8 +3906,8 @@ "refreshExtensions": "重新整理擴充功能", "reinstallButton": "重新安裝 Fusion 技能", "reinstallFailed": "無法重新安裝 Fusion 技能:{{error}}", - "reinstallSuccess": "Fusion 技能重新安裝成功", "reinstalling": "重新安裝 Fusion…", + "reinstallSuccess": "Fusion 技能重新安裝成功", "removeFailed": "無法移除套件:{{error}}", "removePackage": "移除套件", "removePackageLabel": "移除套件 {{label}}", @@ -3893,9 +3923,9 @@ "updateSettingsFailed": "無法更新設定:{{error}}" }, "planning": { - "addSubtask": "新增子任務", "additionalComments": "附加備註(選填)", "additionalCommentsPlaceholder": "新增任何額外的背景資訊或方向…", + "addSubtask": "新增子任務", "advancedSettings": "進階規劃設定", "aiThinking": "AI 正在思考…", "archiveSession": "封存工作階段", @@ -3910,10 +3940,10 @@ "branchNameRequired": "此分支策略需要提供分支名稱。", "branchProjectDefault": "使用專案/預設分支", "branchStrategy": "分支策略", - "breakIntoTasks": "分解為任務", - "breakIntoTasksTitle": "將計畫分解為多個有依賴關係的任務", "breakdownSubheading": "審查並編輯從您的計畫生成的子任務。在建立之前調整標題、描述、規模、優先順序和依賴關係。", "breakingDown": "分解中…", + "breakIntoTasks": "分解為任務", + "breakIntoTasksTitle": "將計畫分解為多個有依賴關係的任務", "collapse": "收合", "continue": "繼續", "createSingleTask": "建立單一任務", @@ -3977,11 +4007,11 @@ "questionsLabel": "問題數量", "reconnecting": "重新連線中…", "refineFurther": "進一步精煉", - "relativeTimeDays": "{{count}}天前", - "relativeTimeHours": "{{count}}小時前", + "relativeTimeDays_other": "", + "relativeTimeHours_other": "", "relativeTimeJustNow": "剛剛", - "relativeTimeMinutes": "{{count}}分鐘前", - "relativeTimeWeeks": "{{count}}週前", + "relativeTimeMinutes_other": "", + "relativeTimeWeeks_other": "", "remove": "移除", "retryFailed": "重試失敗,請再試一次。", "retrying": "重試中…", @@ -4020,34 +4050,21 @@ "untitledSession": "未命名工作階段", "usingDefault": "使用預設", "whatToBuild": "您想建構什麼?", - "whatToBuildPlaceholder": "例如,建構一個包含登入、註冊和密碼重設的使用者驗證系統..." + "whatToBuildPlaceholder": "例如,建構一個包含登入、註冊和密碼重設的使用者驗證系統...", + "relativeTimeDays_one": "", + "relativeTimeHours_one": "", + "relativeTimeMinutes_one": "", + "relativeTimeWeeks_one": "" }, "plugins": { "addItem": "新增項目", - "agentBrowser": { - "groupBrowser": "瀏覽器", - "groupGeneral": "一般", - "groupPromptContributions": "提示貢獻", - "groupSkills": "技能", - "labelAllowedDomains": "允許的網域", - "labelCommandTimeoutMs": "指令逾時 (毫秒)", - "labelEnabled": "啟用代理瀏覽器", - "labelHeadlessMode": "無頭模式", - "labelInstallChannel": "安裝頻道", - "labelPromptExecutorSystem": "執行器系統提示", - "labelPromptExecutorTask": "執行器任務提示", - "labelPromptHeartbeat": "心跳提示", - "labelPromptReviewer": "審查員提示", - "labelPromptTriage": "分類提示", - "labelSkillExposure": "技能公開範圍" - }, "aiScanDisabled": "已停用載入時 AI 掃描", "aiScanEnabled": "已啟用載入時 AI 掃描", "aiScanHint": "開啟此選項只會更新設定。使用「重新掃描並重新載入」立即執行。", "author": "作者:", "backToList": "返回插件列表", - "builtinInstallFailed": "安裝 {{name}} 失敗:{{error}}", "builtinInstalledGlobally": "{{name}} 已全域安裝", + "builtinInstallFailed": "安裝 {{name}} 失敗:{{error}}", "builtinMetadataOnly": "僅內建中繼資料", "builtinNoPackage": "{{name}} 是內建功能,目前沒有可安裝的套件", "builtinPluginRecommendations": "內建插件推薦", @@ -4057,34 +4074,34 @@ "checkingSetup": "正在檢查設定…", "componentUnavailable": "外掛程式元件不可用", "couldNotResolve": "儀表板無法從靜態主機登錄解析此外掛程式表面。", + "disabledForProject": "{{name}} 已為此專案停用", "disableInProject": "在專案中停用", "disablePlugin": "停用 {{name}}", "disablePluginFailed": "停用插件失敗:{{error}}", - "disabledForProject": "{{name}} 已為此專案停用", "droidOnboardingTip": "提示:啟用 Droid CLI 可重複使用 Factory AI 訂閱,無需新增 API 金鑰。", "droidRecommendDesc": "在 Fusion 中使用本機 Droid CLI 工作階段作為 AI 提供者。", "droidRecommendTitle": "啟用 Droid CLI", "enableAiScanBeforeLoad": "在載入/重新載入前啟用 AI 掃描", "enableAiSecurityScan": "在載入時啟用 AI 安全掃描", + "enabledForProject": "{{name}} 已為此專案啟用", "enableFailed": "啟用 {{name}} 失敗:{{error}}", "enableInProject": "在專案中啟用", "enablePlugin": "啟用 {{name}}", "enablePluginFailed": "啟用插件失敗:{{error}}", - "enabledForProject": "{{name}} 已為此專案啟用", "experimental": "實驗性", - "findings": "發現 ({{count}})", + "findings_other": "", "homepage": "首頁:", "install": "安裝", + "installedGlobally": "插件已全域安裝", + "installedPlugins": "已安裝插件", "installFailed": "安裝插件失敗:{{error}}", "installHint": "瀏覽至插件套件根目錄(包含 manifest.json)或已建置的 dist 目錄。", + "installing": "安裝中…", "installNamed": "安裝 {{name}}", "installPathPlaceholder": "插件目錄或 dist 資料夾的絕對路徑", "installPathRequired": "請輸入插件路徑", "installPluginGlobally": "全域安裝插件", "installSetup": "安裝設定", - "installedGlobally": "插件已全域安裝", - "installedPlugins": "已安裝插件", - "installing": "安裝中…", "loadFailed": "載入插件失敗:{{error}}", "loading": "載入中…", "loadingPlugins": "載入插件中…", @@ -4098,8 +4115,8 @@ "refresh": "重新整理", "refreshPluginList": "重新整理插件列表", "reload": "重新載入", - "reloadFailed": "重新載入插件失敗:{{error}}", "reloaded": "{{name}} 已重新載入", + "reloadFailed": "重新載入插件失敗:{{error}}", "reloading": "重新載入中…", "removeItem": "移除項目", "rescanAndReload": "重新掃描並重新載入", @@ -4109,11 +4126,11 @@ "saveSettingsFailed": "儲存設定失敗:{{error}}", "securityScan": "安全掃描", "selectOption": "請選擇…", - "settingUp": "設定中…", "settings": "設定", "settingsSaved": "設定已儲存", - "setupInstallFailed": "安裝 {{name}} 設定失敗:{{error}}", + "settingUp": "設定中…", "setupInstalled": "{{name}} 設定已安裝", + "setupInstallFailed": "安裝 {{name}} 設定失敗:{{error}}", "setupReady": "設定就緒", "setupRequired": "需要設定", "startPluginToCheckSetup": "啟動插件以檢查設定", @@ -4121,14 +4138,15 @@ "statusInstalled": "已安裝", "statusNotInstalled": "未安裝", "uninstallConfirm": "您確定要全域解除安裝\"{{name}}\"(所有專案)嗎?", + "uninstalledGlobally": "{{name}} 已全域解除安裝", "uninstallFailed": "解除安裝插件失敗:{{error}}", "uninstallGlobally": "全域解除安裝", "uninstallGloballyTitle": "全域解除安裝", "uninstallTitle": "全域解除安裝插件", - "uninstalledGlobally": "{{name}} 已全域解除安裝", "unknownError": "未知錯誤", "updateFailed": "更新插件失敗:{{error}}", - "version": "版本:" + "version": "版本:", + "findings_one": "" }, "pr": { "authFail": "執行 gh auth login 並重試。", @@ -4151,7 +4169,6 @@ "createPr": "建立 PR", "createTitle": "建立拉取請求", "dismissError": "關閉 PR 錯誤", - "loadingMetadata": "正在載入 PR 中繼資料…", "noConflicts": "未檢測到合併衝突。", "preflightChecks": "飛行前檢查", "previewTitle": "差異和提交預覽", @@ -4176,22 +4193,26 @@ "confirm": "確認", "confirmRemove": "確認移除", "confirmRemoveProject": "確認移除專案", - "daysAgo": "{{count}} 天前", - "hoursAgo": "{{count}} 小時前", + "daysAgo_other": "", + "hoursAgo_other": "", "justNow": "剛才", "lastActivity": "最後活動:", - "minutesAgo": "{{count}} 分鐘前", - "moreItems": "+{{count}} 個更多", + "minutesAgo_other": "", + "moreItems_other": "", "never": "從未", - "noHealthData": "沒有可用的健康資料", "nodeAvailability": "專案節點可用性", + "noHealthData": "沒有可用的健康資料", "open": "打開", "openProject": "打開專案", "pause": "暫停", "pauseProject": "暫停專案", "removeProject": "移除專案", "resume": "恢復", - "resumeProject": "恢復專案" + "resumeProject": "恢復專案", + "daysAgo_one": "", + "hoursAgo_one": "", + "minutesAgo_one": "", + "moreItems_one": "" }, "projectDetection": { "editName": "編輯名稱", @@ -4199,20 +4220,13 @@ "emptyHint": "嘗試不同的基本路徑或手動添加項目", "noDbWarning": "未找到 fn 資料庫 - 將初始化", "registerAll": "全部註冊", - "registerSelected": "註冊已選項目 ({{count}})", "registering": "註冊中...", - "selectAll": "全選 ({{count}})", - "selectedCount": "已選擇 {{count}} 個" - }, - "projectSelector": { - "allProjects": "所有專案", - "ariaLabel": "選擇專案", - "clearSearch": "清除搜尋", - "noResults": "找不到符合的專案", - "recent": "最近", - "searchPlaceholder": "搜尋專案...", - "selectProject": "選擇專案", - "viewAll": "查看所有專案" + "registerSelected_other": "", + "selectAll_other": "", + "selectedCount_other": "", + "registerSelected_one": "", + "selectAll_one": "", + "selectedCount_one": "" }, "projects": { "actions": { @@ -4237,9 +4251,9 @@ "filterByNode": "依節點篩選", "filterErrored": "錯誤", "filterPaused": "暫停", + "nodesLabel": "節點", "noMatch": "沒有專案符合目前的篩選條件", "noProjectsFound": "未找到專案", - "nodesLabel": "節點", "setup": { "success": "項目 {{name}} 註冊成功" }, @@ -4254,15 +4268,26 @@ "title": "專案", "totalLabel": "合計" }, + "projectSelector": { + "allProjects": "所有專案", + "ariaLabel": "選擇專案", + "clearSearch": "清除搜尋", + "noResults": "找不到符合的專案", + "recent": "最近", + "searchPlaceholder": "搜尋專案...", + "selectProject": "選擇專案", + "viewAll": "查看所有專案" + }, "providers": { "actions": { "addModel": "+ 新增模型", + "detecting": "偵測中…", "detectModels": "偵測模型", "detectModelsTitle": "呼叫提供者的/models端點來探索可用模型", - "detecting": "偵測中…", - "removeModel": "刪除模型", + "removeModel_other": "", "save": "儲存提供者", - "saving": "儲存中..." + "saving": "儲存中...", + "removeModel_one": "" }, "addCustom": "添加自訂提供者", "apiKeyLabel": "API 密鑰", @@ -4280,9 +4305,9 @@ "noModels": "未找到模型。提供者可能需要API密鑰。", "urlRequired": "需要基礎URL來偵測模型。" }, + "detecting": "正在偵測…", "detectModels": "偵測模型", "detectTitle": "從提供者的 /models 端點自動偵測模型", - "detecting": "正在偵測…", "editLabel": "編輯 {{name}}", "failedDelete": "無法刪除提供者。", "failedDetect": "無法偵測模型", @@ -4297,6 +4322,7 @@ "maxTokens": "最大令牌數", "modelId": "模型ID", "modelName": "顯示名稱", + "modelNameLabel": "", "models": "模型", "name": "顯示名稱", "reasoning": "推理" @@ -4360,8 +4386,10 @@ "p95": "P95", "p95Raw": "P95 原始: {{value}} ms", "reason": "原因: {{reason}}", - "sampleCount": "樣本數: {{count}}", - "samples": "樣本: {{count}}" + "sampleCount_other": "", + "samples_other": "", + "sampleCount_one": "", + "samples_one": "" }, "failureRate": "失敗率: {{rate}}", "heading": "可靠性", @@ -4370,12 +4398,14 @@ "insufficientData": "資料不足 — {{reason}}", "mergeAttempts": { "heading": "合併嘗試", - "histogramTotal": "直方圖總計: {{count}}", + "histogramTotal_other": "", "max": "最大值", "mean": "平均值", "moreStats": "更多統計", "reason": "原因: {{reason}}", - "tasksCounted": "計數的工作: {{count}}" + "tasksCounted_other": "", + "histogramTotal_one": "", + "tasksCounted_one": "" }, "reason": "原因: {{reason}}", "resetBaseline": "重設基線: {{date}}", @@ -4413,11 +4443,11 @@ "enrichTaskButton": "豐富任務", "enrichTaskTitle": "豐富現有任務", "enterTaskId": "輸入任務 ID", + "exportedFile": "已匯出 {{filename}}", "exportFailed": "匯出失敗", "exportHtml": "匯出 HTML", "exportJson": "匯出 JSON", "exportMd": "匯出 MD", - "exportedFile": "已匯出 {{filename}}", "findingLabel": "發現:", "loadingRuns": "正在載入研究執行…", "loadingTasks": "載入任務…", @@ -4434,11 +4464,6 @@ "priorityLow": "低", "priorityNormal": "正常", "priorityUrgent": "緊急", - "providerGitHub": "GitHub", - "providerLlmSynthesis": "LLM 合成", - "providerLocalDocs": "本機文件", - "providerPageFetch": "頁面擷取", - "providerWebSearch": "網路搜尋", "providersLabel": "提供程式", "queryLabel": "查詢", "runCancelled": "執行已取消", @@ -4461,7 +4486,7 @@ "viewLabel": "研究檢視" }, "routine": { - "andMore": "…以及另外 {{count}} 個", + "andMore_other": "", "delete": "刪除", "deleteMessage": "刪除例程 {{name}}?此操作無法復原。", "deleteName": "刪除 {{name}}", @@ -4474,11 +4499,14 @@ "enableName": "啟用 {{name}}", "resultFailed": "失敗", "resultSuccess": "成功", - "runHistory": "執行歷史 ({{count}})", + "runHistory_other": "", "runNameNow": "立即執行 {{name}}", - "runNow": "立即執行", "running": "執行中…", - "stepCount": "{{count}} 個步驟" + "runNow": "立即執行", + "stepCount_other": "", + "andMore_one": "", + "runHistory_one": "", + "stepCount_one": "" }, "routing": { "cannotChangeWhileActive": "工作為作用中時無法更改節點覆寫。", @@ -4494,11 +4522,6 @@ "overrideSection": "節點覆寫", "overrideSetTo": "覆寫設定為", "overrideUpdated": "節點覆寫已更新", - "policyLabel": { - "block": "阻止執行", - "fallback": "回退至本機", - "notConfigured": "未設定" - }, "selectLabel": "選取執行節點", "source": { "noRouting": "無路由", @@ -4532,11 +4555,11 @@ "advancedMode": "多步驟", "advancedModeHelp": "依序執行多個步驟(命令和 AI 提示)", "aiPromptType": "AI 提示詞", - "andMore": "…以及另外 {{count}} 個", + "andMore_other": "", "apiEndpointHint": "觸發此例行程序的 API 端點路徑", "apiEndpointLabel": "API 端點", "apiEndpointPlaceholder": "/api/routine/my-routine", - "automationCount": "{{count}} 個自動化{{plural}}", + "automationCount_other": "", "cancelButton": "取消", "catchUpPolicyHint": "當計劃執行被錯過時的處理方式", "catchUpPolicyLabel": "補執行策略", @@ -4591,10 +4614,10 @@ "editTitle": "編輯排程", "emptyStateDescription": "使用排程、webhook、API 或手動觸發器建立自動化。", "enable": "啟用", - "enableName": "啟用 {{name}}", "enabledHelp": "禁用時,排程將不會自動執行", "enabledHint": "停用後,例行程序將不會自動執行", "enabledLabel": "已啟用", + "enableName": "啟用 {{name}}", "errorApiEndpointRequired": "API 端點為必填", "errorCommandRequired": "命令為必填", "errorCronInvalid": "無效的 cron 格式——需要 5 個欄位(例如 '0 */6 * * *')", @@ -4607,9 +4630,9 @@ "errorStepCommandRequired": "步驟 {{n}}:命令為必填", "errorStepNameRequired": "步驟 {{n}}:名稱為必填", "errorStepPromptRequired": "步驟 {{n}}:提示為必填", - "errorStepTaskDescRequired": "步驟 {{n}}:任務描述為必填", "errorStepsEditing": "請在儲存例行程序之前儲存或取消所有步驟的編輯", "errorStepsRequired": "至少需要一個步驟", + "errorStepTaskDescRequired": "步驟 {{n}}:任務描述為必填", "errorTaskDescriptionRequired": "任務描述為必填", "errorTimeoutMin": "逾時必須至少為 1 秒(1000ms)", "errorWebhookPathRequired": "Webhook 路徑為必填", @@ -4626,13 +4649,13 @@ "frequencyLabel": "頻率", "global": "全域", "globalScope": "全域(使用者級)自動化", - "globalScopeTitle": "全域範圍", "globalScoped": "此排程將在全域範圍內建立。", + "globalScopeTitle": "全域範圍", "loadRoutinesError": "載入例行程序失敗", "manualTriggerInfo": "此例行程序將透過控制台或 API 手動觸發。", "modeAriaLabel": "執行模式", - "modeLabel": "執行模式", "model": "模型", + "modeLabel": "執行模式", "modelConsistency": "模型提供者和模型 ID 必須同時設定或同時為空", "modelDropdownLabel": "模型", "modelHelp": "此步驟的 AI 模型。如果未選擇,則使用預設值。", @@ -4656,9 +4679,9 @@ "project": "專案", "projectRequired": "特定於專案的項目需要有效的專案。", "projectScope": "專案範圍的自動化", + "projectScoped": "此排程將限定於目前的專案。", "projectScopeDisabled": "選擇一個專案以啟用專案範圍", "projectScopeTitle": "專案範圍", - "projectScoped": "此排程將限定於目前的專案。", "prompt": "提示詞", "promptHelp": "要執行的 AI 提示。為任務提供清楚的指示。", "promptHint": "要執行的 AI 提示。", @@ -4675,10 +4698,10 @@ "routineSuccess": "{{name}} 已成功完成", "routineUpdated": "例行程序已更新", "runError": "執行例行程序失敗", - "runHistory": "執行歷史 ({{count}})", + "runHistory_other": "", "runNameNow": "立即執行 {{name}}", - "runNow": "立即執行", "running": "執行中…", + "runNow": "立即執行", "saveChanges": "儲存變更", "saveStep": "保存步驟", "saving": "儲存中…", @@ -4695,15 +4718,15 @@ "simpleMode": "簡單", "simpleModeHelp": "執行單一 shell 命令或 AI 提示", "stepCommandRequired": "步驟 {{index}}:命令為必填項", - "stepCount": "{{count}} 個步驟", + "stepCount_other": "", "stepName": "步驟名稱", "stepNamePlaceholder": "例如:執行測試", "stepNameRequired": "需要步驟名稱", "stepPromptRequired": "步驟 {{index}}:提示為必填項", - "stepType": "步驟類型", "steps": "步驟", "stepsEditing": "在儲存排程前,請保存或取消所有步驟編輯", "stepsRequired": "至少需要一個步驟", + "stepType": "步驟類型", "targetColumn": "目標欄", "targetColumnHelp": "將建立新任務的欄", "targetColumnLabel": "目標欄", @@ -4755,7 +4778,11 @@ "webhookPathPlaceholder": "/trigger/my-routine", "webhookSecretHint": "用於簽章驗證的 HMAC 密鑰。無需驗證的 webhook 請留空。", "webhookSecretLabel": "Webhook 密鑰(可選)", - "webhookSecretPlaceholder": "可選——無需驗證的 webhook 請留空" + "webhookSecretPlaceholder": "可選——無需驗證的 webhook 請留空", + "andMore_one": "", + "automationCount_one": "", + "runHistory_one": "", + "stepCount_one": "" }, "scriptsModal": { "addScript": "新增指令碼", @@ -4780,14 +4807,15 @@ "saving": "正在保存...", "scriptAlreadyExists": "已經存在同名指令碼", "scriptCommandRequired": "需要指令碼命令", - "scriptCount": "{{count}} 個指令碼", + "scriptCount_other": "", "scriptCreated": "指令碼已建立", "scriptDeleted": "指令碼已刪除", "scriptName": "指令碼名稱", "scriptNamePlaceholder": "例如,build、test、lint", "scriptNameRequired": "需要指令碼名稱", "scriptUpdated": "指令碼已更新", - "title": "指令碼" + "title": "指令碼", + "scriptCount_one": "" }, "secrets": { "accessPolicyAuto": "自動", @@ -4852,20 +4880,17 @@ "failed": "失敗", "headerAwaitingAndErrorPlural": "{{awaitingCount}} 個 AI 工作階段需要您的輸入,{{errorCount}} 個失敗", "headerAwaitingAndErrorSingular": "{{awaitingCount}} 個 AI 工作階段需要您的輸入,{{errorCount}} 個失敗", - "headerAwaitingPlural": "{{count}} 個 AI 工作階段需要您的輸入", - "headerAwaitingSingular": "{{count}} 個 AI 工作階段需要您的輸入", - "headerErrorPlural": "{{count}} 個 AI 工作階段失敗", - "headerErrorSingular": "{{count}} 個 AI 工作階段失敗", + "headerAwaitingPlural_other": "", + "headerAwaitingSingular_other": "", + "headerErrorPlural_other": "", + "headerErrorSingular_other": "", "regionLabel": "需要輸入或已失敗的 AI 工作階段", "resume": "繼續", "retry": "重試", - "typeLabel": { - "milestoneInterview": "里程碑訪談", - "missionInterview": "任務訪談", - "planning": "規劃", - "sliceInterview": "切片訪談", - "subtask": "子任務分解" - } + "headerAwaitingPlural_one": "", + "headerAwaitingSingular_one": "", + "headerErrorPlural_one": "", + "headerErrorSingular_one": "" }, "settings": { "actions": { @@ -4876,7 +4901,6 @@ "language": "語言", "languageAuto": "自動", "languageAutoHint": "跟隨瀏覽器語言", - "languageHint": "選擇 {{brand}} 介面的語言。", "title": "外觀" }, "auth": { @@ -4930,8 +4954,8 @@ "general": { "learnMore": "了解更多", "settingsSaved": "設定已儲存", - "upToDate": "您已是最新版本 ✓", - "updateAvailablePrefix": "v{{version}} 可用" + "updateAvailablePrefix": "v{{version}} 可用", + "upToDate": "您已是最新版本 ✓" }, "header": { "discord": "Discord" @@ -4941,8 +4965,8 @@ "exportBtn": "匯出", "exportTitle": "將設定匯出為 JSON 檔案", "importBtn": "匯入", - "importTitle": "匯入設定", "importing": "匯入中…", + "importTitle": "匯入設定", "loadingFile": "載入中…", "reviewPrompt": "查看要匯入的設定:" }, @@ -4951,17 +4975,17 @@ "keepRemote": "保留遠端", "loading": "載入中…", "memory": { - "compactSelectedFile": "壓縮選定檔案", "compacting": "壓縮中…", + "compactSelectedFile": "壓縮選定檔案", "dreamCompleted": "夢境處理完成", "dreamNow": "立即觸發夢境", - "installQmd": "安裝 qmd", "installing": "安裝中…", + "installQmd": "安裝 qmd", "memoryCompacted": "記憶檔案已壓縮", "memorySaved": "記憶已儲存", "saveMemory": "儲存記憶", - "testRetrieval": "測試擷取", - "testing": "測試中…" + "testing": "測試中…", + "testRetrieval": "測試擷取" }, "mergeManually": "手動合併", "mobileNav": { @@ -4976,45 +5000,14 @@ "savePreset": "儲存預設" }, "nav": { - "accountHeader": "帳戶", - "agentPermissions": "代理程式權限", - "appearance": "外觀", "aria": { "global": "全域設定", "project": "專案設定" }, - "authentication": "驗證", - "backups": "備份", - "commands": "指令", - "experimental": "實驗性功能", - "globalGeneral": "一般", - "globalHeader": "全域", - "globalModels": "模型", - "hermesRuntime": "Hermes", - "memory": "記憶", - "merge": "合併", - "nodeRouting": "節點路由", - "nodeSync": "節點同步", - "notifications": "通知", - "openclawRuntime": "OpenClaw", - "paperclipRuntime": "Paperclip", - "plugins": "外掛程式", - "projectGeneral": "專案一般", - "projectHeader": "專案", - "projectModels": "專案模型", - "prompts": "提示詞", - "remote": "遠端存取", - "researchGlobal": "研究預設值", - "researchProject": "研究", - "runtimesHeader": "執行環境", - "scheduledEvals": "排程評估", - "scheduling": "排程", - "secrets": "密鑰", "tooltip": { "global": "在所有專案中共用", "project": "特定於此專案" - }, - "worktrees": "工作樹" + } }, "notifications": { "sending": "發送中…", @@ -5028,10 +5021,10 @@ "restarting": "重啟中…", "shortLivedTokenGenerated": "短期權杖已產生", "startFresh": "重新啟動", - "startTunnel": "啟動隧道", "starting": "啟動中…", - "stopTunnel": "停止隧道", + "startTunnel": "啟動隧道", "stopping": "停止中…", + "stopTunnel": "停止隧道", "tunnelRestarted": "遠端隧道已重啟", "tunnelStarted": "遠端隧道已啟動", "tunnelStopped": "遠端隧道已停止", @@ -5039,8 +5032,8 @@ }, "resolveAllLocal": "全部解決:保留本機", "resolveAllRemote": "全部解決:保留遠端", - "resolveFailed": "無法解決衝突", "resolvedSuccess": "設定衝突已成功解決", + "resolveFailed": "無法解決衝突", "resolving": "解決中...", "scheduling": { "selectCurrentDir": "選取目前目錄", @@ -5068,46 +5061,11 @@ "aiSetupDescription": "Fusion 使用 AI 模型為您規劃、撰寫和審查程式碼。在下方連接 AI 提供商以開始使用,您可以使用托管服務或輸入 API 金鑰。", "allProvidersShown": "所有目前可用的提供商已顯示在上方。", "allSet": "全部設定好了!", - "apiKeyFormatError": "{{providerName}} 金鑰應遵循此格式:{{hint}}(例如 {{example}})", "apiKeyFormatHint": "格式:{{hint}}", "apiKeyHint": "金鑰:{{keyHint}}", - "apiKeyLabel": { - "fallback": "API 金鑰", - "kimiCoding": "Kimi API 金鑰", - "minimax": "MiniMax API 金鑰", - "ollama": "Ollama 端點", - "openai": "OpenAI API 金鑰", - "openrouter": "OpenRouter API 金鑰", - "zai": "智譜 AI API 金鑰" - }, - "apiKeyPlaceholder": { - "fallback": "輸入 API 金鑰", - "kimiCoding": "輸入您的 Kimi API 金鑰", - "minimax": "輸入您的 MiniMax API 金鑰", - "zai": "輸入您的智譜 AI API 金鑰" - }, "apiKeyRemoved": "API 金鑰已移除", - "apiKeyRequired": "API 金鑰為必填項", "apiKeySaved": "✓ API 金鑰已儲存", "apiKeySavedToast": "API 金鑰已儲存", - "apiKeySetup": { - "fallback": "輸入此提供商的 API 金鑰。", - "kimiCoding": "在 Moonshot 平台帳戶設定中建立 API 金鑰。", - "minimax": "從 MiniMax 平台開發者控制台產生 API 金鑰。", - "ollama": "輸入您的 Ollama 端點 URL(例如 http://localhost:11434)。", - "openai": "在 OpenAI 控制台的 API 金鑰頁面建立 API 金鑰。", - "openrouter": "從 OpenRouter 帳戶金鑰管理頁面建立 API 金鑰。", - "zai": "在智譜 AI 開放平台帳戶設定中建立 API 金鑰。" - }, - "apiKeyUsage": { - "fallback": "Fusion 用於向此提供商驗證請求", - "kimiCoding": "用於任務執行和規劃中的 Kimi/Moonshot AI 模型", - "minimax": "用於任務執行中的 MiniMax 模型", - "ollama": "連接到您的本地 Ollama 實例", - "openai": "用於任務執行和規劃中的 GPT 模型", - "openrouter": "透過單一金鑰路由到多個 AI 模型提供商", - "zai": "用於任務執行中的 GLM 模型" - }, "ariaDismissRecommendations": "關閉建議", "ariaSetupRecommendations": "設定建議", "authCodeAlreadySubmitted": "該授權碼已提交,等待登入完成…", @@ -5158,13 +5116,13 @@ "connectAiProvider": "連接 AI 提供商", "connectAiProviderDesc": "連接 AI 提供商以啟用 AI 代理進行任務規劃和程式碼生成", "connectAnyway": "仍然連接", + "connectedProviders": "已連接的提供商", "connectGitHub": "連接 GitHub", "connectGitHubAnytime": "如果您還沒準備好也沒關係——隨時可以從設定 → 驗證連接 GitHub。", "connectGitHubButton": "連接 GitHub", "connectGitHubDesc": "連接 GitHub 以導入問題並追蹤拉取請求", "connectOauthOptional": "連接 OAuth(選用)", "connectRemoteServer": "連線遠端 Fusion 伺服器", - "connectedProviders": "已連接的提供商", "continueToLogin": "繼續登入", "continueWithGhCli": "使用 gh CLI 身份驗證繼續 →", "continueWithoutGitHub": "不使用 GitHub 繼續 →", @@ -5229,10 +5187,10 @@ "githubSkipped": "已略過 GitHub。您隨時可以從設定 → 驗證進行連接。", "goBackToStep": "返回{{label}}", "goToDashboard": "前往儀表板", - "howDoIChooseModel": "如何選擇模型?", - "howDoIChooseModelBody": "模型在速度、能力和成本上各有差異。通常選擇所連接提供商的最新模型作為預設值是個好選擇。您可以隨時在設定中更改。", "howDoesLoginWork": "登入是如何運作的?", "howDoesLoginWorkBody": "點擊登入將在新分頁中開啟提供商網站進行登入。授權 Fusion 後,此頁面將自動偵測連線。您的憑證不會儲存在 Fusion 中。", + "howDoIChooseModel": "如何選擇模型?", + "howDoIChooseModelBody": "模型在速度、能力和成本上各有差異。通常選擇所連接提供商的最新模型作為預設值是個好選擇。您可以隨時在設定中更改。", "importFromGitHub": "從 GitHub 匯入", "importFromGitHubSubtitle": "將 GitHub 問題轉換為可在此處追蹤的任務", "inProcess": "進程內", @@ -5294,25 +5252,9 @@ "projectRequired": "在可以使用第一個任務操作之前,需要先有一個專案。", "projectSelected": "已選擇專案——任務建立和匯入功能已可用。", "projectSetupDescription": "在建立或匯入任務之前,請選擇您的第一個專案。您可以註冊現有的本機目錄,或透過設定精靈複製 GitHub 儲存庫 URL。", - "providerDesc": { - "anthropic": "Claude 模型——擅長推理、分析和程式開發", - "fallback": "AI 提供商——連接以開始使用 AI 模型", - "gemini": "Gemini 模型——多模態,推理能力強", - "google": "Gemini 模型——多模態,推理能力強", - "kimi": "Moonshot AI 的 Kimi——長上下文能力", - "kimiCoding": "Moonshot AI 的 Kimi——長上下文能力", - "minimax": "MiniMax 模型——大量使用時具有成本優勢", - "moonshot": "Moonshot AI 的 Kimi——長上下文能力", - "ollama": "在您的電腦上本地執行開源模型", - "openai": "GPT 模型——適用於各種任務", - "openaiCodex": "OpenAI Codex 模型——專為程式任務優化", - "openrouter": "OpenRouter——跨多個 AI 提供商路由請求", - "zai": "智譜 AI 的 GLM 模型——強大的多語言支援" - }, "providersConnectedSummary": "✓ {{total}} 個提供商中已連接 {{connected}} 個", - "providersSkippedSummary": "已略過 {{count}} 個提供商", - "providersSkippedSummary_one": "已略過 {{count}} 個提供商", - "providersSkippedSummary_other": "已略過 {{count}} 個提供商", + "providersSkippedSummary_one_other": "", + "providersSkippedSummary_other_other": "", "quickStartProviders": "快速啟動提供商", "readinessAiProviderConnected": "{{name}} 已連接——AI 代理可以處理任務", "readinessAiProviderLabel": "AI 提供商", @@ -5331,8 +5273,8 @@ "readinessSummaryHeader": "設定摘要", "recommended": "建議", "recommendedNextSteps": "推薦的後續步驟", - "registerProject": "註冊專案", "registering": "正在註冊...", + "registerProject": "註冊專案", "remoteServerNote": "您的本機 Shell 需要一個有效的遠端設定檔,才能完成儀表板移交。", "remoteServerProfileSaved": "遠端伺服器設定檔已儲存", "removeKey": "移除金鑰", @@ -5346,9 +5288,9 @@ "retry": "重試", "reviewStep": "查看{{label}}", "runtimeNode": "執行時節點", + "savedProfileButFailedToActivate": "設定檔已儲存但啟用失敗", "saveKey": "儲存", "saveRemoteServer": "儲存遠端伺服器", - "savedProfileButFailedToActivate": "設定檔已儲存但啟用失敗", "saving": "正在儲存…", "savingKey": "正在儲存…", "savingRemoteServer": "正在儲存…", @@ -5361,9 +5303,9 @@ "setToken": "設定令牌", "setTokenContinue": "設定令牌並繼續", "setUpAi": "設定 AI", - "setUpProject": "設定專案", "setupComplete": "設定完成!", "setupMode": "設定模式", + "setUpProject": "設定專案", "setupWizardHint": "在設定精靈中,選擇現有目錄或貼上 GitHub 複製 URL。", "skip": "跳過", "skipForNow": "暫時略過", @@ -5418,7 +5360,9 @@ "withoutGitHub1": "手動建立任務", "withoutGitHub2": "為 AI 代理描述工作", "withoutGitHub3": "在看板上追蹤進度", - "withoutGitHubHeading": "不使用 GitHub(現在可用):" + "withoutGitHubHeading": "不使用 GitHub(現在可用):", + "providersSkippedSummary_one_one": "", + "providersSkippedSummary_other_one": "" }, "shell": { "activePill": "作用中", @@ -5449,21 +5393,21 @@ "catalogUnavailable": "目錄暫時無法使用。請稍後重試。", "closeDetail": "關閉技能詳情", "closeView": "關閉技能視圖", - "disableSkill": "停用 {{name}}", "disabled": "技能已停用", + "disableSkill": "停用 {{name}}", "discovered": "已發現", - "discoveredCount": "{{count}} 個已發現的技能", + "discoveredCount_other": "", "discoveredSection": "已發現的技能", - "enableSkill": "啟用 {{name}}", "enabled": "技能已啟用", + "enableSkill": "啟用 {{name}}", "filesLabel": "檔案", "install": "安裝", "installError": "安裝技能失敗", "installFailed": "安裝 {{name}} 失敗: {{message}}", - "installSkill": "安裝 {{name}}", - "installSuccess": "已安裝 {{name}}", "installing": "正在安裝…", "installsCount": "{{count}} 次安裝", + "installSkill": "安裝 {{name}}", + "installSuccess": "已安裝 {{name}}", "loadCatalogError": "載入目錄失敗", "loadContentError": "載入技能內容失敗", "loadDiscoveredError": "載入已發現的技能失敗", @@ -5483,7 +5427,8 @@ "title": "技能", "toggleError": "切換技能失敗", "toggleFailed": "切換技能失敗: {{message}}", - "viewDetails": "查看 {{name}} 的詳情" + "viewDetails": "查看 {{name}} 的詳情", + "discoveredCount_one": "" }, "specEditor": { "edit": "編輯", @@ -5491,8 +5436,8 @@ "feedbackPlaceholder": "例如,'新增關於錯誤處理的詳細資訊'、'將其分為較小的步驟'、'包含 API 端點的測試'...", "keyboardHint": "按 Ctrl+Enter(或 Cmd+Enter)儲存", "placeholder": "以 Markdown 格式輸入工作說明...", - "requestRevision": "要求 AI 修訂", "requesting": "請求中…", + "requestRevision": "要求 AI 修訂", "revisionHelp": "為 AI 提供回饋以改進此說明。該工作將移至規劃階段進行重新規劃。", "revisionTitle": "要求 AI 修訂", "saving": "儲存中…", @@ -5514,15 +5459,17 @@ "dropTitle": "刪除孤立的隱藏?", "failedToLoadDiff": "載入差異失敗", "failedToLoadOrphans": "載入孤立記錄失敗", - "fileCount": "{{count}} 個文件", + "fileCount_other": "", "inspectDiff": "檢查差異", "loadingDiff": "正在加載差異…", "noDiffOutput": "沒有可用的差異輸出。", "noOrphans": "未發現孤立的合併自動隱藏。", - "orphanCount": "{{count}} 個孤立", + "orphanCount_other": "", "shaLabel": "SHA", "title": "貯存恢復", - "unknownSource": "未知來源" + "unknownSource": "未知來源", + "fileCount_one": "", + "orphanCount_one": "" }, "stepType": { "aiPrompt": "AI 提示", @@ -5592,7 +5539,7 @@ "untitled": "無標題" }, "syncLog": { - "entryCount": "{{count}} 筆條目", + "entryCount_other": "", "filterAll": "全部", "filterAllNodes": "全部節點", "filterDirection": "方向:", @@ -5603,7 +5550,8 @@ "noHistory": "沒有同步歷史可用", "resultConflict": "衝突", "resultError": "錯誤", - "resultSuccess": "成功" + "resultSuccess": "成功", + "entryCount_one": "" }, "systemStats": { "agentActive": "活躍", @@ -5624,11 +5572,11 @@ "errorLoadVitestSettings": "載入 vitest 設定失敗", "errorSaveVitestSettings": "儲存 vitest 設定失敗", "footerRefreshFailed": "最新重新整理失敗:{{error}}", + "killedProcesses_other": "", "killThresholdInputAriaLabel": "終止閾值 (%)", "killThresholdLabel": "終止閾值 (%)", "killThresholdSliderAriaLabel": "終止閾值滑桿 (%)", "killVitest": "終止 Vitest 程序", - "killedProcesses": "已終止 {{count}} 個程序", "lastAutoKill": "上次自動終止:{{time}}", "loading": "載入系統統計資訊…", "notYet": "尚未", @@ -5667,23 +5615,19 @@ "title": "系統統計", "updatedAt": "已更新 {{time}}", "vitestProcesses": "Vitest 程序", - "waitingFirstUpdate": "等待首次更新" + "waitingFirstUpdate": "等待首次更新", + "killedProcesses_one": "" }, "taskChanges": { "attributionFailed": "已落地檔案集可能包含外來提交(歸因不可用)。", "disableWordWrap": "停用自動換行", - "emptyWorktreeHint": "實時工作樹差異為空。顯示執行期間擷取的最後檔案路徑—修補程式無法使用。", "enableWordWrap": "啟用自動換行", "error": "載入變更出錯: {{error}}", - "executionFilesHint": "這些是在執行期間從工作樹擷取的檔案。它們可能與實際登陸主分支的檔案不同。此工作的血統支持的差異無法使用。", "expandDiff": "擴展為全螢幕差異視圖", "expandDiffView": "擴展差異視圖", - "fileCount": "{{count}} 個檔案{{plural}}已變更。", - "filesChangedHeading": "已變更檔案 ({{count}})", - "landedFilesHint": "這些是從合併提交元數據中擷取的檔案。此工作的血統支持的差異無法使用。", + "filesChangedHeading_other": "", "loadError": "載入任務變更失敗", "loading": "正在載入變更...", - "merged": "合併於 {{date}}", "mergedAt": "已合併 {{date}}", "nextFile": "下一個檔案", "noExecutionModifications": "代理在執行期間沒有修改任何檔案。", @@ -5694,15 +5638,28 @@ "noWorktree": "此工作沒有可用的工作樹。", "noWorktreeHint": "工作開始後將顯示變更。", "previousFile": "上一個檔案", - "statusUnknown": "狀態未知", "summaryHint": "最終提交摘要: {{files}} 個檔案{{plural}}已變更, +{{additions}} 次新增, -{{deletions}} 次刪除。僅計算已記錄的合併/壓縮提交,不計算完整的工作血統。", "toggleWordWrap": "切換自動換行", - "unavailable": "詳細文件變更無法使用。" + "unavailable": "詳細文件變更無法使用。", + "filesChangedHeading_one": "" }, "taskDetail": { "actions": { "menuBtn": "操作" }, + "agent": { + "assignBtn": "指派代理", + "assignedUpdated": "已更新指派的代理", + "assignFailed": "指派代理失敗:{{error}}", + "label": "代理", + "loadFailed": "載入代理失敗:{{error}}", + "loadingAgents": "正在載入代理...", + "noAgents": "沒有可用的代理", + "unassigned": "代理已取消指派", + "unassignFailed": "取消指派代理失敗:{{error}}", + "unassignTitle": "取消指派代理" + }, + "agentLink": "代理 {{id}}", "ageStaleness": { "active": "活躍", "age": "年齡", @@ -5713,24 +5670,11 @@ "title": "任務年齡陳舊度", "warning": "警告" }, - "agent": { - "assignBtn": "指派代理", - "assignFailed": "指派代理失敗:{{error}}", - "assignedUpdated": "已更新指派的代理", - "label": "代理", - "loadFailed": "載入代理失敗:{{error}}", - "loadingAgents": "正在載入代理...", - "noAgents": "沒有可用的代理", - "unassignFailed": "取消指派代理失敗:{{error}}", - "unassignTitle": "取消指派代理", - "unassigned": "代理已取消指派" - }, - "agentLink": "代理 {{id}}", "attachments": { "attachBtn": "附加截圖", "attached": "截圖已附加", - "deleteTitle": "刪除附件", "deleted": "附件已刪除", + "deleteTitle": "刪除附件", "heading": "附件", "none": "(無附件)", "uploading": "正在上傳…" @@ -5749,9 +5693,11 @@ "reattachBtn": "重新綁定分支", "reattached": "已為 {{id}} 重新綁定分支 ({{branch}})", "reattachedResult": "已重新綁定 {{branch}}(領先 {{base}} {{count}} 次提交)。", + "reattachedResult_other": "", "reattaching": "正在重新綁定…", "skipped": "{{id}} 的分支重新綁定已跳過:{{reason}}", - "skippedResult": "重新綁定已跳過:{{reason}}" + "skippedResult": "重新綁定已跳過:{{reason}}", + "reattachedResult_one": "" }, "cacheBreakdown": "(讀取 {{read}} / 寫入 {{write}} / 輸入 {{input}})", "cacheHitRatio": "快取命中率:", @@ -5764,21 +5710,21 @@ "actionLeft": "保留了", "allowRecreation": "允許稍後重新建立(操作員解鎖)", "allowRecreationDesc": "允許代理在沒有 --force-resurrect 的情況下重新建立此任務 ID。取消勾選以保持此任務的墓碑狀態。", + "archivedAfterUnlink": "在取消世系引用關聯後已封存 {{id}}", "archiveInstead": "改為封存", "archiveUnlinkPrompt": "先取消這些引用的關聯後封存?", - "archivedAfterUnlink": "在取消世系引用關聯後已封存 {{id}}", "ariaLabel": "刪除任務", "btn": "刪除", "closeIssue": "關閉 Issue", "confirm": "刪除", + "deletedAfterRemovingDeps": "在刪除依賴引用後已刪除 {{id}}", + "deletedAfterUnlinkLineage": "在取消世系引用關聯後已刪除 {{id}}", + "deletedToast": "已刪除 {{id}}{{suffix}}", "deleteIssue": "刪除 Issue", "deleteLinkedIssueMessage": "在 GitHub 上刪除 {{issueRef}},還是保持不變?", "deleteLinkedIssueTitle": "刪除關聯的 GitHub Issue", "deleteUnlinkDepsPrompt": "先刪除這些依賴引用後再刪除?", "deleteUnlinkLineagePrompt": "先取消這些引用的關聯後再刪除?", - "deletedAfterRemovingDeps": "在刪除依賴引用後已刪除 {{id}}", - "deletedAfterUnlinkLineage": "在取消世系引用關聯後已刪除 {{id}}", - "deletedToast": "已刪除 {{id}}{{suffix}}", "forceDeleteTitle": "強制刪除任務", "issueSuffix": "並{{action}} issue {{ref}}", "leaveUnchanged": "保持不變", @@ -5816,8 +5762,8 @@ "autosaveHint": "編輯時自動儲存更改", "autosaving": "正在自動儲存…", "nodeOverrideLocked": "任務活躍/進行中時,執行節點覆蓋已鎖定。", - "saveFailed": "儲存失敗", "saved": "已儲存", + "saveFailed": "儲存失敗", "saving": "正在儲存…", "sourceExternalIdPlaceholder": "Issue 識別碼", "sourceIssueHint": "將所有欄位留空以清除來源 issue 元數據。", @@ -5892,7 +5838,8 @@ "activityHeading": "活動", "agentLog": "代理日誌", "noActivity": "(無活動)", - "truncated": "顯示最近 {{count}} 筆活動記錄。" + "truncated_other": "", + "truncated_one": "" }, "longestTimingEvent": "最長計時事件", "longestWorkflowStep": "最長工作流程步驟", @@ -5908,8 +5855,8 @@ "backToInProgress": "返回進行中", "cancelMove": "取消移動", "keepProgress": "保留進度", - "moveTo": "移至 {{column}}", "movedTo": "已移至 {{column}}", + "moveTo": "移至 {{column}}", "preserveProgressMessage": "此任務有已完成的步驟。移動前保留進度?", "preserveProgressTitle": "保留進度?", "resetProgress": "重置進度", @@ -5920,9 +5867,9 @@ "actions": "選擇「封存」將此任務封存,或選擇「保留」繼續使用此任務。", "archiveBtn": "封存", "archiveConfirm": "封存", + "archived": "已封存 {{id}}", "archiveMessage": "將 {{id}} 作為 {{duplicateOf}} 的重複封存?", "archiveTitle": "封存近似重複任務", - "archived": "已封存 {{id}}", "copy": "此任務看起來是以下任務的近似重複:", "headline": "檢測到潛在重複", "keepBtn": "保留", @@ -5941,8 +5888,8 @@ "noSteps": "無步驟", "noTimedEvents": "尚無已記錄的計時事件。", "noTokenUsage": "此任務尚無 Token 使用記錄。", - "noWorkflowStepTimings": "尚無已完成的工作流程步驟計時。", "notSet": "未設定", + "noWorkflowStepTimings": "尚無已完成的工作流程步驟計時。", "outputTokens": "輸出", "pause": { "pauseBtn": "暫停", @@ -5959,9 +5906,9 @@ "rebuildMessage": "重建此任務的計劃?任務將進入規劃階段重新規劃。", "rebuildTitle": "重建計劃", "rejectBtn": "拒絕計劃", + "rejected": "計劃已拒絕 — {{id}} 已返回規劃階段重新規劃", "rejectMessage": "拒絕此計劃?規範將被丟棄並重新生成。", "rejectTitle": "拒絕計劃", - "rejected": "計劃已拒絕 — {{id}} 已返回規劃階段重新規劃", "replanning": "正在為 {{id}} 重新規劃…" }, "pr": { @@ -5983,31 +5930,17 @@ "progress": { "heading": "進度", "noSteps": "(未定義步驟)", - "stepCount": "{{count}}/{{total}} 步驟" + "stepCount_other": "", + "stepCount_one": "" }, "provenance": { - "agent": "代理", - "api": "API", - "automation": "自動化", - "chatSession": "聊天工作階段", - "cli": "CLI", "createdBy": "創建者:", - "createdVia": "通過…建立", - "dashboard": "儀表板", - "duplicate": "重複", - "githubImport": "GitHub 匯入", - "openIssue": "未解決問題", - "quickChat": "快速聊天", - "recovery": "復原", - "refinement": "精煉", - "research": "研究", - "scheduledTask": "排程工作", - "workflowStep": "工作流程步驟" + "createdVia": "通過…建立" }, "recoveryState": "恢復狀態", "refine": { "btn": "精化", - "charCount": "{{count}}/2000 字元", + "charCount_other": "", "createBtn": "建立精化任務", "creating": "正在建立...", "feedbackRequired": "請輸入描述需要精化內容的反饋", @@ -6015,7 +5948,8 @@ "help": "描述需要精化或改善的內容...", "modalTitle": "精化", "placeholder": "在此輸入您的反饋...", - "taskCreated": "精化任務已建立:{{id}}" + "taskCreated": "精化任務已建立:{{id}}", + "charCount_one": "" }, "reset": { "btn": "重置", @@ -6082,8 +6016,8 @@ "loading": "正在載入規範…", "noPrompt": "(無提示詞)", "placeholder": "以 Markdown 格式輸入任務規範...", - "requestRevisionBtn": "請求 AI 修訂", "requesting": "正在請求…", + "requestRevisionBtn": "請求 AI 修訂", "revisionColumnError": "無法請求修訂:任務必須在「triage」、「todo」、「in-progress」或「in-review」欄中。", "revisionRequested": "已請求 AI 修訂。任務已移至規劃階段。", "saving": "正在儲存…", @@ -6129,8 +6063,8 @@ "wallClockSinceFirst": "自首次執行的實際時間", "workflow": { "loadFailed": "載入工作流程結果失敗:{{error}}", - "stepsUpdateFailed": "更新工作流程步驟失敗:{{error}}", - "stepsUpdated": "工作流程步驟已更新" + "stepsUpdated": "工作流程步驟已更新", + "stepsUpdateFailed": "更新工作流程步驟失敗:{{error}}" }, "workflowRuntime": "工作流程執行時間", "workflowTimedSteps": "工作流程計時步驟", @@ -6181,8 +6115,8 @@ "taskForm": { "addDependencies": "新增相依項", "attachHint": "您也可以貼上圖片或拖放", - "attachScreenshot": "附加截圖", "attachmentsLabel": "附件", + "attachScreenshot": "附加截圖", "autoMergeDefault": "預設(遵循專案設定)", "autoMergeDisabled": "停用", "autoMergeEnabled": "啟用", @@ -6205,7 +6139,7 @@ "branchStrategyLabel": "分支策略", "collapseDescription": "收合描述", "dependenciesLabel": "相依關係", - "dependenciesSelected": "已選 {{count}} 項", + "dependenciesSelected_other": "", "descriptionLabel": "描述", "descriptionPlaceholder": "需要完成什麼?", "descriptionRefinedToast": "AI 已最佳化描述", @@ -6228,17 +6162,11 @@ "moveDown": "下移", "moveUp": "上移", "noAvailableTasks": "沒有可用任務", - "noModelsAvailable": "沒有可用模型。請在設定中設定驗證。", "nodeDefaultOption": "使用專案預設 / 本機", "nodeOverrideHint": "任務覆寫優先於專案預設節點路由。", "nodeOverrideLabel": "執行節點覆寫", - "nodeStatusConnecting": "連線中", - "nodeStatusError": "錯誤", - "nodeStatusOffline": "離線", - "nodeStatusOnline": "線上", + "noModelsAvailable": "沒有可用模型。請在設定中設定驗證。", "overridePreset": "覆寫", - "phasePostMerge": "合併後", - "phasePreMerge": "合併前", "planButton": "規劃", "planningLabel": "規劃", "planningModelLabel": "規劃模型", @@ -6246,10 +6174,6 @@ "presetLabel": "預設", "presetUseDefault": "使用預設", "priorityLabel": "優先順序", - "priority_high": "高", - "priority_low": "低", - "priority_normal": "一般", - "priority_urgent": "緊急", "refineAddDetailsDesc": "新增實作細節與背景資訊", "refineAddDetailsTitle": "新增細節", "refineButton": "最佳化", @@ -6264,13 +6188,13 @@ "removeImage": "移除圖片", "removeStep": "移除", "reviewDefault": "預設(自動 — 由分類決定)", + "reviewerLabel": "審閱器", + "reviewerModelLabel": "審閱器模型", "reviewLabel": "審查", "reviewLevel0": "0 — 無", "reviewLevel1": "1 — 僅計畫", "reviewLevel2": "2 — 計畫與程式碼", "reviewLevel3": "3 — 完整", - "reviewerLabel": "審閱器", - "reviewerModelLabel": "審閱器模型", "searchTasksPlaceholder": "搜尋任務…", "sharedBranchPlaceholder": "例如 clionboarding", "sharedFeatureBranchLabel": "共享功能分支", @@ -6288,7 +6212,8 @@ "usingPreset": "使用預設:{{name}}", "workflowStepsDescription": "選擇任務實作完成後要執行的步驟", "workflowStepsLabel": "工作流程步驟", - "workingBranchLabel": "工作分支" + "workingBranchLabel": "工作分支", + "dependenciesSelected_one": "" }, "taskHandlers": { "githubImported": "從 GitHub 導入了 {{id}}" @@ -6297,96 +6222,91 @@ "autoMergeOff": "自動合併關閉", "autoMergeOn": "自動合併開啟", "autoMergePreferenceUpdated": "按工作自動合併偏好已更新", - "completed": "已完成", "completedAtSep": " · 已完成:{{timestamp}}", "createPr": "建立拉取請求", "effective": "有效:{{label}}", "effectiveFrozen": "有效:{{label}} — 進入評審時凍結", - "error": "錯誤", "errorSep": " · 錯誤:{{message}}", "followDefault": "跟隨預設設定", - "lastRefreshed": "上次重新整理", "loadError": "載入評審資料失敗。", "loadingData": "載入評審資料中…", "markdown": "Markdown", - "never": "從未", "noCapturedFeedback": "尚未捕獲任何評審回饋。", "noFeedbackDirect": "尚無評審回饋 — 此工作未在直接模式下生成評審代理回饋。", "noReviewItems": "尚無評審項目。", "perTaskAutoMerge": "按工作自動合併", "plain": "純文字", - "prSummaryLine": "{{decision}} · {{count}} 則審查項目", + "prSummaryLine_other": "", "queueing": "加入隊列中…", "refresh": "重新整理", "refreshDataFailed": "重新整理評審資料失敗。", - "refreshFailed": "重新整理失敗", - "refreshSourceBackground": "背景", - "refreshSourceInitialLoad": "初始載入", - "refreshSourceManual": "手動", - "refreshStatusLine": "{{status}} · 最後重新整理:{{timestamp}} · {{source}}", "refreshed": "評審已重新整理", + "refreshFailed": "重新整理失敗", "refreshing": "重新整理中…", + "refreshStatusLine": "{{status}} · 最後重新整理:{{timestamp}} · {{source}}", "requestRevision": "請求修訂", - "reviewerSummaryLine": "{{reviewer}} · {{count}} 則審查項目", + "reviewerSummaryLine_other": "", "revisionQueueFailed": "無法將修訂加入隊列", "revisionStarted": "從選定的評審回饋開始了同工作人工智慧修訂", - "selected": "已選擇", "selectedAt": "已選擇:{{timestamp}}", "showMarkdown": "顯示格式化的 Markdown", "showRawText": "顯示原始文字", - "started": "已開始", "startedAtSep": " · 已開始:{{timestamp}}", + "updateFailed": "更新 {{taskId}} 失敗:{{error}}", "upToDate": "最新", - "updateFailed": "更新 {{taskId}} 失敗:{{error}}" + "prSummaryLine_one": "", + "reviewerSummaryLine_one": "" }, "tasks": { "addTaskPlaceholder": "新增任務……", "agent": "代理程式", "agentLabel": "代理程式", "archive": "封存", + "archived": "已封存 {{taskId}}", + "archivedUnlinked": "已解除譜系參照並封存 {{taskId}}", "archiveFailed": "封存 {{taskId}} 失敗:{{error}}", "archiveLineageConflict": "{{taskId}} 有子項目({{children}})將其參照為來源父項。\n\n仍要先解除這些參照並封存嗎?", "archiveTask": "封存任務", - "archived": "已封存 {{taskId}}", - "archivedUnlinked": "已解除譜系參照並封存 {{taskId}}", "assignedTo": "已指派給 {{name}}", "attach": "附件", - "attachCount": "附件 ({{count}})", - "attachFileFailed": "附加 {{fileName}} 失敗:{{error}}", + "attachCount_other": "", "attachedFile": "已將 {{fileName}} 附加至 {{taskId}}", + "attachFileFailed": "附加 {{fileName}} 失敗:{{error}}", "awaitingApproval": "等待核准", "baseBranch": "基礎", "blockedByTooltip": "被 {{taskId}} 阻擋(檔案重疊)", "branch": "分支", "branchMetadata": "分支中繼資料", + "branchProgress": "", + "branchProgressTitle": "", "cancelMove": "取消移動", "clearSelection": "清除選取", "closeIssue": "關閉 Issue", "collapse": "收合", + "createdByAgent": "由代理程式建立", + "createdByAgentNamed": "由代理程式建立:{{name}}", + "createdPr": "已建立 PR #{{number}}", "createFailed": "建立任務失敗", "createPr": "建立 PR", "createPrAriaLabel": "建立 Pull Request", "createPrTitle": "為此任務建立 PR", "createTaskTitle": "建立任務", - "createdByAgent": "由代理程式建立", - "createdByAgentNamed": "由代理程式建立:{{name}}", - "createdPr": "已建立 PR #{{number}}", "creating": "建立中……", "decisionOnly": "僅決策", "decisionOnlyTitle": "純決策任務", "deleteConfirm": "刪除 {{taskId}}?", + "deleted": "已刪除 {{taskId}}{{suffix}}", + "deletedRemovedDeps": "已移除相依參照並刪除 {{taskId}}", + "deletedUnlinked": "已解除譜系參照並刪除 {{taskId}}", "deleteFailed": "刪除 {{taskId}} 失敗:{{error}}", "deleteIssue": "刪除 Issue", "deleteLinkedIssueMessage": "在 GitHub 上刪除 {{issueLabel}},還是保持不變?", "deleteLinkedIssueTitle": "刪除關聯的 GitHub Issue", "deleteTask": "刪除任務", "deleteTitle": "刪除任務", - "deleted": "已刪除 {{taskId}}{{suffix}}", - "deletedRemovedDeps": "已移除相依參照並刪除 {{taskId}}", - "deletedUnlinked": "已解除譜系參照並刪除 {{taskId}}", "dependencyConflict": "{{taskId}} 是 {{dependentList}} 的相依項目。\n\n仍要先移除這些相依參照並刪除嗎?", "deps": "依賴", - "depsCount": "{{count}} 個依賴", + "depsCount_other": "", "descriptionPlaceholder": "任務描述", "descriptionRefined": "已用 AI 優化說明", "doneNoMerge": "完成(不合併)", @@ -6406,11 +6326,11 @@ "fanoutEscalated": "升級的重疊", "fanoutEscalationSuffix": " · 在阻擋欄中 {{minutes}} 分鐘後升級", "fanoutHighFanoutSuffix": "(重疊瓶頸閾值:{{threshold}})", - "fanoutStale": "{{count}} 過期", - "fanoutTooltip": "阻擋 {{count}} 個活躍任務;重疊阻擋佇列:{{queueCount}} 待辦{{highFanout}}{{escalation}}", + "fanoutStale_other": "", + "fanoutTooltip_other": "", "fast": "快速", "fastMode": "快速模式", - "filesChanged": "{{count}} 個檔案已變更", + "filesChanged_other": "", "forceDeleteTitle": "強制刪除任務", "githubTrackingDefaultOff": "關", "githubTrackingDefaultOn": "開", @@ -6438,23 +6358,24 @@ "loadAgentsFailed": "載入代理程式失敗:{{msg}}", "loadAgentsFailedGeneric": "載入代理程式失敗", "loadDependencyFailed": "載入相依項目 {{depId}} 失敗", - "loadModelsFailed": "載入模型失敗", "loadingAgents": "正在載入代理程式……", + "loadModelsFailed": "載入模型失敗", "missionBadgeTitle": "任務:{{name}}", "modelExecutor": "執行器", "modelPlan": "規劃", "modelReviewer": "審閱者", "models": "模型", - "modelsCount": "{{count}} 個模型", + "modelsCount_other": "", "moreOptions": "更多選項", "move": "移動", + "moved": "已將 {{taskId}} 移動至 {{column}}", "moveFailed": "移動 {{taskId}} 失敗:{{error}}", "moveTask": "移動任務", - "moved": "已將 {{taskId}} 移動至 {{column}}", "nearDuplicateTitle": "疑似與 {{id}} 重複", + "needsInput": "", "noAgentsAvailable": "目前沒有可用的代理程式", - "noExistingTasks": "目前沒有任務", "node": "節點", + "noExistingTasks": "目前沒有任務", "openRetryBreakdown": "查看重試詳情", "paused": "已暫停", "pausedByAgent": "已被代理程式暫停", @@ -6482,7 +6403,7 @@ "resetProgress": "重設進度", "resetProgressMessage": "移動此任務前重設所有步驟進度?", "resetProgressTitle": "重設進度?", - "retriesAriaLabel": "{{count}} 次重試", + "retriesAriaLabel_other": "", "retry": "重試", "retryFailed": "重試 {{taskId}} 失敗:{{error}}", "retrying": "重試中…", @@ -6498,22 +6419,30 @@ "showSteps": "顯示步驟", "stalled": "停滯", "statusMergingFix": "正在合併修正…", - "stepCount": "{{count}} 個步驟", + "stepCount_other": "", "stuck": "卡住", "subtask": "子任務", "subtaskButtonTitle": "拆分為 AI 生成的子任務", "toggleFastMode": "切換快速執行模式", "unarchive": "取消封存", + "unarchived": "已取消封存 {{taskId}}", "unarchiveFailed": "取消封存 {{taskId}} 失敗:{{error}}", "unarchiveTask": "取消封存任務", - "unarchived": "已取消封存 {{taskId}}", - "updateFailed": "更新 {{taskId}} 失敗:{{error}}", "updated": "已更新 {{taskId}}", + "updateFailed": "更新 {{taskId}} 失敗:{{error}}", "uploadFailed": "上傳失敗:{{files}}", "usingDefault": "使用預設", "viewDependency": "點擊查看 {{depId}}", "workflow": "工作流程", - "workflowCheck": "工作流程檢查" + "workflowCheck": "工作流程檢查", + "attachCount_one": "", + "depsCount_one": "", + "fanoutStale_one": "", + "fanoutTooltip_one": "", + "filesChanged_one": "", + "modelsCount_one": "", + "retriesAriaLabel_one": "", + "stepCount_one": "" }, "terminal": { "clear": "清空", @@ -6539,37 +6468,14 @@ "statusReconnecting": "正在重新連線..." }, "theme": { - "colorTheme": { - "default": "預設" - }, + "colorTheme": "", "colorThemeLabel": "色彩主題", "currentTheme": "目前主題", - "dark": "深色", - "darkMode": "深色模式", - "fontSize": { - "Default": "預設", - "Large": "大", - "Largest": "最大", - "Small": "小" - }, + "fontSize": "", "fontSizeLabel": "儀表板字型大小", - "light": "淺色", - "lightMode": "淺色模式", "modeLabel": "主題模式", "resetButton": "重設為預設值", - "resetLabel": "重設為預設主題", - "system": "系統", - "systemMode": "系統模式" - }, - "time": { - "daysAgo": "{{n}}天前", - "hoursAgo": "{{n}}小時前", - "inAMoment": "即將", - "inDays": "{{n}}天後", - "inHours": "{{n}}小時後", - "inMinutes": "{{n}}分鐘後", - "justNow": "剛剛", - "minutesAgo": "{{n}}分鐘前" + "resetLabel": "重設為預設主題" }, "todo": { "addItemPlaceholder": "新增待辦事項", @@ -6593,6 +6499,7 @@ "failedDeleteItemToast": "刪除待辦項目失敗", "failedDeleteList": "刪除清單失敗", "failedDeleteListToast": "刪除待辦事項清單失敗", + "failedLoadLists": "", "failedRenameList": "重新命名清單失敗", "failedRenameListToast": "重新命名待辦事項清單失敗", "failedReorderItems": "重新排序項目失敗", @@ -6653,19 +6560,20 @@ "resetsInDaysHours": "在 {{days}} 天 {{hours}} 小時後重置", "resetsInHours": "在 {{hours}} 小時後重置", "resetsInMinutes": "在 {{mins}} 分鐘後重置", - "showHidden": "顯示隱藏的 ({{count}})", + "showHidden_other": "", "statusError": "錯誤", "statusNotConfigured": "未設定", "title": "使用情況", "viewModeLabel": "使用情況檢視模式", "viewModeRemaining": "剩餘", - "viewModeUsed": "已使用" + "viewModeUsed": "已使用", + "showHidden_one": "" }, "workflow": { "add": "新增", + "adding": "新增中...", "addTemplate": "新增範本", "addWorkflowStep": "新增工作流程步驟", - "adding": "新增中...", "advisoryExplanation": "建議工作流程步驟標記了非阻塞改進:", "agentPromptLabel": "Agent 提示", "agentPromptPlaceholder": "留空以使用 AI 自動優化", @@ -6719,6 +6627,7 @@ "gateModeAdvisoryHint": "失敗將被記錄為建議性,不會阻止合併。", "gateModeGate": "閘控", "gateModeGateHint": "失敗會阻止合併並請求修復。", + "graphEditor": "", "hideOutput": "隱藏輸出", "loadingBuiltInTemplates": "正在載入內建範本...", "loadingResults": "載入工作流程結果…", @@ -6727,12 +6636,12 @@ "modalAriaLabel": "工作流程步驟", "modalTitle": "工作流程步驟", "modeAiPrompt": "AI 提示", - "modeScript": "執行腳本", "modelHintCustom": "正在使用 {{provider}}/{{modelId}}", "modelHintDefault": "正在使用全域預設模型", "modelOverrideDropdownLabel": "此工作流程步驟的模型覆蓋", "modelOverrideLabel": "模型覆蓋", "modelOverridePlaceholder": "選擇模型覆蓋…", + "modeScript": "執行腳本", "moveDown": "向下移動", "moveUp": "向上移動", "needsReview": "需要後續審查。", @@ -6749,8 +6658,6 @@ "phasePreMergeHint": "合併前執行——失敗時可阻止合併", "plain": "純文字", "polishNotes": "潤色筆記", - "postMerge": "合併後", - "preMerge": "合併前", "promptRefined": "提示已透過 AI 優化", "refineWithAi": "使用 AI 優化", "refineWithAiAriaLabel": "使用 AI 優化提示", @@ -6761,31 +6668,87 @@ "selectStepsDescription": "選擇任務實作完成後要執行的步驟", "showOutput": "顯示輸出", "started": "開始於:", - "statusAdvisory": "建議失敗", - "statusFailed": "失敗", - "statusPassed": "已通過", - "statusRunning": "執行中…", - "statusSkipped": "已略過", - "stepCount": "{{count}} 個步驟", + "stepCount_other": "", "stepCreated": "工作流程步驟已建立", "stepDefinitionNotFound": "找不到步驟定義。", "stepDeleted": "工作流程步驟已刪除", - "stepUpdated": "工作流程步驟已更新", "steps": "工作流程步驟", "stepsExplanation": "合併前步驟在實作後、合併前執行。合併後步驟在合併成功後執行。", - "summaryAdvisory": "{{count}} 建議", - "summaryFailed": "{{count}} 失敗", - "summaryPassed": "{{count}} 通過", - "summaryRunning": "{{count}} 執行中", + "stepUpdated": "工作流程步驟已更新", + "summaryAdvisory_other": "", + "summaryFailed_other": "", + "summaryPassed_other": "", + "summaryRunning_other": "", "summarySeparator": " · ", - "summarySkipped": "{{count}} 已跳過", + "summarySkipped_other": "", + "summaryStepCount_other": "", "switchToMarkdown": "切換為 Markdown", "switchToPlain": "切換為純文字", - "tabMySteps": "我的工作流程步驟({{count}})", - "tabTemplates": "範本({{count}})", + "tabMySteps_other": "", + "tabTemplates_other": "", "templateAdded": "已新增工作流程步驟「{{name}}」", "useDefault": "使用預設", - "waitingForOutput": "等待代理輸出…" + "stepCount_one": "", + "summaryAdvisory_one": "", + "summaryFailed_one": "", + "summaryPassed_one": "", + "summaryRunning_one": "", + "summarySkipped_one": "", + "summaryStepCount_one": "", + "tabMySteps_one": "", + "tabTemplates_one": "" + }, + "workflowColumns": { + "add": "", + "compositionBlocked": "", + "empty": "", + "moveDown": "", + "moveUp": "", + "nameLabel": "", + "newColumnName": "", + "nodeUnplaced": "", + "readOnlyHint": "", + "remove": "", + "title": "", + "traits": "", + "traitsLoadFailed": "", + "unplacedCount_other": "", + "unplacedCount_one": "" + }, + "workflowNodes": { + "advisory": "", + "failureCollect": "", + "failureFailFast": "", + "failurePolicy": "", + "gateBlocks": "", + "gateMode": "", + "joinAll": "", + "joinAny": "", + "joinMode": "", + "joinQuorum": "", + "mergeBoundaryNote": "", + "quorumN": "", + "releaseCapacity": "", + "releaseCondition": "", + "releaseDependency": "", + "releaseExternal": "", + "releaseManual": "", + "releaseTimer": "", + "splitNote": "" + }, + "workflows": { + "duplicateToCustomize": "", + "readOnlyBuiltin": "", + "saved": "", + "savedNotCompilable": "", + "saveFailed": "", + "selectOrCreate": "" + }, + "workflowSelector": { + "switchActiveMessage": "", + "switchActiveTitle": "", + "switchCancel": "", + "switchConfirm": "" }, "workspace": { "projectRoot": "項目根目錄", diff --git a/packages/i18n/locales/zh-TW/cli.json b/packages/i18n/locales/zh-TW/cli.json index fb1830bc83..4c68317b36 100644 --- a/packages/i18n/locales/zh-TW/cli.json +++ b/packages/i18n/locales/zh-TW/cli.json @@ -20,11 +20,11 @@ "agentRunId": "ID:", "agentRunLogsBackHint": "[Esc/q] 返回執行列表", "agentRunLogsTitle": "執行日誌({{index}})", + "agentsFooterHints": "[s] 啟動 [x] 停止 [D] 刪除 [r] 重新整理 [Tab] 焦點 ↑↓ 選擇", + "agentsListTitle_other": "", + "agentsNoAgents": "找不到代理。", "agentStarted": "代理已啟動", "agentStopped": "代理已停止", - "agentsFooterHints": "[s] 啟動 [x] 停止 [D] 刪除 [r] 重新整理 [Tab] 焦點 ↑↓ 選擇", - "agentsListTitle": "代理({{count}})", - "agentsNoAgents": "找不到代理。", "boardCreateTaskHints": "Enter 建立 · Esc 取消", "boardCreateTaskNoProject": "未選擇專案", "boardCreateTaskTitleEmpty": "標題不能為空", @@ -33,6 +33,7 @@ "boardNewTaskProject": "專案:{{name}}", "boardNewTaskTitle": "新增任務", "boardNewTaskTitleLabel": "標題", + "boardOtherReadOnlyHint": "", "copiedSuccess": "✓ 已複製!", "copyFailed": "✗ 複製失敗", "expandedLogHeader": "條目 {{index}}/{{total}} · [Enter/Esc] 關閉 · [c] 複製", @@ -43,26 +44,26 @@ "filesEmpty": "(空)", "filesEmptyFile": "(空檔案)", "filesFooterHints": "[Tab] 切換面板 [↑↓/jk] 移動 [Enter] 開啟 [←/→] 折疊/展開 [.] 隱藏檔案 [w] 換行 [p] 專案 [r] 重新載入", - "filesMoreLines": "… 還有 {{count}} 行", + "filesMoreLines_other": "", "filesSelectProject": "選擇專案", "filesSelectToPreview": "選擇檔案以預覽", "filesTooLarge": "{{size}} — [檔案過大,無法預覽]", "filesUnableToRead": "無法讀取檔案", - "gitFetchFailed": "擷取失敗:{{output}}", "gitFetched": "已擷取", + "gitFetchFailed": "擷取失敗:{{output}}", "gitFetching": "正在擷取…", "gitFooterHints": "[r] 重整 {{push}}[F] 擷取 [↑↓] 列 [←→] 狀態▸分支{{worktrees}}▸提交▸變更 [p] 專案 [Esc/s] 返回", "gitNoCommits": "無提交", "gitNoProject": "無專案", "gitPushDismissHint": "[Esc] 關閉", "gitPushFailed": "推送失敗", + "gitPushingToOrigin": "正在推送至 origin/{{branch}}", "gitPushModalAhead": "領先", "gitPushModalBranch": "分支:", "gitPushModalCommits": "待推送提交(從舊到新):", "gitPushModalHints": "[Enter] 推送 [Esc] 取消", "gitPushModalTitle": "推送至遠端", "gitPushSuccessful": "推送成功", - "gitPushingToOrigin": "正在推送至 origin/{{branch}}", "gitRefreshing": "重新整理中", "gitWorkingTreeClean": "工作區乾淨", "headerHelpQuitHint": "[?] 說明 [q] 退出", @@ -117,14 +118,13 @@ "projectSelectorChangeHint": "[p] 切換", "projectSelectorLabel": "專案:", "projectSelectorNavHints": "↑↓ 導航 · Enter 選擇 · Esc 取消", - "projectSelectorNoProjects": "(無已登錄專案)", "projectSelectorNone": "(無)", + "projectSelectorNoProjects": "(無已登錄專案)", "projectSelectorPickTitle": "選擇專案", "qrCloseHint": "[Esc] 關閉", "qrGenerating": "正在產生 QR 碼…", "qrNoTunnelRunning": "沒有執行中的遠端通道。請在設定(g)中啟動。", "qrOverlayTitle": "遠端存取 — 掃碼連線", - "quit": "結束", "readyIn": "就緒,耗時 {{secs}} 秒", "runLogNone": "此次執行未擷取日誌。", "runLogResult": "結果:", @@ -137,16 +137,6 @@ "runStatusFailed": "失敗", "runStatusTerminated": "已終止", "runStatusUnknown": "未知", - "settingAutoMerge": "自動合併", - "settingEnginePaused": "引擎已暫停", - "settingGlobalPause": "全域暫停", - "settingMaxConcurrent": "最大並行數", - "settingMaxWorktrees": "最大工作樹數", - "settingMergeStrategy": "合併策略", - "settingPollIntervalMs": "輪詢間隔(毫秒)", - "settingRemoteActiveProvider": "遠端提供者", - "settingRemoteShortLivedEnabled": "短期權杖", - "settingRemoteShortLivedTtlMs": "短期權杖 TTL(毫秒)", "settingsActivatedProvider": "已啟用提供商:{{provider}}", "settingsAdjust1": "[+/-] 調整 1", "settingsAdjust5000ms": "[+/-] 調整 5000ms", @@ -161,7 +151,7 @@ "settingsFooterHints": "[Tab] 切換面板 ↑↓ 選擇設定 [Space] 切換布林 [+/-] 調整數值 [←/→] 循環枚舉 [C/V/X/P/L/U/K/R] 遠端操作", "settingsInteractivePanelTitle": "設定", "settingsLoadingSettings": "正在載入設定…", - "settingsMoreModels": "… 還有 {{count}} 個", + "settingsMoreModels_other": "", "settingsPanelTitle": "設定", "settingsPersistentTokenRegenerated": "持久金鑰已重新產生", "settingsQrFetched": "QR 資料已取得", @@ -228,6 +218,9 @@ "utilitiesKillVitest": "終止 Vitest 行程", "utilitiesPanelTitle": "工具", "utilitiesRefreshStats": "重新整理統計", - "utilitiesToggleEnginePause": "切換引擎暫停" + "utilitiesToggleEnginePause": "切換引擎暫停", + "agentsListTitle_one": "", + "filesMoreLines_one": "", + "settingsMoreModels_one": "" } } diff --git a/packages/i18n/locales/zh-TW/common.json b/packages/i18n/locales/zh-TW/common.json index 867475c99c..3d99717b89 100644 --- a/packages/i18n/locales/zh-TW/common.json +++ b/packages/i18n/locales/zh-TW/common.json @@ -4,8 +4,65 @@ "close": "關閉", "save": "儲存" }, + "agents": { + "ratings": { + "trendDeclining": "", + "trendImproving": "", + "trendInsufficient": "", + "trendStable": "" + }, + "reflections": { + "triggerManual": "", + "triggerPeriodic": "", + "triggerPostTask": "", + "triggerUserRequested": "" + }, + "time": { + "daysAgo_one": "", + "daysAgo_other": "", + "hoursAgo_one": "", + "hoursAgo_other": "", + "inAMoment": "", + "inDays_one": "", + "inDays_other": "", + "inHours_one": "", + "inHours_other": "", + "inMinutes_one": "", + "inMinutes_other": "", + "justNow": "", + "minutesAgo_one": "", + "minutesAgo_other": "" + } + }, "archive": "封存", + "board": { + "rejection": { + "capacityExhausted": "", + "guardRejected": "", + "mergeBlocked": "", + "unknownColumn": "", + "workflowMismatch": "" + } + }, "cancel": "取消", + "chat": { + "failedToGetResponse": "", + "failureReferenceId": "", + "failureReferenceKind": "", + "failureReferenceLabel": "", + "failureReferenceMetaLabel": "", + "openMailboxMessage": "", + "toolCallArgsPrefix": "", + "toolCallResultPrefix": "", + "toolCallStatusCompleted": "", + "toolCallStatusError": "", + "toolCallStatusErrors": "", + "toolCallStatusRunning": "", + "toolCallsCount_one": "", + "toolCallsCount_other": "", + "toolCallsHeader": "", + "viewFailureDetails": "" + }, "close": "關閉", "columns": { "archived": "已封存", @@ -16,8 +73,162 @@ "triage": "規劃" }, "delete": "刪除", + "health": { + "anomaly": { + "duplicateActiveId": "", + "idInBothStorages": "", + "sequenceOverlap": "", + "unknownPrefix": "" + } + }, + "inline": { + "connecting": "", + "error": "", + "offline": "", + "online": "" + }, + "merge": { + "unknown": "" + }, + "missions": { + "autopilotStateActivating": "", + "autopilotStateCompleting": "", + "autopilotStateInactive": "", + "autopilotStateUnknown": "", + "autopilotStateWatching": "", + "interviewStatusAwaitingInput": "", + "interviewStatusComplete": "", + "interviewStatusError": "", + "interviewStatusGenerating": "", + "runHelperActive": "", + "runHelperBlocked": "", + "runHelperPlanning": "" + }, + "models": { + "messages": { + "modelSetTo": "", + "modelSetToDefault": "" + } + }, + "nodeStatus": { + "connecting": "", + "error": "", + "offline": "", + "online": "", + "unknown": "" + }, + "nodes": { + "auth": { + "differ": "", + "differProviders": "", + "match": "", + "notSynced": "" + }, + "status": { + "connecting": "", + "creating": "", + "deleting": "", + "error": "", + "exited": "", + "offline": "", + "online": "", + "recreating": "", + "running": "", + "stopped": "" + } + }, "refresh": "重新整理", + "research": { + "providerGitHub": "", + "providerLlmSynthesis": "", + "providerLocalDocs": "", + "providerPageFetch": "", + "providerWebSearch": "" + }, "retry": "重試", + "routing": { + "policyLabel": { + "block": "", + "fallback": "", + "notConfigured": "" + } + }, + "setup": { + "apiKeyFormatError": "", + "apiKeyLabel": { + "fallback": "", + "kimiCoding": "", + "minimax": "", + "ollama": "", + "openai": "", + "openrouter": "", + "zai": "" + }, + "apiKeyPlaceholder": { + "fallback": "", + "kimiCoding": "", + "minimax": "", + "zai": "" + }, + "apiKeyRequired": "", + "apiKeySetup": { + "fallback": "", + "kimiCoding": "", + "minimax": "", + "ollama": "", + "openai": "", + "openrouter": "", + "zai": "" + }, + "apiKeyUsage": { + "fallback": "", + "kimiCoding": "", + "minimax": "", + "ollama": "", + "openai": "", + "openrouter": "", + "zai": "" + }, + "providerDesc": { + "anthropic": "", + "fallback": "", + "gemini": "", + "google": "", + "kimi": "", + "kimiCoding": "", + "minimax": "", + "moonshot": "", + "ollama": "", + "openai": "", + "openaiCodex": "", + "openrouter": "", + "zai": "" + } + }, "skip": "略過", - "tryAgain": "重試" + "taskForm": { + "nodeStatusConnecting": "", + "nodeStatusError": "", + "nodeStatusOffline": "", + "nodeStatusOnline": "", + "phasePostMerge": "", + "phasePreMerge": "" + }, + "taskReview": { + "never": "", + "refreshSourceBackground": "", + "refreshSourceInitialLoad": "", + "refreshSourceManual": "" + }, + "tryAgain": "重試", + "workflow": { + "postMerge": "", + "preMerge": "", + "statusAdvisory": "", + "statusFailed": "", + "statusPassed": "", + "statusRunning": "", + "statusSkipped": "", + "waitingForOutput": "" + } } diff --git a/packages/i18n/locales/zh-TW/errors.json b/packages/i18n/locales/zh-TW/errors.json index 963851f5a2..0967ef424b 100644 --- a/packages/i18n/locales/zh-TW/errors.json +++ b/packages/i18n/locales/zh-TW/errors.json @@ -1,4 +1 @@ -{ - "fetchProjectsFailed": "擷取專案失敗", - "openTaskLogsFailed": "開啟工作記錄失敗:{{detail}}" -} +{} diff --git a/packages/i18n/src/i18next-resources.d.ts b/packages/i18n/src/i18next-resources.d.ts new file mode 100644 index 0000000000..70f98c42fe --- /dev/null +++ b/packages/i18n/src/i18next-resources.d.ts @@ -0,0 +1,10 @@ +// This file is automatically generated by i18next-cli, because it was not existing. You can edit it based on your needs: https://www.i18next.com/overview/typescript#custom-type-options +import type Resources from './resources'; + +declare module 'i18next' { + interface CustomTypeOptions { + enableSelector: false; + defaultNS: 'common'; + resources: Resources; + } +} \ No newline at end of file diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts new file mode 100644 index 0000000000..f580ddd861 --- /dev/null +++ b/packages/i18n/src/resources.d.ts @@ -0,0 +1,7214 @@ +// This file is automatically generated by i18next-cli. Do not edit manually. +export default interface Resources { + "app": { + "actions": { + "add": "Add", + "apply": "Apply", + "back": "Back", + "cancel": "Cancel", + "close": "Close", + "closeInsightsView": "Close insights view", + "closeModal": "Close modal", + "confirm": "Confirm", + "continue": "Continue", + "create": "Create", + "delete": "Delete", + "discard": "Discard", + "dismiss": "Dismiss", + "dismissOAuth": "Dismiss OAuth re-login banner", + "done": "Done", + "edit": "Edit", + "no": "No", + "openSettings": "Open Settings", + "pull": "Pull", + "refresh": "Refresh", + "refreshInsights": "Refresh insights", + "retry": "Retry", + "run": "Run", + "save": "Save", + "search": "Search", + "send": "Send", + "showLess": "Show less", + "showMore": "Show more", + "update": "Update", + "yes": "Yes" + }, + "activityLog": { + "activeFilters": "Active filters:", + "allEvents": "All Events", + "allProjects": "All Projects", + "clearFilters": "Clear all", + "clearFiltersBtnLabel": "Clear Filters", + "clearLog": "Clear Log", + "confirmClear": "Clear Activity Log?", + "confirmClearButton": "Clear Log", + "confirmClearMessage": "This will permanently delete all activity log entries. This action cannot be undone.", + "eventType": { + "autoArchivedDeterministicDuplicate": "Task Auto-Archived (Deterministic Duplicate)", + "autoArchivedDuplicate": "Task Auto-Archived (Duplicate)", + "autoArchivedGhostBug": "Task Auto-Archived (Ghost Bug)", + "autoArchivedNearDuplicate": "Task Auto-Archived (Near-Duplicate)", + "duplicateWarningOverridden": "Duplicate Warning Overridden", + "mergeWorktreeReacquired": "Merge Worktree Reacquired", + "nearDuplicateFlagged": "Near-Duplicate Flagged", + "projectIsolationTransition": "Project Isolation Transition", + "settingsUpdated": "Settings Updated", + "taskCreated": "Task Created", + "taskDeleted": "Task Deleted", + "taskFailed": "Task Failed", + "taskMerged": "Task Merged", + "taskMoved": "Task Moved", + "taskUpdated": "Task Updated" + }, + "loadMore": "Load More", + "merged": "Merged", + "noActivityRecorded": "No activity recorded yet", + "noMatchingActivity": "No activity matches the current filters", + "notMerged": "Not merged", + "refresh": "Refresh", + "time": { + "daysAgo_one": "{{count}}d ago", + "daysAgo_other": "{{count}}d ago", + "hoursAgo_one": "{{count}}h ago", + "hoursAgo_other": "{{count}}h ago", + "justNow": "Just now", + "minutesAgo_one": "{{count}}m ago", + "minutesAgo_other": "{{count}}m ago" + }, + "title": "Activity Log" + }, + "agentError": { + "copied": "Copied", + "copiedLabel": "Copied error to clipboard", + "copy": "Copy", + "copyLabel": "Copy error to clipboard", + "dialogLabel": "Agent error details", + "openDetails": "Open error details", + "reportOnGithub": "Report on GitHub", + "title": "Agent Error Details" + }, + "agentLog": { + "agentNameTriage": "Plan", + "collapseModelDetails": "Collapse model details", + "empty": "No agent output yet.", + "executor": "Executor", + "exitFullscreen": "Exit full screen", + "expandFullscreen": "Expand agent log to full screen", + "expandModelDetails": "Expand model details", + "hideOutput": "Hide output", + "hideToolCallsResults": "Hide tool calls and results", + "hideToolOutput": "Hide tool output", + "live": "Live", + "loadMore": "Load More", + "loading": "Loading agent logs…", + "loadingMore": "Loading…", + "markdown": "Markdown", + "plain": "Plain", + "planning": "Planning", + "reviewer": "Reviewer", + "showFormattedMarkdown": "Show formatted markdown", + "showOutput": "Show output", + "showRawText": "Show raw text", + "showToolCallsResults": "Show tool calls and results", + "showToolOutput": "Show tool output", + "showing": "Showing {{visible}} of {{total}} entries", + "switchMarkdown": "Switch to markdown mode", + "switchPlainText": "Switch to plain text mode", + "timeDaysAgo_one": "{{count}}d ago", + "timeDaysAgo_other": "{{count}}d ago", + "timeHoursAgo_one": "{{count}}h ago", + "timeHoursAgo_other": "{{count}}h ago", + "timeJustNow": "just now", + "timeMinutesAgo_one": "{{count}}m ago", + "timeMinutesAgo_other": "{{count}}m ago", + "toolEntriesHidden_one": "{{count}} tool entries hidden", + "toolEntriesHidden_other": "{{count}} tool entries hidden", + "toolsOff": "Tools: Off", + "toolsOn": "Tools: On", + "usingDefault": "Using default" + }, + "agentMention": { + "membersOf": "Members of #{{roomName}}", + "noAgentsFound": "No agents found", + "otherAgents": "Other agents", + "roomMemberBadge": "Room member", + "roomMembers": "Room members", + "suggestionsLabel": "Agent mention suggestions", + "typeToSearch": "Type to search other agents" + }, + "agentPolicy": { + "allow": "Allow", + "approvalRequired": "Approval Required", + "ariaLabel": "Permission policy categories", + "block": "Block", + "category": { + "commandExecution": { + "description": "Runs shell commands and scripts.", + "label": "Command execution" + }, + "fileWriteDelete": { + "description": "Create, edit, or remove files in the workspace.", + "label": "File writes/deletes" + }, + "gitWrite": { + "description": "Commits, branch updates, and merge-affecting git changes.", + "label": "Git writes" + }, + "networkApi": { + "description": "Outbound network or API access.", + "label": "Network/API" + }, + "taskAgentMutation": { + "description": "Task state changes, delegation, or agent lifecycle actions.", + "label": "Task/agent mutation" + } + }, + "categoryExamples": "{{label}} examples", + "custom": "Custom", + "exemptTools": "Tools exempt from approval policy", + "exemptToolsDescription": "These coordination tools bypass approval policy so heartbeats and inter-agent messaging cannot deadlock. They are not user-configurable.", + "fromProjectDefault": "from project default", + "inherit": "Inherit", + "inheritDefault": "Inherit project default", + "lockedDown": "Locked Down", + "preset": "Preset", + "requireApproval": "Require approval", + "unrestricted": "Unrestricted" + }, + "agentPrompts": { + "actions": { + "addCustomTemplate": "Add Custom Template", + "cancel": "Cancel", + "collapse": "Collapse", + "create": "Create", + "delete": "Delete", + "reset": "Reset", + "save": "Save" + }, + "ariaLabels": { + "collapse": "Collapse", + "deleteTemplate": "Delete {{name}}", + "editTemplate": "Edit {{name}}", + "expand": "Expand", + "expandPrompt": "Expand prompt to fullscreen", + "expandToFullscreen": "Expand to fullscreen", + "promptOverride": "{{name}} prompt override ({{key}})", + "promptOverrideFullscreen": "{{name}} prompt override ({{key}}) - fullscreen", + "templatePromptFullscreen": "Template prompt - fullscreen", + "viewFullPromptFor": "View full prompt for {{name}}" + }, + "badges": { + "builtin": "Built-in", + "custom": "Custom", + "customized": "customized", + "overridesBuiltin": "Overrides built-in" + }, + "confirmations": { + "deleteTemplate": "Delete \"{{name}}\"?" + }, + "descriptions": { + "assignmentNote": "Role assignments are stored in the agentPrompts configuration. Custom templates override built-ins by ID.", + "assignments": "Assign specific templates to agent roles. When a role has an assignment, that template will be used instead of the default built-in.", + "builtinTemplates": "These templates are provided by Fusion and cannot be modified.", + "customTemplates": "Create custom templates to override built-in prompts for specific roles.", + "overrides": "Customize specific segments of AI agent prompts. Edits override built-in defaults. Use the Reset button to restore the original default for any prompt." + }, + "editor": { + "editTemplate": "Edit Custom Template", + "newTemplate": "New Custom Template" + }, + "emptyStates": { + "noCustomTemplates": "No custom templates yet. Create one to get started." + }, + "errors": { + "idConflict": "Template ID \"{{templateId}}\" conflicts with a built-in template. Please use a different name.", + "nameRequired": "Template name is required" + }, + "hints": { + "customOverrideActive": "Custom override active. Click Reset to restore default.", + "noOverrideSet": "No override set. Using built-in default ({{chars}} chars)." + }, + "labels": { + "builtIn": "built-in", + "custom": "Custom", + "description": "Description", + "name": "Name", + "note": "Note", + "overridesDefault": "overrides default", + "prompt": "Prompt", + "role": "Role" + }, + "options": { + "useDefault": "Use default" + }, + "placeholders": { + "description": "Brief description of this template", + "prompt": "Enter the system prompt for this template...", + "promptDefault": "Default: {{preview}}", + "templateName": "e.g. My Custom Executor" + }, + "sections": { + "builtinTemplates": "Built-in Templates", + "customTemplates": "Custom Templates" + }, + "tabs": { + "assignments": "Assignments", + "overrides": "Overrides", + "templates": "Templates" + }, + "titles": { + "delete": "Delete", + "edit": "Edit", + "editPrompt": "Edit Prompt", + "viewFullPrompt": "View full prompt" + } + }, + "agentProvisioning": { + "always": "Always require approval", + "alwaysApproveDelete": "Always require approval for fn_agent_delete", + "alwaysDesc": "All fn_agent_create/fn_agent_delete requests require approval unless caller is trusted.", + "approvalMode": "Approval mode", + "helpText": "These settings govern durable provisioning tools only: fn_agent_create and fn_agent_delete. Ephemeral fn_spawn_agent requests stay under the task/agent mutation approval gate (FN-3973).", + "never": "Never require approval", + "neverDesc": "Allow provisioning without approval for non-privileged callers.", + "trustedAgentIds": "Trusted agent IDs", + "trustedAgentIdsPlaceholder": "agent-abc123", + "trustedOnly": "Trusted-only", + "trustedOnlyDesc": "Trusted roles/agent IDs bypass approval; other callers require approval.", + "trustedRoles": "Trusted roles", + "trustedRolesPlaceholder": "reviewer, ceo" + }, + "agents": { + "activate": "Activate", + "activeAgents_one": "Active Agents ({{count}})", + "activeAgents_other": "Active Agents ({{count}})", + "activePrefix": "Active: ", + "advancedSettingsDesc": "Low-level configuration options for this agent.", + "advancedSettingsTitle": "Advanced Settings", + "agent": "Agent", + "agentControls": "Agent controls", + "agentLogs": "Agent Logs", + "agentMail": "Agent Mail", + "agentModelLabel": "Agent Model", + "agentPlural": "agents", + "agentSingular": "agent", + "agentSoulLabel": "Agent Soul", + "agentsFound_one": "{{count}} agent{{plural}} found", + "agentsFound_other": "{{count}} agent{{plural}} found", + "agentsLabel": "Agents", + "aiInterview": "AI Interview", + "allChangesSaved": "All changes saved", + "allTime": "All time", + "allowParallelExecution": "Allow Parallel Execution", + "allowParallelExecutionHint": "Allow this agent to run multiple heartbeats concurrently.", + "alreadyOnDefault": "Already on default", + "applyPreset": "Apply preset", + "assignedSkills": "Assigned Skills", + "auto": "Auto", + "autoClaimHint": "Agent will automatically claim tasks that match its skills.", + "autoClaimRelevantTasks": "Auto-claim relevant tasks", + "avatarLabel": "Avatar", + "avatarRemoved": "Avatar removed", + "avatarUploaded": "Avatar uploaded", + "back": "Back", + "backToAgents": "Back to Agents", + "backToInbox": "Back to Inbox", + "backToOrgChart": "Back to org chart", + "backToOutbox": "Back to Outbox", + "boardView": "Board view", + "browseCatalog": "Browse Catalog", + "budgetExhaustedBody": "This agent has exhausted its token budget and will not run until the budget resets.", + "budgetExhaustedTitle": "Budget Exhausted", + "budgetPeriodHint": "How often the token budget resets.", + "budgetPeriodLabel": "Budget Period", + "budgetResetFailed": "Failed to reset budget", + "budgetResetSuccess": "Budget reset successfully", + "budgetSettingsDesc": "Control token usage limits and reset schedule for this agent.", + "budgetSettingsTitle": "Budget Settings", + "budgetUsageDisplay": "{{used}} / {{limit}} tokens used", + "builtInModel": "Built-in model", + "bulkActionFailed": "Bulk action failed", + "bulkActions": "Bulk Actions", + "bulkActionsLoadFailed": "Failed to load bulk agent actions: {{error}}", + "bulkAgentActions": "Bulk agent actions", + "bulkConfirmMessage_one": "{{action}} {{count}} agent(s) in this project?", + "bulkConfirmMessage_other": "{{action}} {{count}} agent(s) in this project?", + "bulkNoEligible": "No eligible agents", + "bulkResultWithFailures": "{{action}} {{count}} agent(s), {{failed}} failed", + "bulkResult_one": "{{action}} {{successCount}} {{agentWord}}; skipped {{skippedCount}}", + "bulkResult_other": "{{action}} {{successCount}} {{agentWord}}; skipped {{skippedCount}}", + "bundleDescription": "Configure how this agent's code bundle is managed.", + "bundleEntryFileHint": "The entry file for the managed bundle.", + "bundleEntryFileLabel": "Entry File", + "bundleExternal": "External", + "bundleExternalHint": "Point to an externally managed bundle path.", + "bundleExternalPathHint": "Absolute path to the external bundle file.", + "bundleExternalPathLabel": "External Bundle Path", + "bundleExternalPathPlaceholder": "/path/to/bundle.js", + "bundleFilesHint": "Source files included in this agent's bundle.", + "bundleFilesLabel": "Bundle Files", + "bundleManaged": "Managed", + "bundleManagedHint": "Fusion manages the bundle automatically.", + "bundleModeLabel": "Bundle Mode", + "bundleNone": "None", + "bundleSelectMode": "Select a bundle mode", + "bundleTitle": "Bundle", + "cacheHitRatio": "Cache Hit Ratio", + "cacheReadTokens": "Cache Read", + "cacheWriteTokens": "Cache Write", + "cancel": "Cancel", + "cancelCustomInterval": "Cancel custom interval", + "center": "Center", + "chainOfCommand": "Chain of Command", + "change": "Change", + "changeRole": "Click to change role", + "chooseFile": "Choose File", + "clearAgents": "Clear agents", + "clearSkills": "Clear skills", + "clickToChangeRole": "Click to change role", + "close": "Close", + "closeAriaLabel": "Close", + "combinedTokens": "Combined Tokens", + "company": "Company", + "configDescription": "Configure this agent's identity, model, and behavior settings.", + "configTitle": "Configuration", + "connecting": "Connecting...", + "connectingStream": "Connecting to log stream...", + "connectionLost": "Connection lost. Retrying...", + "context": "Context", + "controls": "Controls", + "coordinationOnlyAgent": "Coordination-only agent", + "coordinationOnlyHint": "This agent coordinates work but does not execute tasks directly.", + "copyId": "Copy ID", + "create": "Create", + "createAgent": "Create agent", + "createError": "Failed to create agent: {{error}}", + "createSuccess": "Agent \"{{name}}\" created", + "created": "Agent \"{{name}}\" created", + "creating": "Creating...", + "creatingAgent": "Creating agent...", + "currentAgent": "Current agent", + "currentInput": "Current input: {{method}}", + "currentPath": "Current path", + "currentUsage": "Current Usage", + "currentWork": "Current Work", + "customHeartbeatAria": "Custom heartbeat interval in minutes for {{name}}", + "customHeartbeatOption": "Custom...", + "customizeForAgent": "Customize for this agent", + "daily": "Daily", + "dangerZone": "Danger Zone", + "dangerZoneDesc": "Irreversible actions — proceed with caution.", + "delete": "Delete", + "deleteAgent": "Delete Agent", + "deleteConfirm": "Are you sure you want to delete this agent? This cannot be undone.", + "deleteError": "Failed to delete agent: {{error}}", + "deleteFailed": "Failed to delete agent", + "deleteMessage": "Delete agent \"{{name}}\"? This cannot be undone.", + "deleteSuccess": "Agent \"{{name}}\" deleted", + "deleteTitle": "Delete Agent", + "deleted": "Agent deleted", + "deletionNotAvailable": "Deletion is not available while the agent is running.", + "deletionPermanent": "This will permanently delete the agent and all associated data.", + "details": "Details", + "dialogAriaLabel": "Create new agent", + "dialogTitle": "New Agent", + "employeesTitle": "Employees", + "emptySubtitle": "Create an agent to get started", + "emptyTitle": "No agents found", + "errors": "Errors", + "executionPrompt": "Execution Prompt", + "executionPromptNotCaptured": "Execution prompt not captured", + "failedToImport": "Failed to import agents", + "failedToLoadCompanies": "Failed to load companies", + "failedToParseDirectory": "Failed to parse AGENTS.md files from selected directory", + "failedToParseManifest": "Failed to parse manifest", + "failedToReadFile": "Failed to read file", + "fieldHeartbeatPath": "Heartbeat Procedure Path", + "fieldIcon": "Icon", + "fieldInstructionsPath": "Instructions Path", + "fieldInstructionsText": "Inline Instructions", + "fieldMaxTurns": "Max Turns", + "fieldMemory": "Agent Memory", + "fieldName": "Name", + "fieldReportsTo": "Reports To", + "fieldRole": "Role", + "fieldSkills": "Skills", + "fieldSoul": "Soul", + "fieldThinking": "Thinking", + "fieldThinkingLevel": "Thinking Level", + "fieldTitle": "Title", + "fileContentHint": "Edit the file contents directly.", + "fileContentLabel": "File Content", + "fileContentPlaceholder": "File content will appear here...", + "fileHint": ".md and .txt files supported", + "fileLoadError": "Failed to load file", + "fileSaved": "File saved", + "filterAll": "All States", + "filterByState": "Filter agents by state", + "fit": "Fit", + "fixValidationErrors": "Fix validation errors before saving", + "fixValidationToSave": "Fix validation errors to save", + "from": "From", + "generateHint": "Describe your agent's role and let AI generate a specification", + "generateWithAI": "Generate with AI", + "generation": { + "collapse": "Collapse", + "expand": "Expand", + "generate": "Generate", + "info": "Describe your agent's role and the AI will generate a complete specification including system prompt, suggested configuration, and more.", + "loading": "Generating agent specification…", + "previewDescription": "Description", + "previewMaxTurns": "Max Turns", + "previewRole": "Role", + "previewThinking": "Thinking", + "previewTitle": "Title", + "rateLimited": "Too many requests. Please wait a moment and try again.", + "regenerate": "Regenerate", + "roleHint": "Describe what your agent should do", + "roleLabel": "Role Description", + "rolePlaceholder": "e.g. \"Senior frontend code reviewer who specializes in React accessibility\"", + "systemPrompt": "System Prompt", + "title": "Generate Agent", + "useThis": "Use This" + }, + "healthError": "Error", + "heartbeat": "Heartbeat:", + "heartbeatAndHealth": "Heartbeat & Health", + "heartbeatClampedToMin_one": "Heartbeat interval set to 5 minutes (minimum). {{count}} minute was below the 5-minute minimum.", + "heartbeatClampedToMin_other": "Heartbeat interval set to 5 minutes (minimum). {{count}} minutes was below the 5-minute minimum.", + "heartbeatCustom": "Custom heartbeat run", + "heartbeatEnabled": "Heartbeat Enabled", + "heartbeatEnabledHint": "Allow this agent to run on a scheduled heartbeat.", + "heartbeatEnterMinutes": "Please enter a heartbeat interval in minutes", + "heartbeatFileEditMode": "Edit mode", + "heartbeatFileEditorHint": "Edit the heartbeat procedure file directly.", + "heartbeatFileEmptyPreview": "Empty file — switch to edit mode to add content.", + "heartbeatFileLoadFailed": "Failed to load heartbeat file", + "heartbeatFilePlaceholder": "Heartbeat procedure content...", + "heartbeatFilePreviewMode": "Preview mode", + "heartbeatFileSaveFailed": "Failed to save heartbeat file", + "heartbeatFileSaved": "Heartbeat file saved", + "heartbeatIntervalHint": "How frequently the heartbeat runs, in seconds.", + "heartbeatIntervalLabel": "Heartbeat Interval (s)", + "heartbeatIntervalUpdateFailed": "Failed to update heartbeat interval: {{error}}", + "heartbeatIntervalUpdated": "Heartbeat interval updated to {{interval}} for {{name}}", + "heartbeatMustBeNumber": "Heartbeat interval must be a valid number", + "heartbeatMustBePositive": "Heartbeat interval must be greater than 0", + "heartbeatOverdue": "Heartbeat overdue {{elapsed}}", + "heartbeatPathHint": "Path to the agent's heartbeat procedure path, typically .fusion/agents/ceo-agent2736/HEARTBEAT.md. Legacy id-only default paths still work.", + "heartbeatPathPlaceholder": "e.g. .fusion/agents/ceo-agent2736/HEARTBEAT.md", + "heartbeatProcedureDesc": "The heartbeat procedure guides what this agent does on each scheduled run.", + "heartbeatProcedureDescSuffix": "You can customize it here or leave it on the built-in default.", + "heartbeatProcedureFileLabel": "Heartbeat Procedure File", + "heartbeatProcedureFileReady": "Heartbeat procedure file ready", + "heartbeatProcedurePathSet": "Heartbeat procedure path set", + "heartbeatProcedureTitle": "Heartbeat Procedure", + "heartbeatPromptTemplate": "Heartbeat Prompt Template", + "heartbeatRunFailed": "Failed to start heartbeat run: {{error}}", + "heartbeatRunStarted": "Heartbeat run started for {{name}}", + "heartbeatRunsWillAppear": "Heartbeat runs will appear here.", + "heartbeatScopeDiscipline": "Heartbeat Scope Discipline", + "heartbeatSettingsDesc": "Control the scheduling and behavior of this agent's heartbeat.", + "heartbeatSettingsTitle": "Heartbeat Settings", + "heartbeatSpeed": "Heartbeat Speed", + "heartbeatSpeedHint": "Scales all agent heartbeat intervals. ×0.5 = twice as fast, ×2.0 = twice as slow. Default: ×1.0", + "heartbeatSpeedPreset": "Heartbeat speed preset", + "heartbeatSpeedSaveFailed": "Failed to save heartbeat multiplier: {{error}}", + "heartbeatSpeedSet": "Heartbeat speed set to ×{{value}}", + "heartbeatStartFailed": "Failed to start heartbeat", + "heartbeatStarted": "Heartbeat started", + "heartbeatTimeoutHint": "Maximum time in seconds a heartbeat run may take before being killed.", + "heartbeatTimeoutLabel": "Heartbeat Timeout (s)", + "heartbeatUpgradeFailed": "Failed to upgrade heartbeat procedure", + "iconLabel": "Icon", + "iconPlaceholder": "e.g. 🤖", + "idCopied": "ID copied to clipboard", + "idleNoTask": "Idle — no task assigned", + "immediate": "Immediate", + "import": "Import", + "importAgents": "Import Agents", + "importButton": "Import {{label}}", + "importComplete": "Import Complete", + "importDescription": "Import agents from an Agent Companies package. Browse the companies.sh catalog to discover published agents, upload an AGENTS.md file, select a directory, or paste manifest content.", + "importingAgentsAndSkills": "Importing {{agentCount}} agent{{agentPlural}} and {{skillCount}} skill{{skillPlural}}...", + "importingAgents_one": "Importing {{count}} agent{{plural}}...", + "importingAgents_other": "Importing {{count}} agent{{plural}}...", + "importingSkills_one": "Importing {{count}} skill{{plural}}...", + "importingSkills_other": "Importing {{count}} skill{{plural}}...", + "inProgress": "In progress", + "inbox": "Inbox", + "inheritProjectDefault": "Inherit project default", + "inheritingProjectDefault": "Inheriting project default", + "inlineMemoryFieldHint": "This memory is embedded directly in the agent's context.", + "inlineMemoryHint": "Short-form memory injected on every heartbeat.", + "inlineMemoryLabel": "Inline Memory", + "input": "Input", + "inputTokens": "Input", + "installs": "installs", + "instructionsDescription": "Persistent instructions that guide this agent's behavior.", + "instructionsEmptyPreview": "No instructions yet — switch to edit mode to add some.", + "instructionsFileEditorDesc": "Edit the linked instructions file directly.", + "instructionsFileEditorTitle": "Instructions File", + "instructionsFileSaveFailed": "Failed to save instructions file", + "instructionsFileSaved": "Instructions file saved", + "instructionsHint": "These instructions are prepended to every prompt this agent receives.", + "instructionsPathHint": "Path to a markdown file containing this agent's instructions.", + "instructionsPathLabel": "Instructions File Path", + "instructionsPathPlaceholder": "/path/to/instructions.md", + "instructionsPlaceholder": "Enter instructions for this agent...", + "instructionsSaveFailed": "Failed to save instructions", + "instructionsSaved": "Instructions saved", + "instructionsTextPlaceholder": "Add custom behavior instructions...", + "instructionsTitle": "Instructions", + "intentPrompt": "What do you want this agent to do?", + "interval": "Interval", + "interviewDraftApplied": "Interview draft applied", + "last24h": "Last 24h", + "last7d": "Last 7 days", + "lastHeartbeat": "Last heartbeat", + "latestRunLabel": "Latest run", + "layoutAuto": "Auto", + "layoutAutoAria": "Automatic layout", + "layoutHorizontal": "Horizontal", + "layoutHorizontalAria": "Horizontal layout", + "layoutVertical": "Vertical", + "layoutVerticalAria": "Vertical layout", + "listView": "List view", + "lite": "Lite", + "live": "Live", + "liveLogs": "Live logs", + "liveRun": "Live run", + "loadError": "Failed to load agents: {{error}}", + "loadTasksFailed": "Failed to load tasks", + "loading": "Loading agent...", + "loadingAgents": "Loading agents...", + "loadingCompanies": "Loading companies…", + "loadingDetails": "Loading run details...", + "loadingEligibility": "Loading eligibility…", + "loadingEligible": "Loading eligible agents...", + "loadingEmployees": "Loading employees...", + "loadingFile": "Loading file...", + "loadingFileContent": "Loading file content...", + "loadingLogs": "Loading logs...", + "loadingMailbox": "Loading mailbox...", + "loadingMemoryFiles": "Loading memory files...", + "loadingModels": "Loading models…", + "loadingOrgChart": "Loading org chart...", + "loadingReportingChain": "Loading reporting chain...", + "loadingRuns": "Loading runs...", + "loadingRuntimes": "Loading runtimes...", + "loadingSkillContent": "Loading skill content...", + "loadingTasks": "Loading tasks...", + "logEntries_one": "{{count}} entries", + "logEntries_other": "{{count}} entries", + "logsWillAppear": "Logs will appear here once the agent starts running.", + "logsWillAppearActive": "Logs will appear here.", + "mailFrom": "From", + "mailSent": "Sent", + "mailTo": "To", + "mailToLabel": "To", + "mailType": "Type", + "mailboxLoadFailed": "Failed to load mailbox", + "manifestContent": "Manifest content", + "manifestPlaceholder": "---\nname: CEO\ntitle: Chief Executive Officer\nreportsTo: null\nskills:\n - review\n---\nAgent instructions go here...", + "maxConcurrentRunsHint": "Maximum number of heartbeats that can run simultaneously.", + "maxConcurrentRunsLabel": "Max Concurrent Runs", + "maxTurns": "Max turns", + "memoryDescription": "Manage this agent's long-term and working memory.", + "memoryEmptyPreview": "No inline memory yet — switch to edit mode to add some.", + "memoryFileEditMode": "Edit mode", + "memoryFileEmptyPreview": "Empty file — switch to edit mode to add content.", + "memoryFileLoadFailed": "Failed to load memory file", + "memoryFileMeta": "{{size}} bytes · updated {{date}}", + "memoryFilePlaceholder": "Memory file content...", + "memoryFilePreviewMode": "Preview mode", + "memoryFileSaveFailed": "Failed to save memory file", + "memoryFileSaved": "Memory file saved", + "memoryFilesHint": "Files stored in the agent's memory layers.", + "memoryFilesHintSuffix": "Select a file to view or edit its contents.", + "memoryFilesLabel": "Memory Files", + "memoryFilesLoadFailed": "Failed to load memory files", + "memoryLayerDaily": "Daily", + "memoryLayerDailyDesc": "Short-term notes refreshed each day.", + "memoryLayerDreams": "Dreams", + "memoryLayerDreamsDesc": "Synthesized reflections from recent activity.", + "memoryLayerLongTerm": "Long-term", + "memoryLayerLongTermDesc": "Persistent facts and knowledge retained across sessions.", + "memoryPlaceholder": "Enter inline memory for this agent...", + "memoryReadOnly": "Read-only", + "memorySaveFailed": "Failed to save memory", + "memorySaved": "Memory saved", + "memoryTitle": "Memory", + "memoryTooLong": "Memory content is too long", + "messageResponseModeHint": "When this agent responds to incoming messages.", + "messageResponseModeLabel": "Message Response Mode", + "minutesUnit": "min", + "modalTitle": "Agents", + "model": "Model", + "modelDefault": "default", + "modelDescription": "Choose the AI model this agent uses for inference.", + "modelPlaceholder": "Select a model…", + "modelTitle": "Model", + "monthly": "Monthly", + "name": "Name", + "nameLabel": "Name", + "namePlaceholder": "Agent name...", + "never": "Never", + "newAgent": "New Agent", + "next": "Next", + "nextExpected": "Next expected", + "nextHeartbeat": "Next heartbeat in {{elapsed}}", + "noActiveAssignment": "No active assignment", + "noActiveEligible": "No active agents eligible to pause", + "noActivityYet": "No activity yet", + "noAgentsFound": "No agents found in the manifest.", + "noAgentsMdFiles": "Selected directory has no AGENTS.md files", + "noAgentsToPause": "No agents eligible to pause", + "noAgentsToPauseHint": "No active or running project agents to pause", + "noAgentsToResume": "No agents eligible to resume", + "noAgentsToResumeHint": "No paused project agents to resume", + "noCompaniesAvailable": "No companies available", + "noCompaniesMatch": "No companies match your search", + "noEmployees": "No employees", + "noEmployeesDesc": "This agent has no direct reports.", + "noInboxMessages": "No messages in inbox", + "noInstructionsPath": "No instructions file path set", + "noLimit": "No limit", + "noLogEntriesYet": "No log entries yet", + "noLogsForRun": "No logs for this run", + "noManager": "No manager", + "noMemoryFiles": "No memory files", + "noOutboxMessages": "No messages in outbox", + "noOutputCaptured": "No output captured", + "noPausedEligible": "No paused agents eligible to resume", + "noReportingChain": "No reporting chain", + "noReset": "No reset", + "noRunsYet": "No runs yet", + "noRuntimes": "No runtimes available", + "noSkillContent": "No skill content available", + "noSkillMd": "No skill documentation found", + "noSkillsInPackage": "No skills in package", + "noTasksAssigned": "No tasks assigned", + "noTokenUsageYet": "No token usage recorded yet. Token totals appear here once agents run.", + "noneUsingBuiltIn": "None (using built-in)", + "notScheduled": "Not scheduled", + "notSelected": "Not selected", + "off": "Off", + "onHeartbeat": "On heartbeat", + "onboarding": { + "applyDraftAgent": "Apply draft to agent form", + "applyDraftSettings": "Apply draft to settings form", + "continue": "Continue", + "dialogLabel": "AI Interview", + "draftIntro": "Review this generated draft. Nothing is applied until you confirm.", + "draftReady": "Draft ready for review", + "fieldAgentMemory": "Agent Memory", + "fieldHeartbeatEnabled": "Heartbeat Enabled", + "fieldHeartbeatInterval": "Heartbeat Interval", + "fieldHeartbeatPath": "Heartbeat Procedure Path", + "fieldIcon": "Icon", + "fieldInlineInstructions": "Inline Instructions", + "fieldMaxTurns": "Max Turns", + "fieldModelHint": "Model Hint", + "fieldName": "Name", + "fieldPatternAgent": "Pattern Agent", + "fieldReportsTo": "Reports To", + "fieldRole": "Role", + "fieldRuntimeHint": "Runtime Hint", + "fieldSkills": "Skills", + "fieldSoul": "Soul", + "fieldTemplate": "Template", + "fieldThinkingLevel": "Thinking Level", + "fieldTitle": "Title", + "intentLabelCreate": "What should this new agent own?", + "intentLabelEdit": "What should this agent change or improve?", + "no": "no", + "notSet": "Not set", + "sectionConfiguration": "Configuration", + "sectionIdentity": "Identity", + "sectionRationale": "Rationale", + "sectionRuntimeHints": "Runtime Hints", + "startInterview": "Start interview", + "startOnboarding": "Start onboarding", + "thinking": "Thinking...", + "title": "AI Interview", + "updatedDraftReady": "Updated draft ready for review", + "yes": "yes" + }, + "openDetails": "Open details for {{name}}", + "optional": "(optional)", + "orPasteManifest": "or paste manifest content", + "orgChartCanvas": "Org chart canvas", + "orgChartCenter": "Center org chart", + "orgChartEmployees": "{{name}} employees", + "orgChartFit": "Fit org chart", + "orgChartLoadFailed": "Failed to load org chart: {{error}}", + "orgChartView": "Org Chart view", + "orgChartZoomIn": "Zoom in org chart", + "orgChartZoomOut": "Zoom out org chart", + "outbox": "Outbox", + "output": "Output", + "outputTokens": "Output", + "overview": "Overview", + "parsing": "Parsing…", + "pause": "Pause", + "pauseAction": "Pause", + "pauseAgentsFailed": "Failed to pause agents: {{error}}", + "pauseAll": "Pause All", + "pauseAllAgents": "Pause All Agents", + "pauseAllConfirm_one": "Pause {{count}} agent in this project?", + "pauseAllConfirm_other": "Pause {{count}} agents in this project?", + "pauseAllTitle": "Pause All Agents", + "pauseCountHint_one": "Pause {{count}} active/running agent", + "pauseCountHint_one_one": "", + "pauseCountHint_one_other": "", + "pauseCountHint_other": "Pause {{count}} active/running agents", + "pauseCountHint_other_one": "", + "pauseCountHint_other_other": "", + "pausedPast": "paused", + "pausedSummary_one": "Paused {{count}} agent; skipped {{skipped}}", + "pausedSummary_other": "Paused {{count}} agents; skipped {{skipped}}", + "pendingApprovals": "Pending Approvals", + "pendingApprovalsCount_one": "{{count}} pending approvals", + "pendingApprovalsCount_other": "{{count}} pending approvals", + "performance": { + "avgDuration": "Avg Duration", + "noData": "No performance data yet", + "reflections": "Reflections", + "successRate": "Success Rate", + "tasksCompleted": "Tasks Completed", + "tasksFailed": "Tasks Failed" + }, + "permissionPolicyFailed": "Failed to update permission policy", + "permissionPolicyUpdated": "Permission policy updated", + "permissionsDescription": "Control what this agent is allowed to do.", + "permissionsTitle": "Permissions", + "pluginRuntime": "Plugin runtime", + "presetsHeader": "Choose a preset persona to prefill role, identity, soul, and instructions", + "preview": "Preview", + "promptSize": "Prompt size", + "promptSizeChart": "Prompt Size Chart", + "provideManifest": "Please provide manifest content", + "ratings": { + "addError": "Failed to add rating: {{error}}", + "addRating": "Add Rating", + "addSuccess": "Rating added", + "categoryAverages": "Category Averages", + "categoryCommunication": "Communication", + "categoryOther": "Other", + "categoryQuality": "Quality", + "categoryReliability": "Reliability", + "categorySelect": "Select category...", + "categorySpeed": "Speed", + "commentPlaceholder": "Optional comment...", + "count_one": "{{count}} ratings", + "count_other": "{{count}} ratings", + "deleteError": "Failed to delete rating: {{error}}", + "deleteRating": "Delete rating", + "deleteSuccess": "Rating deleted", + "historyTitle": "Rating History", + "loadError": "Failed to load ratings: {{error}}", + "loading": "Loading ratings...", + "noRatings": "No ratings yet", + "starCount_one": "{{count}} star", + "starCount_other": "{{count}} stars", + "submitRating": "Submit Rating", + "submitting": "Submitting...", + "title": "User Ratings" + }, + "recentRuns": "Recent Runs", + "reflections": { + "agentNotFound": "This agent is no longer available. It may have been deleted.", + "generateError": "Failed to generate reflection: {{error}}", + "generateSuccess": "Reflection generated successfully", + "getStarted": "Trigger a reflection to get started", + "historyTitle": "Reflection History", + "insights": "Insights", + "insufficientHistory": "Not enough history to generate a reflection yet", + "loadError": "Failed to load reflections: {{error}}", + "loading": "Loading reflections...", + "loadingEvaluation": "Loading evaluation...", + "metricAvgDuration": "Avg Duration:", + "metricErrors": "Errors:", + "metricFailed": "Failed:", + "metricTasks": "Tasks:", + "metrics": "Metrics", + "noReflections": "No reflections yet", + "reflectNow": "Reflect Now", + "reflectNowTitle": "Generate a manual reflection", + "reflecting": "Reflecting...", + "sectionTitle": "Performance, Reflections & Ratings", + "suggestedImprovements": "Suggested Improvements" + }, + "refresh": "Refresh", + "removeAvatar": "Remove avatar", + "replyingTo": "Replying to", + "reportsToLabel": "Reports to", + "resetBudgetUsage": "Reset Budget Usage", + "resetDayHint": "The day of the week or month when the budget resets.", + "resetDayLabel": "Reset Day", + "resetDayMonthly": "Day of month (1–28)", + "resetDayWeekly": "Day of week (0=Sun)", + "resetting": "Resetting...", + "result": "Result", + "resultCreated_one": "{{count}} created", + "resultCreated_other": "{{count}} created", + "resultErrors_one": "{{count}} error{{plural}}", + "resultErrors_other": "{{count}} error{{plural}}", + "resultSkipped_one": "{{count}} skipped (already exist)", + "resultSkipped_other": "{{count}} skipped (already exist)", + "resume": "Resume", + "resumeAction": "Resume", + "resumeAgentsFailed": "Failed to resume agents: {{error}}", + "resumeAll": "Resume All", + "resumeAllAgents": "Resume All Agents", + "resumeAllConfirm_one": "Resume {{count}} agent in this project?", + "resumeAllConfirm_other": "Resume {{count}} agents in this project?", + "resumeAllTitle": "Resume All Agents", + "resumeCountHint_one": "Resume {{count}} paused agent", + "resumeCountHint_one_one": "", + "resumeCountHint_one_other": "", + "resumeCountHint_other": "Resume {{count}} paused agents", + "resumeCountHint_other_one": "", + "resumeCountHint_other_other": "", + "resumedPast": "resumed", + "resumedSummary_one": "Resumed {{count}} agent; skipped {{skipped}}", + "resumedSummary_other": "Resumed {{count}} agents; skipped {{skipped}}", + "retry": "Retry", + "reviewConfiguration": "Review generated configuration", + "reviewHint": "Review your agent configuration before creating.", + "role": "Role", + "roleCustom": "Custom", + "roleEngineer": "Engineer", + "roleExecutor": "Executor", + "roleLabel": "Role", + "roleLabel2": "Role", + "roleMerger": "Merger", + "roleReviewer": "Reviewer", + "roleScheduler": "Scheduler", + "roleTriage": "Triage", + "roleUpdateError": "Failed to update role: {{error}}", + "roleUpdateFailed": "Failed to update role: {{error}}", + "roleUpdateSuccess": "Agent role updated to {{role}}", + "roleUpdated": "Agent role updated to {{role}}", + "runAriaLabel": "Run {{id}}", + "runDetailsFailed": "Failed to load run details", + "runMissedHeartbeat": "Run missed heartbeat", + "runMissedHeartbeatHint": "Trigger a run if the agent misses a scheduled heartbeat.", + "runNow": "Run Now", + "runNowAria": "Run now for {{name}}", + "runNowFor": "Run now for {{name}}", + "runStarted": "Run started", + "runStopped": "Run stopped", + "running": "Running", + "runs": { + "empty": "No runs yet", + "loading": "Loading runs…", + "stop": "Stop", + "stopAriaLabel": "Stop run", + "stopMessage": "Stop this run?", + "stopTitle": "Stop Run" + }, + "runsCount_one": "{{count}} run", + "runsCount_other": "{{count}} runs", + "runsSuccessRate": "{{rate}}% success rate", + "runsToday": "Runs today", + "runtime": "Runtime", + "runtimeEmpty": "No plugin runtimes available", + "runtimeLabel": "Runtime", + "runtimeMode": "Runtime mode", + "runtimePlaceholder": "Select a plugin runtime…", + "runtimeSource": "Runtime Source", + "runtimeSourceBuiltIn": "Built-in Model", + "runtimeSourcePlugin": "Plugin Runtime", + "save": "Save", + "saveBeforeSwitch": "Save changes before switching files", + "saveCustomInterval": "Save custom interval", + "saveFailed": "Save failed", + "saveFile": "Save File", + "saveHeartbeatFile": "Save Heartbeat File", + "saveInstructions": "Save Instructions", + "saveMemory": "Save Memory", + "saveMemoryFile": "Save Memory File", + "saveSettings": "Save Settings", + "saveSoul": "Save Soul", + "saving": "Saving…", + "savingChanges": "Saving changes...", + "savingFile": "Saving file...", + "scopeDisciplineHint": "Controls how strictly the agent follows its scope during heartbeats.", + "searchCompanies": "Search companies…", + "sectionConfiguration": "Configuration", + "sectionIdentity": "Identity", + "sectionRuntime": "Runtime", + "selectAgent": "Select agent {{name}}", + "selectAgentHint": "Choose an agent from the sidebar to view details", + "selectAllAgents": "Select all agents", + "selectAllSkills": "Select all skills", + "selectAnAgent": "Select an agent", + "selectCompany": "Please select a company from the catalog", + "selectDirectory": "Select Directory", + "selectMemoryFile": "Select a memory file", + "selectModel": "Model", + "selectModelPlaceholder": "Select a model…", + "selectRuntime": "Select a runtime", + "selectSkill": "Select skill {{name}}", + "selected": "Selected:", + "selectedAgentLabel_one": "{{count}} Agent{{plural}}", + "selectedAgentLabel_other": "{{count}} Agent{{plural}}", + "selectedSkillLabel_one": "{{count}} Skill{{plural}}", + "selectedSkillLabel_other": "{{count}} Skill{{plural}}", + "setHeartbeatAria": "Set heartbeat interval for {{name}}", + "settingsSaveFailed": "Failed to save settings", + "settingsSaved": "Settings saved", + "setupModeAriaLabel": "Agent setup mode", + "showSystemAgents": "Show system agents", + "skills": "Skills", + "skillsDescription": "Manage the skills available to this agent.", + "skillsErrors_one": "{{count}} skill{{plural}} error{{pluralError}}", + "skillsErrors_other": "{{count}} skill{{plural}} error{{pluralError}}", + "skillsFound_one": "{{count}} skill{{plural}} found", + "skillsFound_other": "{{count}} skill{{plural}} found", + "skillsHint": "Optional skills to assign to this agent", + "skillsImported_one": "{{count}} skill{{plural}} imported", + "skillsImported_other": "{{count}} skill{{plural}} imported", + "skillsNone": "No skills assigned", + "skillsSelected_one": "{{count}} skill selected", + "skillsSelected_other": "{{count}} skills selected", + "skillsSkipped_one": "{{count}} skill{{plural}} skipped (already exist)", + "skillsSkipped_other": "{{count}} skill{{plural}} skipped (already exist)", + "skillsTitle": "Skills", + "skipHeartbeatWhenIdle": "Skip heartbeat when idle", + "skipHeartbeatWhenIdleHint": "Avoid running heartbeats when the agent has nothing to do.", + "soulDescription": "The soul defines this agent's personality, values, and communication style.", + "soulEmptyPreview": "No soul yet — switch to edit mode to add one.", + "soulHint": "Describe who this agent is — its character, tone, and values.", + "soulPlaceholder": "Describe this agent's soul...", + "soulSaveFailed": "Failed to save soul", + "soulSaved": "Soul saved", + "soulTitle": "Soul", + "soulTooLong": "Soul content is too long", + "start": "Start", + "startOnboarding": "Start onboarding", + "starting": "Starting...", + "stateActive": "Active", + "stateAll": "All States", + "stateError": "Error", + "stateIdle": "Idle", + "statePaused": "Paused", + "stateRunning": "Running", + "stateUpdateError": "Failed to update state: {{error}}", + "stateUpdateFailed": "Failed to update agent state", + "stateUpdateSuccess": "Agent state updated to {{state}}", + "stateUpdated": "Agent state updated", + "status": "Status", + "statusCount": "{{activeCount}} active · {{runningCount}} running", + "step": "Step {{number}}{{total}}: {{name}}", + "stepAriaLabel": "Step {{step}}", + "stop": "Stop", + "stopActiveRun": "Stop active run", + "stopActiveRunFor": "Stop active run for {{name}}", + "stopRun": "Stop Run", + "stopRunConfirm": "Are you sure you want to stop this run?", + "stopRunFailed": "Failed to stop run", + "stopRunTitle": "Stop Run", + "strict": "Strict", + "successRate": "Success rate", + "systemDefault": "System default", + "systemDefaultOnHeartbeat": "System default (on heartbeat)", + "systemPrompt": "System Prompt", + "systemPromptNotCaptured": "System prompt not captured", + "tabConfig": "Config", + "tabCustom": "Custom agent", + "tabDashboard": "Dashboard", + "tabEmployees": "Employees", + "tabInstructions": "Instructions", + "tabLogs": "Logs", + "tabMail": "Mail", + "tabMemory": "Memory", + "tabPresets": "Preset personas", + "tabReflections": "Reflections", + "tabRuns": "Runs", + "tabSoul": "Soul", + "tabTasks": "Tasks", + "taskUpdated": "Task updated", + "templateCompact": "Compact", + "templateDefault": "Default", + "thinkingHigh": "High", + "thinkingLevel": "Thinking level", + "thinkingLow": "Low", + "thinkingMedium": "Medium", + "thinkingMinimal": "Minimal", + "thinkingOff": "Off", + "throughput": "Throughput", + "title": "Agents", + "titleLabel": "Title", + "titlePlaceholder": "e.g. Senior Engineer", + "tokenBudgetHint": "Maximum tokens this agent can use per budget period.", + "tokenBudgetLabel": "Token Budget", + "tokenStatistics": "Agent token usage statistics", + "tokenUsage": "Token Usage", + "tokenUsageByAgent": "Token Usage by Agent", + "tokenUsageTotals": "Token usage totals", + "total": "Total", + "totalRuns": "Total runs", + "unknownManager": "Unknown manager", + "unreadMessage": "Unread", + "upgradeHint": "Upgrade from", + "upgradeHintSuffix": "to the latest default.", + "upgradeHintTo": "to", + "upgradeToDefault": "Upgrade to default", + "upgradeToDefaultAriaLabel": "Upgrade heartbeat procedure to default", + "upgrading": "Upgrading...", + "uploadAvatar": "Upload avatar", + "usageThresholdHint": "Send a warning when usage reaches this percentage of the budget.", + "usageThresholdLabel": "Usage Warning Threshold (%)", + "useGlobalDefault": "Use global default", + "viewAgent": "View agent", + "viewDetailsFor": "View details for {{name}}", + "viewHeartbeatMarkdown": "View heartbeat markdown", + "viewLiveLogs": "View live run logs", + "viewLiveRun": "View live run details", + "viewLiveRunAria": "View live run details for {{name}}", + "viewLogsFor": "View live logs for {{taskId}}", + "viewSkillDetails": "View skill details", + "viewTask": "View task", + "waitingForActivity": "Waiting for activity...", + "waitingForQuestion": "Waiting for AI question...", + "waitingOutput": "Waiting for output...", + "weekly": "Weekly", + "workingOn": "Working on", + "zoomIn": "Zoom in", + "zoomOut": "Zoom out" + }, + "app": { + "backendError": { + "failedFetch": "Failed to fetch projects" + }, + "testMode": "Test mode — no real AI calls" + }, + "approval": { + "dismissBanner": "Dismiss approval notification banner", + "needAttention_one": "{{count}} approval {{noun}} need your attention", + "needAttention_other": "{{count}} approval {{noun}} need your attention", + "openMailbox": "Open Mailbox", + "requestPlural": "requests", + "requestSingular": "request", + "requests": "Approval requests" + }, + "auth": { + "clearAndRetry": "Clear token and retry", + "pasteToken": "Paste token", + "relogin": "Re-login", + "reloginRequired": "Re-login required: {{provider}}. Your {{provider}} session expired — sign in again to keep agents running.", + "reloginRequiredMultiple": "Re-login required: {{providers}}", + "replacementToken": "Replacement token", + "setAndReload": "Set token and reload", + "tokenRecoveryDescription": "This dashboard session can't authenticate with the daemon. Set a replacement token or clear the current token and retry.", + "tokenRequired": "Authentication token required" + }, + "backend": { + "changeLaunchMode": "Change Launch Mode…", + "connectionError": "Can't reach the Fusion backend", + "couldNotLoad": "Fusion couldn't load your projects right now. Please make sure the backend is running and try again.", + "error": "Error: {{error}}", + "manageConnection": "Manage Connection", + "retryConnection": "Retry Connection", + "retrying": "Retrying…" + }, + "backgroundTasks": { + "confirmMessage": "This session is active in another tab. Open anyway?", + "confirmTitle": "Open Active Session", + "dismissButton": "Dismiss", + "pillLabel_one": "AI {{count}}", + "pillLabel_other": "AI {{count}}", + "pillTitleWithInput_one": "{{count}} background AI task ({{needsInput}} needs input)", + "pillTitleWithInput_other": "{{count}} background AI task ({{needsInput}} needs input)", + "pillTitle_one": "{{count}} background AI task", + "pillTitle_other": "{{count}} background AI task", + "popoverHeader": "Background Tasks", + "status": { + "activeElsewhere": "active in another tab", + "failed": "Failed", + "generating": "generating...", + "needsInput": "needs input" + }, + "typeLabel": { + "milestoneInterview": "Milestone Interview", + "missionInterview": "Mission Interview", + "planning": "Planning", + "sliceInterview": "Slice Interview", + "subtask": "Subtask Breakdown" + } + }, + "board": { + "archived": "Archived", + "done": "Done", + "inProgress": "In Progress", + "inReview": "In Review", + "rejection": { + "capacityExhausted": "That column is at capacity. Try again when a slot frees up.", + "guardRejected": "This move is not allowed by the workflow.", + "mergeBlocked": "This task is blocked from completing until its merge step finishes.", + "promoteRejected": "This card could not be promoted.", + "unknownColumn": "That column doesn't exist in this task's workflow.", + "workflowMismatch": "Drag can't move a card between workflows. Use the workflow switcher instead." + }, + "todo": "To Do", + "triage": "Triage" + }, + "branchGroup": { + "abandonGroup": "Abandon group", + "autoMergeEnabled": "Auto-merge enabled", + "collapseLabel": "Collapse branch group", + "completionText": "{{landed}} of {{total}} members finished", + "expandLabel": "Expand branch group", + "groupLabel": "Group {{id}}", + "loadError": "Failed to load branch group", + "loading": "Loading branch group…", + "mergeIntoMain": "Merge group into main", + "openPr": "Open PR", + "unavailable": "Branch group unavailable" + }, + "capacity": { + "dismiss": "Dismiss capacity warning", + "risk": "Capacity risk:", + "status": "Todo {{todoCount}} (threshold {{threshold}}) · In Progress {{inProgress}} · In Review {{inReview}} · Idle agents {{idleAgents}}" + }, + "changes": { + "disableWrap": "Disable word wrap", + "enableWrap": "Enable word wrap", + "merged": "Merged {{date}}", + "nextFile": "Next file (Ctrl+↓)", + "nextFileAria": "Next file", + "noDiff": "No diff available for this file.", + "previousFile": "Previous file (Ctrl+↑)", + "previousFileAria": "Previous file", + "selectFile": "Select a file to view its diff", + "title": "Changes", + "toggleWrap": "Toggle word wrap" + }, + "chat": { + "archive": "Archive", + "attachFiles": "Attach files", + "cancel": "Cancel", + "cancelButton": "Cancel", + "clearConversationFailed": "Failed to clear conversation", + "closeQuickChat": "Close quick chat", + "connectingStatus": "Connecting…", + "conversationArchived": "Conversation archived", + "conversationDeleted": "Conversation deleted", + "copyFailed": "Copy failed", + "copyResponse": "Copy response", + "create": "Create", + "createButton": "Create", + "createRoom": "Create room", + "delete": "Delete", + "deleteConversation": "Delete conversation", + "deleteConversationBody": "This action cannot be undone. All messages in this conversation will be permanently deleted.", + "deleteConversationTitle": "Delete Conversation?", + "deleteRoom": "Delete room {{name}}", + "deleteRoomBody": "This action cannot be undone. This room and all its messages will be permanently deleted.", + "deleteRoomTitle": "Delete Room?", + "dismissQueuedMessage": "Dismiss queued message", + "failedToArchiveConversation": "Failed to archive conversation", + "failedToClearConversation": "Failed to clear conversation", + "failedToClearRoomConversation": "Failed to clear room conversation", + "failedToCreateSession": "Failed to create chat session", + "failedToDeleteConversation": "Failed to delete conversation", + "failedToDeleteRoom": "Failed to delete room", + "failedToSendRoomMessage": "Failed to send room message", + "failureDetails": "Failure details", + "helpMessageContent": "Available commands:\n- `/new` or `/clear` — Clear conversation and start fresh\n- `/skill:{name}` — Use a specific skill\n- `/help` — Show this help", + "jumpToLatest": "Latest", + "latest": "Latest", + "loadingAgents": "Loading agents...", + "loadingConversation": "Loading conversation…", + "loadingConversations": "Loading...", + "loadingMessages": "Loading messages...", + "loadingModels": "Loading models...", + "loadingOlderMessages": "Loading older messages…", + "loadingSessions": "Loading sessions…", + "loadingSkills": "Loading skills…", + "mentionNonMember": "Not a member of {{roomName}}", + "messageAgentPlaceholder": "Message {{name}}", + "messageModelPlaceholder": "Message {{name}}", + "messageRoomPlaceholder": "Message #{{name}}", + "messageSentButReplyFailed": "Message sent, but assistant reply failed", + "messageSentButReplyFailedDetail": "Message sent, but assistant reply failed: {{detail}}", + "modeAgent": "Agent", + "modeModel": "Model", + "newChat": "New Chat", + "newChatModeAgent": "Agent", + "newChatModeModel": "Model", + "newChatTitle": "New Chat", + "noAgentsAvailable": "No agents available", + "noConversationsYet": "No conversations yet", + "noMessages": "No messages", + "noMessagesYet": "No messages yet. Start the conversation!", + "noRoomsYet": "No rooms yet.", + "noSkillsAvailable": "No skills available", + "noSkillsFound": "No skills found", + "openQuickChat": "Open quick chat", + "queuedMessage": "Queued: {{preview}}", + "quickChatTitle": "Quick Chat", + "relativeTimeDays_one": "{{count}}d ago", + "relativeTimeDays_other": "{{count}}d ago", + "relativeTimeHours_one": "{{count}}h ago", + "relativeTimeHours_other": "{{count}}h ago", + "relativeTimeJustNow": "just now", + "relativeTimeMinutes_one": "{{count}}m ago", + "relativeTimeMinutes_other": "{{count}}m ago", + "removeAttachment": "Remove {{name}}", + "resizePanelBottom": "Resize panel from bottom", + "resizePanelBottomLeft": "Resize panel from bottom-left corner", + "resizePanelBottomRight": "Resize panel from bottom-right corner", + "resizePanelLeft": "Resize panel from left", + "resizePanelRight": "Resize panel from right", + "resizePanelTop": "Resize panel from top", + "resizePanelTopLeft": "Resize panel from top-left corner", + "resizePanelTopRight": "Resize panel from top-right corner", + "resizeSidebar": "Resize chat sidebar", + "responseCopied": "Response copied", + "responseFailed": "Response failed", + "roomMemberCount_one": "{{count}} member", + "roomMemberCount_other": "{{count}} members", + "roomsGroupLabel": "Rooms", + "scopeDirect": "Direct", + "scopeRooms": "Rooms", + "scrollMessageToTop": "Scroll message to top", + "searchConversations": "Search conversations...", + "selectAgentForNewChat": "Select agent for new chat", + "selectAgentPlaceholder": "Select an agent to start chatting", + "selectModel": "Select a model", + "selectModelOverrideLabel": "Select model override", + "selectModelPlaceholder": "Select a model to start chatting", + "selectModelPlaceholder2": "Select a model", + "selectRoomOrCreate": "Select a room or create one", + "selectSession": "Select a session", + "selectSessionLabel": "Select session", + "sendMessageFailed": "Failed to send message", + "sendRoomMessageFailed": "Failed to send room message", + "sessionsGroupLabel": "Sessions", + "showPlainText": "Show all messages as plain text", + "showRenderedMarkdown": "Show all messages as rendered Markdown", + "skillSuggestions": "Skill suggestions", + "startNewChat": "Start a new chat", + "startNewConversation": "Start a new conversation", + "stopGeneration": "Stop generation", + "thinking": "Thinking", + "thinkingLabel": "Thinking", + "thinkingStatus": "Thinking…", + "toolCalls": "Tool calls", + "toolCallsCount_one": "{{count}} tool calls", + "toolCallsCount_other": "{{count}} tool calls", + "typeMessage": "Type a message...", + "unreadMessages": "Unread messages", + "untitledSession": "Untitled", + "you": "You" + }, + "chatRooms": { + "error": { + "deliveredButRefreshFailed": "Message delivered, but failed to refresh room replies", + "failedToLoad": "Failed to load chat rooms", + "failedToUpload": "Failed to upload attachment: {{filename}}", + "selectRoomFirst": "Select a room before sending a message" + } + }, + "cli": { + "installBody": "Get the {{fn}} and {{fusion}} commands on your terminal so you can drive Fusion from anywhere. One click below or copy the command into your shell.", + "installButton": "Install with npm", + "installTitle": "Install the Fusion CLI", + "installing": "Installing…", + "openSettings": "Open Settings", + "updateButton": "Update with npm", + "updateTitle": "Update the Fusion CLI", + "updating": "Updating…", + "versionMismatchInfix": "but this dashboard expects", + "versionMismatchPrefix": "Your installed", + "versionMismatchSuffix": "Update to stay in sync." + }, + "cliBinary": { + "checking": "Checking…", + "copied": "Copied", + "copy": "Copy", + "failedExit": "Install failed (exit {{code}})", + "heading": "CLI Binary", + "help": "Installing the global CLI lets you run fn and fusion from any terminal. Automations and scripts work without it via npx, but a global install is faster and more convenient.", + "installWithNpm": "Install with npm", + "installing": "Installing…", + "notOnPath": "Neither fn nor fusion was found on PATH.", + "orCopyLabel": "Or copy and run yourself:", + "refresh": "Refresh", + "reinstall": "Reinstall", + "stateCheckDisabled": "Check disabled", + "stateInstalled": "Installed", + "stateMissing": "Not installed", + "stateVersionMismatch": "Version mismatch", + "succeededDuration": "Install succeeded in {{duration}}s" + }, + "column": { + "actionsAriaLabel": "{{columnLabel}} column actions", + "actionsTitle": "Column actions", + "archiveAllDoneAriaLabel": "Archive all done tasks", + "archiveAllDoneTitle": "Archive all done tasks", + "archiveAllMessage_one": "Archive all {{count}} done tasks?", + "archiveAllMessage_other": "Archive all {{count}} done tasks?", + "archiveAllTitle": "Archive All Done", + "archivedTasks_one": "Archived {{count}} tasks", + "archivedTasks_other": "Archived {{count}} tasks", + "autoMerge": "Auto-merge", + "autoMergeDisabled": "Auto-merge disabled", + "autoMergeEnabled": "Auto-merge enabled", + "cancelMove": "Cancel Move", + "collapseArchivedLabel": "Collapse archived tasks", + "collapseArchivedTitle": "Collapse archived tasks", + "expandArchivedLabel": "Expand archived tasks", + "expandArchivedTitle": "Expand archived tasks", + "failedToArchive": "Failed to archive tasks", + "keepProgress": "Keep Progress", + "loadMore_one": "Load {{count}} more ({{remaining}} remaining)", + "loadMore_other": "Load {{count}} more ({{remaining}} remaining)", + "moveAllToTodo": "Move All to Todo", + "moveAllToTodoMessage_one": "Move all {{count}} {{columnLabel}} task{{plural}} to Todo?", + "moveAllToTodoMessage_other": "Move all {{count}} {{columnLabel}} task{{plural}} to Todo?", + "moveAllToTodoTitle": "Move All to Todo", + "movePartialFailure": "Moved {{moved}} of {{total}} tasks; {{failed}} failed", + "moveToTodoHint_one": "Move {{count}} task{{plural}} to Todo", + "moveToTodoHint_other": "Move {{count}} task{{plural}} to Todo", + "moveToTodoPartialFailure": "Moved {{moved}} of {{total}} tasks to Todo; {{failed}} failed", + "movedToPlanning_one": "Moved {{count}} task{{plural}} to planning for replanning", + "movedToPlanning_other": "Moved {{count}} task{{plural}} to planning for replanning", + "movedToTodo_one": "Moved {{count}} task{{plural}} to Todo", + "movedToTodo_other": "Moved {{count}} task{{plural}} to Todo", + "newTask": "New Task", + "noManuallyPausableTasks": "No manually pausable tasks", + "noTasks": "No tasks", + "noTasksInColumn": "No tasks in this column", + "pauseHint_one": "Pause {{count}} active unassigned task{{plural}}", + "pauseHint_other": "Pause {{count}} active unassigned task{{plural}}", + "preserveProgressMessage": "This task has completed steps. Keep progress before moving?", + "preserveProgressMoveTodoMessage": "Some tasks have completed steps. Keep progress before moving to Todo?", + "preserveProgressTitle": "Preserve Progress?", + "promote": "Promote", + "promoting": "Promoting…", + "replanAll": "Replan All", + "replanAllHint_one": "Move {{count}} task{{plural}} to Planning", + "replanAllHint_other": "Move {{count}} task{{plural}} to Planning", + "replanAllMessage_one": "Move all {{count}} todo task{{plural}} back to planning to be replanned?", + "replanAllMessage_other": "Move all {{count}} todo task{{plural}} back to planning to be replanned?", + "replanAllTitle": "Replan All Tasks", + "resetProgress": "Reset Progress", + "resetProgressConfirm": "Reset Progress", + "resetProgressMessage": "Reset all step progress before moving this task?", + "resetProgressMoveTodoMessage": "Reset step progress for tasks before moving to Todo?", + "resetProgressTitle": "Reset Progress?", + "stopAll": "Stop All", + "stopAllMessage_one": "Stop all {{count}} {{columnLabel}} task{{plural}}?", + "stopAllMessage_other": "Stop all {{count}} {{columnLabel}} task{{plural}}?", + "stopAllTitle": "Stop All Tasks", + "stopPartialFailure": "Stopped {{paused}} of {{total}} tasks; {{failed}} failed", + "stoppedTasks_one": "Stopped {{count}} task{{plural}}", + "stoppedTasks_other": "Stopped {{count}} task{{plural}}" + }, + "comments": { + "addButton": "Add Comment", + "addedSuccess": "Comment added", + "deletedSuccess": "Comment deleted", + "deletingButton": "Deleting…", + "editedSuffix": "(edited)", + "emptyState": "No comments yet.", + "heading": "Comments", + "placeholder": "Add a comment", + "postingButton": "Posting…", + "updatedSuccess": "Comment updated" + }, + "commit": { + "filesChanged_one": "Files Changed ({{count}})", + "filesChanged_other": "Files Changed ({{count}})" + }, + "commitDiff": { + "error": "Error loading commit diff: {{error}}", + "loadError": "Failed to load commit diff", + "loading": "Loading commit diff...", + "mergedOnly": "Commit diff is only available for tasks that were merged.", + "noFiles": "No files changed in this commit.", + "noSha": "No commit SHA available." + }, + "common": { + "archive": "Archive", + "back": "Back", + "cancel": "Cancel", + "close": "Close", + "closeAriaLabel": "Close", + "continue": "Continue", + "create": "Create", + "delete": "Delete", + "edit": "Edit", + "editMode": "Edit", + "learnMore": "Learn more →", + "loading": "Loading...", + "no": "No", + "noDataAvailable": "No data available", + "nothingToShow": "There is nothing to show yet.", + "preview": "Preview", + "previewMode": "Preview", + "refresh": "Refresh", + "retry": "Retry", + "save": "Save", + "saveAndTest": "Save & Test", + "saving": "Saving...", + "skip": "Skip", + "somethingWentWrong": "Something went wrong while loading this view.", + "stop": "Stop", + "test": "Test", + "testing": "Testing…", + "total": "total", + "tryAgain": "Try Again", + "unableToLoadData": "Unable to load data", + "unknown": "Unknown", + "unsavedChanges": "Unsaved changes", + "yes": "Yes" + }, + "composer": { + "loadingAgents": "Loading agents…", + "messageId": "Message {{id}}", + "messageLabel": "Message:", + "messagePlaceholder": "Type your message…", + "newMessageTitle": "New Message", + "noAgentsAvailable": "No agents available", + "replyTitle": "Reply", + "replyingToLabel": "Replying to:", + "selectAgent": "Select agent…", + "sendingButton": "Sending…", + "toLabel": "To:", + "wakeAgentCheckbox": "Wake agent immediately", + "wakeAlwaysImmediate": "(agent is already set to immediate response mode)", + "wakeOneOff": "(one-off override for this message only)" + }, + "confirm": { + "cancel": "Cancel", + "closeDialog": "Close confirmation dialog", + "confirm": "Confirm" + }, + "conversation": { + "aiReasoning": "AI Reasoning", + "aiThinking": "AI thinking", + "confirm": { + "no": "No", + "yes": "Yes" + }, + "hide": "Hide {{type}}", + "show": "Show {{type}}", + "yourResponse": "Your response" + }, + "createRoom": { + "create": "Create room", + "creating": "Creating...", + "failedCreate": "Failed to create room.", + "failedLoadAgents": "Failed to load agents.", + "loadingAgents": "Loading agents...", + "members": "Members", + "nameLabel": "Room name", + "noAgents": "No agents in this project yet.", + "noMatch": "No agents match your search.", + "searchAgents": "Search agents", + "selectMember": "Select at least one member.", + "title": "Create room" + }, + "dashboard": { + "initializingDashboard": "Initializing dashboard...", + "loadingMessage": "Loading Fusion dashboard", + "loadingProgress": "Dashboard loading progress", + "updatingMessage": "Updating Fusion dashboard", + "updatingVersion": "Updating to a new frontend version..." + }, + "dbBanner": { + "body": "Fusion's background SQLite integrity check reported corruption. Review the failing objects below before continuing critical operations.", + "instructions": "Back up the project, try {{cmd}} if the database still opens cleanly, and restore from a known-good backup if corruption persists. See {{link}} for the storage layout and recovery guidance.", + "lastChecked": "Last checked: {{checkedAtLabel}}", + "refreshHealth": "Refresh health", + "refreshing": "Refreshing…", + "title": "Database corruption detected", + "whatToDo": "What to do:" + }, + "deepLink": { + "projectNotFound": "Project '{{id}}' not found", + "taskNotFound": "Task {{id}} not found" + }, + "desktop": { + "chooseMode": "How do you want to run Fusion?", + "chooseModeDescription": "Run Fusion locally in this app, or connect to a Fusion server you're already running somewhere else.", + "connectRemoteButton": "Connect to Remote Fusion", + "couldNotStart": "Couldn't start local Fusion", + "loading": "Loading Fusion…", + "opening": "Opening…", + "retry": "Retry", + "runLocalButton": "Run Fusion Locally", + "settingUpRemote": "Setting up remote connection…", + "starting": "Starting…", + "startingLocalRuntime": "Starting local Fusion runtime…" + }, + "devserver": { + "allSeverities": "All severities", + "auto": "Auto", + "autoDetected": "Auto-detected: {{url}}", + "change": "Change", + "clear": "Clear", + "command": "Command", + "configuration": "Configuration", + "embedded": "Embedded", + "embeddedPreviewDisabled": "Embedded preview is disabled. Open your app in a separate browser tab.", + "enterFullscreen": "Enter fullscreen logs", + "errBadge": "ERR", + "error": "Error", + "exitFullscreen": "Exit fullscreen logs", + "externalOnly": "External only", + "filterBySeverity": "Filter logs by severity", + "info": "Info", + "lines": "{{count}} lines", + "lines_one": "{{count}} lines", + "lines_other": "{{count}} lines", + "loadOlderLogs": "Load older logs", + "loading": "Loading...", + "loadingConfig": "Loading dev server configuration...", + "loadingLogs": "Loading logs…", + "loadingOlderLogs": "Loading older logs…", + "logs": "Logs", + "lostConnection": "Lost log stream connection.", + "manual": "Manual", + "matchCount_one": "{{count}} match", + "matchCount_other": "{{count}} matches", + "newLogs": "New logs", + "noLogsYet": "No logs yet. Start the dev server to see output.", + "noMatchesSearch": "No log lines match your search.", + "noMatchesSeverity": "No log lines match the selected severity.", + "noPreviewDetected": "No preview URL detected. Start the dev server or set a manual URL to preview your app.", + "noPreviewUrl": "No preview URL", + "noScriptsDetected": "No dev server scripts detected. Check that your project has a package.json with a dev, start, or similar script.", + "notAvailable": " · Not available", + "openInNewTab": "Open in new tab", + "openPreviewInNewTab": "Open preview in new tab", + "openPreviewOrRetry": "Open the preview in a new tab, or retry embedded mode after checking your server settings.", + "preview": "Preview", + "previewBlocked": "Preview blocked", + "previewFailed": "Preview failed", + "previewUrlOverride": "Preview URL Override", + "refreshPreview": "Refresh preview", + "restart": "Restart", + "restarting": "Restarting...", + "retry": "Retry", + "retryEmbeddedPreview": "Retry embedded preview", + "runningCommand": "Running command", + "save": "Save", + "scriptSelection": "Script Selection", + "searchLogs": "Search logs", + "start": "Start", + "startDevServer": "Start a dev server to see a live preview here.", + "starting": "Starting...", + "status": { + "failed": "Failed", + "running": "Running", + "starting": "Starting...", + "stopped": "Stopped", + "stopping": "Stopping..." + }, + "stop": "Stop", + "stopping": "Stopping...", + "title": "Dev Server", + "toast": { + "clearedScript": "Cleared selected dev server script.", + "enterCommand": "Enter a command before starting the dev server.", + "previewCleared": "Preview URL override cleared.", + "previewUpdated": "Preview URL updated.", + "restarted": "Dev server restarted.", + "selectedScript": "Selected {{name}} script.", + "started": "Dev server started.", + "stopped": "Dev server stopped." + }, + "warn": "Warn" + }, + "dirPicker": { + "ariaLabel": "Directory browser", + "browse": "Browse", + "closeBrowser": "Close directory browser", + "defaultPlaceholder": "/path/to/your/project", + "hideHidden": "Hide hidden", + "hideHiddenAria": "Hide hidden directories", + "hideHiddenTitle": "Hide hidden", + "loading": "Loading…", + "noSubdirs": "No subdirectories", + "openBrowser": "Browse directories", + "parentDir": "Go to parent directory", + "parentDirTitle": "Parent directory", + "select": "Select", + "showHidden": "Show hidden", + "showHiddenAria": "Show hidden directories", + "showHiddenTitle": "Show hidden", + "up": "Up" + }, + "docker": { + "actions": { + "addMount": "Add mount", + "addVariable": "Add variable", + "cancel": "Cancel", + "createNode": "Create Docker Node" + }, + "ariaLabels": { + "closeModal": "Close onboarding modal", + "modal": "Docker node onboarding", + "removeMount": "Remove volume mount", + "removeVariable": "Remove environment variable" + }, + "available": "Docker is available{{version}}", + "caPath": "CA Certificate Path", + "certPath": "Client Certificate Path", + "connected": "Connected{{version}}", + "connectionFailed": "Connection failed", + "container": "Container:", + "context": "Docker Context", + "creatingNode": "Creating Docker node...", + "errors": { + "cpusMinimum": "CPUs must be at least 0.5", + "memoryMinimum": "Memory must be at least 512 MB", + "nameRequired": "Name is required and must be 64 characters or fewer", + "urlRequired": "URL is required" + }, + "failedAt": "Failed at: {{stage}}", + "failedMessage": "Provisioning failed", + "host": "Docker Host", + "hostPlaceholder": "tcp://host:2376", + "keyPath": "Client Key Path", + "labels": { + "apiKey": "API Key", + "cpus": "CPUs", + "image": "Image", + "memory": "Memory (MB)", + "nodeName": "Node Name", + "reachableUrl": "Reachable URL", + "tag": "Tag" + }, + "local": "Local Docker", + "notFound": "Docker not found{{error}}", + "notFoundError": "Docker not found: {{message}}", + "options": { + "autoGenerate": "Auto-generate", + "claudeCli": "Claude CLI", + "droidCli": "Droid CLI", + "persistentStorage": "Keep data across container recreations", + "provideManually": "Provide manually" + }, + "placeholders": { + "apiKey": "Enter API key", + "containerPath": "Container path", + "envVarKey": "KEY", + "envVarValue": "Value", + "hostPath": "Host path", + "image": "runfusion/fusion", + "nodeName": "my-docker-node", + "tag": "latest" + }, + "provisionedIn": "Provisioned in {{durationSec}}s", + "refreshContexts": "Refresh contexts", + "remote": "Remote Host", + "retry": "Retry", + "sections": { + "advanced": "Advanced", + "environmentVariables": "Environment Variables", + "requiredSettings": "Required Settings", + "volumeMounts": "Volume Mounts" + }, + "selectContext": "Select context", + "stage": { + "creatingContainer": "Creating container...", + "pullingImage": "Pulling image...", + "registeringNode": "Registering node...", + "startingContainer": "Starting container..." + }, + "states": { + "creating": "Creating..." + }, + "successMessage": "Node created successfully!", + "targetMode": "Docker target mode", + "testConnection": "Test Connection", + "testing": "Testing...", + "titles": { + "provisionNode": "Provision Docker Node" + }, + "useTls": "Use TLS", + "verifyTls": "Verify TLS Certificate", + "viewNode": "View Node" + }, + "documents": { + "backToFiles": "Back to files", + "backToFilesList": "Back to project files list", + "clearSearch": "Clear search", + "collapse": "Collapse", + "collapseContent": "Collapse content", + "docCount_one": "{{count}} doc{{plural}}", + "docCount_other": "{{count}} doc{{plural}}", + "documentsCreatedIn": "Documents are created in task detail tabs.", + "expand": "Expand", + "expandContent": "Expand content", + "failedToLoad": "Failed to load {{type}}: {{error}}", + "hideHidden": "Hide hidden project files", + "hideHiddenFiles": "Hide hidden files", + "hideHiddenLabel": "Hide Hidden", + "loadingFileContent": "Loading file content…", + "loadingProjectFiles": "Loading project markdown files…", + "loadingTaskDocuments": "Loading task documents…", + "markdown": "Markdown", + "noMarkdownFiles": "No Markdown files found in this project.", + "noMatchProject": "No project markdown files match \"{{query}}\".", + "noMatchTask": "No task documents match \"{{query}}\".", + "noTaskDocuments": "No task documents yet.", + "openTask": "Open task", + "plain": "Plain", + "projectFiles": "project files", + "projectFilesTab": "Project Files", + "resultCount_one": "{{count}} result{{plural}}", + "resultCount_other": "{{count}} result{{plural}}", + "retry": "Retry", + "retryLoading": "Retry loading documents", + "searchProjectFiles": "Search project markdown files…", + "searchTaskDocuments": "Search task documents…", + "selectFile": "Select a Markdown file to view its content.", + "showHidden": "Show hidden project files", + "showHiddenFiles": "Show hidden files", + "showHiddenLabel": "Show Hidden", + "showProjectFiles": "Show project markdown files", + "showTaskDocuments": "Show task documents", + "switchToMarkdown": "Switch to markdown", + "switchToPlainText": "Switch to plain text", + "taskDocuments": "task documents", + "taskDocumentsTab": "Task Documents", + "title": "Documents", + "untitled": "Untitled" + }, + "droidCli": { + "active": "Active", + "cardTitle": "Factory AI — via Droid CLI", + "connectedVersion": "Connected{{version}}", + "description": "Route AI calls through your locally-installed droid CLI. Uses your existing Factory AI subscription / quota instead of an API key.", + "details": "Details", + "detected": "droid {{version}} detected{{path}}. Click Enable to route AI calls through it.", + "disable": "Disable", + "disabling": "Disabling…", + "droidNotFound": "droid not found on PATH", + "enable": "Enable", + "enabledValidating": "Enabled. Validating…", + "enabling": "Enabling…", + "extensionFailed": "Extension load failed: {{reason}}", + "modelsHidden": "Factory AI (via Droid CLI) models are hidden from the model picker.", + "modelsNowVisible": "Factory AI (via Droid CLI) models are now visible in the model picker.", + "notConnected": "Not connected", + "notInstalled": "Not installed", + "notOnPath": "droid binary not detected on PATH — install Droid CLI first.", + "probingCli": "Probing local CLI…", + "restartRequired": "Restart required: restart your active CLI/chat session for routing changes to take effect.", + "test": "Test", + "testing": "Testing…", + "toastDisabled": "Disabled", + "toastEnabled": "Enabled" + }, + "duplicateWarning": { + "cancel": "Cancel", + "createAnyway": "Create anyway", + "message": "We found similar active tasks. Open an existing task or create this one anyway.", + "open": "Open", + "title": "Possible duplicates", + "untitledTask": "Untitled task" + }, + "editor": { + "failedLoadFile": "Failed to load file", + "failedSaveFile": "Failed to save file", + "failedToLoadFile": "Failed to load file", + "failedToSaveFile": "Failed to save file" + }, + "evals": { + "allRuns": "All runs", + "disabledTitle": "Scheduled evals are disabled", + "empty": "No evals yet. Scheduled evals review tasks completed since the last run.", + "enablePrompt": "Enable Scheduled Evals to review scored tasks, evidence, and follow-up recommendations.", + "evidenceHeading": "Evidence", + "loading": "Loading evals…", + "maxScorePlaceholder": "Max score", + "minScorePlaceholder": "Min score", + "naPlaceholder": "n/a", + "noFollowups": "None", + "noRationale": "No rationale recorded.", + "openSettings": "Open Scheduled Evals Settings", + "overallScore": "Overall score: {{score}}", + "refreshAria": "Refresh evals", + "searchPlaceholder": "Search task or rationale", + "selectPrompt": "Select an evaluation to inspect scores, rationale, and evidence.", + "suggestedFollowupsHeading": "Suggested follow-up tasks" + }, + "executor": { + "blocked": "Blocked", + "daysAgo_one": "{{count}}d ago", + "daysAgo_other": "{{count}}d ago", + "escalated": "Escalated", + "escalatedSuffix": " (escalated)", + "hideProjectDir": "Hide project directory", + "hoursAgo_one": "{{count}}h ago", + "hoursAgo_other": "{{count}}h ago", + "inReview": "In Review", + "justNow": "just now", + "loading": "Loading...", + "minutesAgo_one": "{{count}}m ago", + "minutesAgo_other": "{{count}}m ago", + "noActivity": "no activity", + "overlapBottleneck_one": "{{status}} overlap bottleneck {{blockerId}}: {{count}} todo blocked via blockedBy (threshold {{threshold}})", + "overlapBottleneck_other": "{{status}} overlap bottleneck {{blockerId}}: {{count}} todo blocked via blockedBy (threshold {{threshold}})", + "overlapQueue": "Overlap queue", + "queued": "Queued", + "running": "Running", + "secondsAgo_one": "{{count}}s ago", + "secondsAgo_other": "{{count}}s ago", + "showProjectDir": "Show project directory", + "stateIdle": "Idle", + "statePaused": "Paused", + "stateRunning": "Running", + "status": "Executor status", + "stuck": "Stuck", + "temporary": "Temporary" + }, + "fileBrowser": { + "back": "Back to file list", + "binaryReadOnly": "Binary file — read only", + "contextCopy": "Copy", + "contextDelete": "Delete", + "contextDownload": "Download", + "contextDownloadZip": "Download as ZIP", + "contextMenuLabel": "File operations", + "contextMove": "Move", + "contextRename": "Rename", + "copy": "Copy", + "copyPlaceholder": "Destination path", + "copyTitle": "Copy", + "delete": "Delete", + "deleteConfirm": "Are you sure you want to delete {{name}}?", + "deleteRecursive": "This will delete all contents recursively.", + "deleteTitle": "Delete {{type}}", + "deleting": "Deleting...", + "emptyDirectory": "(empty directory)", + "error": "Error: {{message}}", + "loading": "Loading…", + "loadingFiles": "Loading files...", + "modalTitle": "Files — {{workspace}}", + "modified": "Modified: {{date}}", + "move": "Move", + "movePlaceholder": "Destination path", + "moveTitle": "Move", + "operationFailed": "Operation failed", + "operationSuffix": "ing...", + "rename": "Rename", + "renamePlaceholder": "New name", + "renameTitle": "Rename", + "resizeSidebar": "Resize sidebar", + "root": "Root", + "saving": "Saving…", + "selectFileToEdit": "Select a file to edit", + "toggleEditorOptions": "Toggle editor options", + "typeFile": "File", + "typeFolder": "Folder", + "unsavedChanges": "Unsaved changes", + "upOneLevel": "Up one level", + "workspaceProject": "Project" + }, + "fileEditor": { + "edit": "Edit", + "editMode": "Edit mode", + "editorFor": "Editor for {{filePath}}", + "fileEditor": "File editor", + "lineNumber": "Line #", + "preview": "Preview", + "previewMode": "Preview mode", + "toggleLineNumbers": "Toggle line numbers", + "toggleOptions": "Toggle editor options", + "toggleWordWrap": "Toggle word wrap", + "wrap": "Wrap" + }, + "fileMention": { + "empty": "No tasks or files found", + "fileHeader": "Files", + "fileMatches": "File matches", + "taskHeader": "Tasks", + "taskMatches": "Task matches" + }, + "git": { + "add": "Add", + "addRemote": "Add Remote", + "advancesHelpFix": "Fix: Fusion only shows Sync working tree when at least one advance is genuinely pending and HEAD is not aligned with the integration tip. If entries are already handled (subsumed/orphaned/reachable/superseded), no sync action is offered.", + "advancesHelpIntro": "Each entry is a Fusion task whose squash commit advanced the integration branch ref ({{integrationBranch}}). The auto-sync outcome says whether your working tree was also fast-forwarded to that new tip.", + "advancesHelpItem1": "clean-sync / synced-with-edits-restored — working tree is in sync; nothing to do.", + "advancesHelpItem2": "reachable / subsumed / orphaned / superseded — already handled (including history rewrites where equivalent content already landed, original SHAs disappeared, or HEAD is already aligned to the rewritten integration tip).", + "advancesHelpItem3": "pending + off / not run — auto-sync is disabled in Settings; the branch ref moved but your worktree didn't follow.", + "advancesHelpItem4": "pending + stash-failed / would-conflict / similar — auto-sync tried but couldn't reconcile (usually local edits collide with the new commit).", + "advancesNeedAction_one": "{{count}} need action", + "advancesNeedAction_other": "{{count}} need action", + "aheadOfUpstream_one": "{{count}} commit(s) ahead of upstream", + "aheadOfUpstream_other": "{{count}} commit(s) ahead of upstream", + "aligned": "Aligned", + "apply": "Apply", + "applyStashKeep": "Apply stash (keep)", + "autoMergeDisabled": "Auto-merge disabled", + "autoMergeEnabled": "Auto-merge enabled", + "autoMergeUpdateFailed": "Failed to update auto-merge", + "autoMergeWhenGreen": "Auto-merge when green", + "autoMergeWillHandle": "Auto-merge will handle this task automatically.", + "autoMovedToTodo": "Auto-moved to Todo — reviewer feedback ready", + "autoSyncOffNotRun": "auto-sync: off / not run", + "autoSyncOutcome": "auto-sync: {{outcome}}", + "awaitingPrChecks": "Waiting for required checks or review feedback before auto-merge.", + "backToIssuesList": "Back to issues list", + "backToPullsList": "Back to pull requests list", + "baseHead": "Base: HEAD", + "behindUpstream_one": "{{count}} commit(s) behind upstream", + "behindUpstream_other": "{{count}} commit(s) behind upstream", + "branchLabel": "Branch:", + "cancel": "Cancel", + "capturedAt": "Captured:", + "changesStashed": "Changes stashed", + "checkout": "Checkout", + "chooseAnotherRepo": "Choose another repository.", + "clickToViewDiff": "Click to view diff", + "close": "Close", + "closeModalAriaLabel": "Close import modal", + "commentLast": "Last:", + "commit": "Commit", + "commitMessagePlaceholder": "Commit message...", + "commitStagedChanges": "Commit staged changes", + "commitsOnBranch": "Commits on {{name}}", + "commitsToPull_one": "{{count}} to pull", + "commitsToPull_other": "{{count}} to pull", + "commitsToPushHeader_one": "Commits to Push ({{count}})", + "commitsToPushHeader_other": "Commits to Push ({{count}})", + "commitsToPush_one": "{{count}} to push", + "commitsToPush_other": "{{count}} to push", + "committedHash": "Committed: {{hash}}", + "conflictReclaimFailed": "Failed to queue conflict reclaim", + "conflictReclaimQueued": "Conflict reclaim queued", + "conflictReclaimUnavailable": "Conflict reclaim unavailable", + "conflictedCount_one": "{{count}} conflicted", + "conflictedCount_other": "{{count}} conflicted", + "conflictsButton": "Conflicts", + "copiedButton": "Copied", + "copiedLabel": "Copied {{label}}", + "copiedToClipboard": "Copied to clipboard", + "copyButton": "Copy", + "copyCommitHashLabel": "commit hash", + "copyFetchUrl": "Copy fetch URL", + "copyFullCommitHashLabel": "full commit hash", + "copyFullHash": "Copy full hash", + "copyFullShaTitle": "Copy full 40-char SHA", + "copyPushUrl": "Copy push URL", + "copyShortHashTitle": "Copy short commit hash", + "copyShortHashTitleWithFull": "Copy short commit hash (use the full SHA below for git operations)", + "couldNotLoadIssues": "Could not load issues", + "couldNotLoadPulls": "Could not load pull requests", + "create": "Create", + "createPrButton": "Create PR", + "createPrTitle": "Create a PR for this task", + "createdBranch": "Created branch {{name}}", + "defaultBadge": "default", + "deleteBranch": "Delete", + "deleteBranchMessage": "Delete branch \"{{name}}\"?", + "deleteBranchTitle": "Delete Branch", + "deletedBranch": "Deleted branch {{name}}", + "detectingRemotes": "Detecting…", + "diffColon": "diff:", + "discardChangesMessage_one": "Discard changes to {{count}} file(s)? This cannot be undone.", + "discardChangesMessage_other": "Discard changes to {{count}} file(s)? This cannot be undone.", + "discardChangesTitle": "Discard Changes", + "discardSelected": "Discard selected", + "discardedFiles_one": "Discarded changes to {{count}} file(s)", + "discardedFiles_other": "Discarded changes to {{count}} file(s)", + "dismiss": "Dismiss", + "dismissPrError": "Dismiss PR error", + "dropStash": "Drop stash", + "dropStashMessage": "Drop stash@{{{index}}}? This cannot be undone.", + "dropStashTitle": "Drop Stash", + "editRemoteName": "Edit remote name", + "editRemoteUrl": "Edit remote URL", + "failedToAddRemote": "Failed to add remote", + "failedToApplyStash": "Failed to apply stash", + "failedToCheckoutBranch": "Failed to checkout branch", + "failedToCommit": "Failed to commit", + "failedToCopy": "Failed to copy", + "failedToCreateBranch": "Failed to create branch", + "failedToDeleteBranch": "Failed to delete branch", + "failedToDiscardChanges": "Failed to discard changes", + "failedToDropStash": "Failed to drop stash", + "failedToFetchData": "Failed to fetch git data", + "failedToFetchIssues": "Failed to fetch issues", + "failedToFetchPulls": "Failed to fetch pull requests", + "failedToImportIssue": "Failed to import issue", + "failedToImportPull": "Failed to import pull request", + "failedToLoadDiff": "Failed to load diff", + "failedToLoadFileDiff": "Failed to load file diff", + "failedToLoadRemoteCommits": "Failed to load remote commits", + "failedToLoadRemotes": "Failed to load remotes", + "failedToLoadStashDiff": "Failed to load stash diff", + "failedToRemoveRemote": "Failed to remove remote", + "failedToRenameRemote": "Failed to rename remote", + "failedToStageFiles": "Failed to stage files", + "failedToStashChanges": "Failed to stash changes", + "failedToUnstageFiles": "Failed to unstage files", + "failedToUpdateRemoteUrl": "Failed to update remote URL", + "fetch": "Fetch", + "fetchCompleted": "Fetch completed", + "fetchFailed": "Fetch failed", + "fetchLabel": "Fetch:", + "fetchUrlLabel": "fetch URL", + "fetchingFromGitHub": "Fetching the latest list from GitHub.", + "filterBranches": "Filter branches...", + "filterByLabelsLabel": "Filter by labels", + "filterByLabelsPlaceholder": "Filter: bug,enhancement…", + "filterIssuesByLabels": "Filter issues by labels", + "fnCreatingPr": "fn is creating a pull request automatically for this task.", + "fnMergingPr": "fn is merging this pull request automatically.", + "forceDeleteBranchMessage": "Branch has unmerged commits. Force delete?", + "forceDeleteBranchTitle": "Force Delete Branch", + "forceDeletedBranch": "Force deleted branch {{name}}", + "fullShaAbbrev": "full", + "ghAuthLoginHint": "Run {{code}} to enable PR creation.", + "headAheadOfIntegration_one": "HEAD has {{count}} commit(s) not on {{branch}}", + "headAheadOfIntegration_other": "HEAD has {{count}} commit(s) not on {{branch}}", + "headAheadOfOriginIntegration_one": "HEAD has {{count}} commit(s) not on origin/{{branch}}", + "headAheadOfOriginIntegration_other": "HEAD has {{count}} commit(s) not on origin/{{branch}}", + "headVsIntegration": "HEAD vs {{branch}}", + "headVsOriginIntegration": "HEAD vs origin/{{branch}}", + "hide": "Hide", + "hideExplanation": "Hide explanation", + "import": "Import", + "importFromGitHub": "Import from GitHub", + "importSubtitle": "Choose a detected remote, load open issues or pull requests, and import one into the board.", + "importTypeAriaLabel": "Import type", + "imported": "Imported", + "importedCount_one": "{{count}} imported", + "importedCount_other": "{{count}} imported", + "integrationAheadOfHead_one": "{{branch}} has {{count}} commit(s) HEAD doesn't", + "integrationAheadOfHead_other": "{{branch}} has {{count}} commit(s) HEAD doesn't", + "issueCount_one": "{{count}} issue", + "issueCount_other": "{{count}} issues", + "load": "Load", + "loadFromRepoAriaLabel": "Load {{tab}} from repository", + "loadMoreCommits": "Load more commits", + "loadTabTitle": "Load {{tab}}", + "loading": "Loading…", + "loadingAriaLabel": "Loading {{tab}}", + "loadingCommits": "Loading commits...", + "loadingDiff": "Loading diff...", + "loadingIssues": "Loading open issues…", + "loadingPulls": "Loading open pull requests…", + "loadingStashDiff": "Loading stash diff…", + "loadingTitle": "Loading…", + "localAheadOfOriginIntegration_one": "Local {{branch}} is {{count}} commit(s) ahead of origin/{{branch}}", + "localAheadOfOriginIntegration_other": "Local {{branch}} is {{count}} commit(s) ahead of origin/{{branch}}", + "localBehindOriginIntegration_one": "Local {{branch}} is {{count}} commit(s) behind origin/{{branch}}", + "localBehindOriginIntegration_other": "Local {{branch}} is {{count}} commit(s) behind origin/{{branch}}", + "localVsOrigin": "Local {{branch}} vs origin", + "manualPrFlowHint": "Use the footer action to run PR-first completion for this task.", + "mergeBadge": "merge", + "mergeConflictDetected": "Merge conflict detected. Resolve/rebase branch and retry reclaim.", + "mergeLabel": "Merge", + "mergePrButton": "Merge pull request", + "mergeStrategyMerge": "merge", + "mergeStrategyRebase": "rebase", + "mergeStrategySquash": "squash", + "mergedTaskDone": "Merged — task moved to Done", + "mergingPrHint": "Merging pull request…", + "mergingStatus": "Merging…", + "modalTitle": "Git Manager", + "modified": "Modified", + "modifiedCount_one": "{{count}} modified", + "modifiedCount_other": "{{count}} modified", + "newBranchName": "New branch name", + "noAheadCommitsFound": "No ahead commits found (may need to fetch first)", + "noBranchesFound": "No branches found", + "noCommitsFound": "No commits found", + "noCommitsOnRemote": "No commits found on {{remote}}. Try fetching first.", + "noDescription": "(no description)", + "noIssueSelected": "No issue selected", + "noIssueSelectedHint": "Choose an issue from the list to inspect its title and description.", + "noLocalRefTitle": "No local refs/heads/ exists; nothing to compare against origin.", + "noLocalTracking": "no local tracking", + "noMatchingBranches": "No matching branches", + "noMatchingCommits": "No matching commits", + "noOpenIssues": "No open issues found", + "noOpenPulls": "No open pull requests found", + "noOriginTracking": "no origin tracking", + "noPullSelected": "No pull request selected", + "noPullSelectedHint": "Choose a pull request from the list to inspect its details.", + "noRefFound": "no ref found for {{branch}}", + "noRefFoundTitle": "Neither refs/heads nor refs/remotes/origin has this branch", + "noRemotes": "No remotes", + "noRemotesDetected": "No GitHub remotes detected", + "noRemotesInstructions": "Add a GitHub remote to this repository, then reopen the modal.", + "noReviewComments": "No review comments synced yet", + "noReviewsYet": "No reviews yet", + "noStagedChanges": "No staged changes", + "noStagedChangesToCommit": "No staged changes to commit", + "noStashes": "No stashes", + "noUnstagedChanges": "No unstaged changes", + "notOnIntegrationBranch": "(not on {{branch}})", + "notOnIntegrationBranchBtn": "Not on integration branch ({{branch}})", + "notOnIntegrationBranchTitle": "Currently on a non-integration branch", + "nothingLoadedInstructions": "Select a repository and click Load to start reviewing import candidates.", + "nothingLoadedYet": "Nothing loaded yet", + "openPullsFrom": "Open pull requests from {{remote}}", + "originIntegrationAheadOfHead_one": "origin/{{branch}} has {{count}} commit(s) HEAD doesn't", + "originIntegrationAheadOfHead_other": "origin/{{branch}} has {{count}} commit(s) HEAD doesn't", + "pop": "Pop", + "popStashTitle": "Pop stash (apply and drop)", + "prAuthUnavailable": "PR auth unavailable — run 'gh auth login'", + "prChecks": { + "empty": "No checks reported yet", + "refreshAriaLabel": "Refresh checks", + "required": "Required", + "retry": "Retry", + "summary": "{{passing}} passing, {{failing}} failing, {{pending}} pending", + "viewDetails": "View details" + }, + "prMergeFailed": "Failed to merge pull request", + "prMerged": "Pull request merged", + "prRefreshFailed": "Failed to refresh PR", + "prStatusRefreshed": "PR status refreshed", + "prUnlinkConfirm": "Unlink PR #{{number}} from this task? The PR will not be closed.", + "prUnlinked": "Unlinked PR #{{number}}", + "previewHeading": "Preview", + "previewIssueMeta": "Issue #{{number}}", + "previewPullMeta": "Pull Request #{{number}}", + "projectRootNotAvailable": "Project root path not available", + "pull": "Pull", + "pullCompleted": "Pull completed", + "pullCount_one": "{{count}} pull request", + "pullCount_other": "{{count}} pull requests", + "pullFailed": "Pull failed", + "pullOptions": "Pull options", + "pullOptionsMenu": "Pull options menu", + "pullRebase": "Pull --rebase", + "pullRebaseCompleted": "Pull --rebase completed", + "pullRequestHeading": "Pull Request", + "pullRequestsCount_one": "{{count}} pull requests", + "pullRequestsCount_other": "{{count}} pull requests", + "push": "Push", + "pushCompleted": "Push completed", + "pushFailed": "Push failed", + "pushLabel": "Push:", + "pushUrlLabel": "push URL", + "reCheckConflicts": "Re-check conflicts", + "recentCommitsOnRemote": "Recent commits on {{remote}}", + "recentIntegrationAdvances": "Recent integration-branch advances", + "refresh": "Refresh", + "refreshPrStatus": "Refresh PR status", + "refreshToCheckMerge": "Refresh PR status to check merge readiness", + "remoteAdded": "Remote '{{name}}' added successfully", + "remoteName": "Remote name", + "remoteOnlyIntegrationTipTitle": "No local refs/heads/; using refs/remotes/origin/ as the integration tip.", + "remoteOnlyTrackLocally": "(remote-only — run git switch {{branch}} to track locally)", + "remoteRemoved": "Remote '{{name}}' removed", + "remoteRenamed": "Remote renamed to '{{name}}'", + "remoteUrlUpdated": "Remote URL updated", + "removeRemote": "Remove remote", + "removeRemoteMessage": "Are you sure you want to remove remote '{{name}}'?", + "removeRemoteTitle": "Remove Remote", + "repoMustBeSelected": "Repository must be selected", + "repositoryLabel": "Repository", + "repositoryStatus": "Repository Status", + "repositoryUrl": "Repository URL", + "resizeIssuesList": "Resize issues list", + "resolvedFrom": "Resolved from {{source}}", + "retry": "Retry", + "retryButton": "Retry", + "retryConflictReclaim": "Retry conflict reclaim", + "reviewLabel": "Review", + "reviewsLabel": "Reviews", + "searchCommits": "Search commits...", + "sectionBranches": "Branches", + "sectionChanges": "Changes", + "sectionCommits": "Commits", + "sectionRemotes": "Remotes", + "sectionStashes": "Stashes", + "sectionStatus": "Status", + "sectionWorktrees": "Worktrees", + "selectFileToViewDiff": "Select a file to view its diff", + "selectIssueAriaLabel": "Select issue #{{number}}", + "selectPullAriaLabel": "Select pull request #{{number}}", + "selectRemoteAriaLabel": "Select Git remote", + "selectRemotePlaceholder": "Select remote…", + "selectRemoteToViewDetails": "Select a remote to view details", + "selectedRemote": "selected remote", + "sidebarAriaLabel": "Git Manager Sections", + "stageAll": "Stage All", + "stageAllAndCommit": "Stage All & Commit", + "stageAllAndCommitTitle": "Stage all and commit", + "stageCount_one": "Stage ({{count}})", + "stageCount_other": "Stage ({{count}})", + "stageFile": "Stage file", + "stageSelected": "Stage selected", + "staged": "Staged", + "stagedChanges_one": "Staged Changes ({{count}})", + "stagedChanges_other": "Staged Changes ({{count}})", + "stagedCount_one": "{{count}} staged", + "stagedCount_other": "{{count}} staged", + "stagedFiles_one": "Staged {{count}} file(s)", + "stagedFiles_other": "Staged {{count}} file(s)", + "staleIndexWarning": "Stale index detected. HEAD has advanced (typically because Fusion's merger updated the integration-branch ref) but the index still reflects the previous tip — `git status` will report the new commits inverted as \"staged changes.\" Enable mergeAdvanceAutoSync in Settings to have the merger reconcile automatically, or run git reset --hard HEAD to snap forward manually.", + "stash": "Stash", + "stashApplied": "Stash applied", + "stashConflict": { + "copied": "Stash SHA copied.", + "copyFailed": "Could not copy stash SHA.", + "dropStash": "Drop stash", + "failedWarning": "Automatic restore failed. Your changes are preserved in the stash above; use git stash apply to recover manually, or use Retry below.", + "keepIncoming": "Keep incoming", + "keepMine": "Keep mine", + "openInEditor": "Open in editor", + "retryRestore": "Retry restore", + "summary": "Pulled {{branch}}, but restoring local edits from stash produced conflicts.", + "title": "Resolve auto-stash conflicts" + }, + "stashDropped": "Stash dropped", + "stashMessagePlaceholder": "Stash message (optional)", + "stashPopped": "Stash popped", + "statusLabelBranch": "Branch", + "statusLabelCommit": "Commit", + "statusLabelIntegrationBranch": "Integration branch", + "statusLabelStashes": "Stashes", + "statusLabelVsOrigin": "vs origin", + "statusLabelWorkingTree": "Working Tree", + "switchedToBranch": "Switched to {{name}}", + "sync": "Sync", + "syncFailed": "Sync failed", + "syncLocalTip": "Sync local tip", + "syncLocalTipTitle": "Sync working tree to local integration tip (same as banner Pull)", + "syncOriginTitle": "Pull --rebase from origin, then push current branch", + "syncWithOriginFailed": "Sync with origin failed", + "syncWorkingTree": "Sync working tree", + "syncWorkingTreeTitle": "Pull the integration branch into your working tree (auto-stashes uncommitted edits and restores them)", + "synced": "Synced", + "syncedWithOrigin": "Synced with origin (pull --rebase + push)", + "syncedWorktreeToIntegrationTip": "Synced worktree to local integration tip", + "syncing": "Syncing…", + "tabIssues": "Issues", + "tabPullRequests": "Pull Requests", + "tip": "tip", + "toolbarAriaLabel": "GitHub import controls", + "tryDifferentFilter": "Try a different label filter or choose another repository.", + "unlinkButton": "Unlink", + "unresolvedMergeConflicts": "Unresolved merge conflicts", + "unstageAll": "Unstage All", + "unstageCount_one": "Unstage ({{count}})", + "unstageCount_other": "Unstage ({{count}})", + "unstageFile": "Unstage file", + "unstageSelected": "Unstage selected", + "unstaged": "Unstaged", + "unstagedChanges_one": "Unstaged Changes ({{count}})", + "unstagedChanges_other": "Unstaged Changes ({{count}})", + "unstagedFiles_one": "Unstaged {{count}} file(s)", + "unstagedFiles_other": "Unstaged {{count}} file(s)", + "untracked": "Untracked", + "untrackedCount_one": "{{count}} untracked", + "untrackedCount_other": "{{count}} untracked", + "upToDate": "Up to date", + "view": "View", + "viewOnGithub": "View on GitHub", + "waitingFor": "Waiting for: {{reasons}}", + "whatDoesThisMean": "What does this mean?", + "workingTreeClean": "Clean", + "workingTreeModified": "Modified", + "worktreeBadgeBare": "bare", + "worktreeBadgeMain": "main", + "worktreesInUse_one": "{{count}} in use", + "worktreesInUse_other": "{{count}} in use", + "worktreesTotal_one": "{{count}} total", + "worktreesTotal_other": "{{count}} total" + }, + "goals": { + "activeCount_one": "{{count}} active goals", + "activeCount_other": "{{count}} active goals", + "addGoal": "Add Goal", + "archive": "Archive", + "capError": "Cannot activate more than 5 goals. Resolve an active goal before activating another.", + "capWarning": "Approaching the 5-active goal cap. Keep active goals focused.", + "createError": "Unable to create goal right now. Please try again.", + "draftWithAi": "Draft with AI", + "drafting": "Drafting…", + "emptyState": "No goals yet. Add one to begin tracking strategic outcomes.", + "labelDescription": "Description", + "labelTitle": "Title", + "loadError": "Unable to load goals right now. Please try again.", + "loading": "Loading goals…", + "saveError": "Unable to save goal right now. Please try again.", + "status": "Status", + "title": "Goals", + "titleRequired": "Title is required.", + "unarchive": "Unarchive", + "updateError": "Unable to update goal status right now. Please try again." + }, + "groupTask": { + "abandonGroup": "Abandon group", + "ariaLabel": "Branch group details", + "autoMergeEnabled": "Auto-merge enabled", + "completionText": "{{landed}} of {{total}} members finished", + "errorLoading": "Failed to load branch group", + "loading": "Loading branch group…", + "members": "Members", + "mergeIntoMain": "Merge group into main", + "openPR": "Open PR", + "openTask": "Open task", + "prClosed": "Group PR closed", + "prMerged": "Group PR merged", + "sharedBranch": "Shared branch", + "status": "Status", + "title": "Branch Group {{id}}", + "unavailable": "Branch group unavailable" + }, + "header": { + "activePlanningSessions_one": "{{count}} active planning session", + "activePlanningSessions_other": "{{count}} active planning sessions", + "addFirstScript": "Add your first script", + "additionalHeaderActions": "Additional header actions", + "agentsView": "Agents view", + "allBaseBranches": "All base branches", + "allWorkingBranches": "All working branches", + "automation": "Automation", + "baseBranch": "Base branch", + "boardView": "Board view", + "browseFiles": "Browse Files", + "chatView": "Chat view", + "closeSearch": "Close search", + "createTaskWithPlanning": "Create a task with AI planning", + "devServerView": "Dev Server", + "documentsView": "Documents view", + "engineOptions": "Engine options", + "evalsView": "Evals", + "gitManager": "Git Manager", + "goalsView": "Goals", + "importFromGitHub": "Import from GitHub", + "insightsView": "Insights", + "listView": "List view", + "loadingScripts": "Loading scripts...", + "localNode": "Local", + "mailbox": "Mailbox", + "mailboxView": "Mailbox view", + "mailboxWithCount_one": "Mailbox ({{count}})", + "mailboxWithCount_other": "Mailbox ({{count}})", + "manageProjects": "Manage Projects", + "manageScripts": "Manage Scripts...", + "memoryView": "Memory", + "missionsView": "Missions view", + "moreActions": "More actions", + "moreHeaderActions": "More header actions", + "moreViews": "More views", + "noBaseBranch": "No base branch", + "noScriptsAddOne": "No scripts — add one…", + "noScriptsConfigured": "No scripts configured", + "noWorkingBranch": "No working branch", + "nodes": "Nodes", + "openSearch": "Open search", + "openTerminal": "Open Terminal", + "pauseTriage": "Pause triage", + "pendingApprovals": "Pending approvals", + "projects": "Projects", + "quickScripts": "Quick scripts", + "reliabilityView": "Reliability", + "researchView": "Research", + "resumePlanningSession": "Resume planning session", + "resumePlanningSessionCount_one": "Resume planning session ({{count}})", + "resumePlanningSessionCount_other": "Resume planning session ({{count}})", + "resumeScheduling": "Resume scheduling", + "scripts": "Scripts", + "scriptsSubmenu": "Scripts submenu", + "searchTasks": "Search tasks...", + "secretsView": "Secrets", + "selectNode": "Select node", + "selectProject": "Select project", + "settings": "Settings", + "showScripts": "Show scripts", + "skillsView": "Skills", + "startAiEngine": "Start AI engine", + "stashRecoveryView": "Stash Recovery", + "stopAiEngine": "Stop AI engine", + "switchNode": "Switch node", + "switchProject": "Switch project", + "switchProjectCurrent": "Switch project (current: {{name}})", + "systemStats": "System Stats", + "terminal": "Terminal", + "todosView": "Todos", + "unreadChatResponse": "Unread chat response", + "unreadMessages_one": "{{count}} unread messages", + "unreadMessages_other": "{{count}} unread messages", + "viewActivityLog": "View Activity Log", + "viewProjects": "View Projects", + "viewUsage": "View usage", + "workflowSteps": "Workflow Steps", + "workingBranch": "Working branch" + }, + "health": { + "activeTasks": "Active Tasks", + "anomalyBody": "Fusion found allocator state that can cause task IDs to be reused or overwrite live task records.", + "anomalyDetected": "Task ID integrity anomaly detected", + "completed": "Completed", + "failed": "Failed", + "inFlightAgents": "In-Flight Agents", + "lastError": "Last Error", + "metricsTitle": "Health Metrics", + "recheck": "Re-check", + "rechecking": "Re-checking…", + "refreshFailed": "Failed to refresh integrity status." + }, + "hermes": { + "autoProfile": "Auto / use Hermes default", + "binaryPathHelp": "Leave blank to resolve {{cmd}} from your PATH.", + "binaryPathLabel": "Binary path", + "binaryPathPlaceholder": "hermes (defaults to PATH)", + "defaultProfile": " (default)", + "description": "Drives the local {{cmd}} CLI as a subprocess. Each Fusion prompt is sent as {{chatCmd}}; subsequent prompts resume the same hermes session via {{resumeFlag}}. Provider, model, and skills are configured inside hermes itself; this card only chooses overrides.", + "detected": "✓ hermes detected{{version}}{{path}}.", + "gitHubLink": "Hermes on GitHub", + "maxTurnsHelp": "Cap per Hermes turn. Hermes's own default is 90; we cap lower.", + "maxTurnsLabel": "Max turns", + "modelControlled": "Controlled by profile: {{profile}}", + "modelHelp": "Optional — overrides Hermes's configured default model.", + "modelLabel": "Model override", + "modelPlaceholder": "e.g. claude-sonnet-4-5, MiniMax-M3", + "notDetected": "{{cmd}} not detected. Install the upstream agent:", + "notFound": "✗ {{reason}}", + "probing": "Probing local hermes binary…", + "profileHelp": "Select a Hermes profile to use. Activates the profile by setting {{env}} to the profile directory when invoking {{cmd}}.", + "profileLabel": "Profile (optional)", + "profileModelSeparator": " — {{model}}", + "providerControlled": "Controlled by profile: {{profile}}", + "providerHelp": "Inference provider Hermes routes calls through (default: {{default}}).", + "providerLabel": "Provider", + "savedDetected": "Saved · ✓ hermes detected{{version}}.", + "savedNotFound": "Saved · ✗ {{reason}}", + "savedProbeFailed": "Saved, but probe failed.", + "settingsSaved": "Settings saved.", + "statusDetected": "✓ Detected{{version}}{{path}}", + "statusNotDetected": "✗ {{reason}}", + "subname": "by Nous Research", + "testFailed": "Test failed — see status above.", + "timeoutHelp": "Fusion-side hard cap. Default 5 min.", + "timeoutLabel": "CLI hard-kill timeout (ms)", + "yoloHelp": "Required for non-interactive sessions that trigger shell-style tools.", + "yoloLabel": "Auto-approve dangerous tool calls ({{flag}})" + }, + "inline": { + "agent": "Agent", + "breakDownSubtasks": "Break down into AI-generated subtasks", + "browserVerify": "Browser Verify", + "browserVerifyChecked": "Browser Verify ✓", + "clearSelection": "Clear selection", + "collapse": "Collapse", + "collapseDescription": "Collapse description", + "collapseTaskOptions": "Collapse advanced task options", + "creating": "Creating...", + "custom": "Custom", + "deps": "Deps", + "editingDescription": "Editing Description", + "enableBrowserVerification": "Enable browser verification workflow step", + "enterDescriptionFirst": "Enter a description first", + "expand": "Expand", + "expandDescription": "Expand description", + "expandTaskOptions": "Expand advanced task options", + "hintEnterEsc": "Enter to create · Esc to cancel", + "loadingAgents": "Loading agents...", + "model_one": "model", + "model_other": "model", + "models": "Models", + "noAgentsAvailable": "No agents available", + "noExistingTasks": "No existing tasks", + "node": "Node", + "openPlanningMode": "Open planning mode with current description", + "plan": "Plan", + "preset": "Preset", + "priority": "Priority", + "priorityLabel": "Priority: {{level}}", + "projectDefaultLocal": "Project default / local", + "removeImage": "Remove image", + "save": "Save", + "searchTasks": "Search tasks…", + "selectAgent": "Select agent", + "selectExecutionNode": "Select execution node", + "subtask": "Subtask", + "useDefault": "Use default", + "whatNeedsToBeDone": "What needs to be done?" + }, + "insights": { + "allInsights": "All Insights", + "alreadyRunning": "Insight generation is already running. Showing the active run.", + "alreadyRunningShort": "Insight generation is already running", + "archiveLabel": "Archive this insight", + "archiveTitle": "Archive this insight", + "archived": "Archived \"{{title}}\"", + "archivedMsg": "Insight archived: {{title}}", + "archiving": "Archiving \"{{title}}\"...", + "backlogHealth": "Backlog Health", + "configureModel": "Configure insight generation model", + "configureModelTitle": "Configure model", + "createTaskLabel": "Create task from this insight", + "createTaskTitle": "Create task from this insight", + "creatingTask": "Creating task from \"{{title}}\"...", + "dismissLabel": "Dismiss this insight", + "dismissTitle": "Dismiss this insight", + "dismissed": "Dismissed \"{{title}}\"", + "dismissedMsg": "Insight dismissed: {{title}}", + "dismissing": "Dismissing \"{{title}}\"...", + "failedToArchive": "Failed to archive insight", + "failedToCreateTask": "Failed to create task", + "failedToDismiss": "Failed to dismiss insight", + "failedToPreparePayload": "Failed to prepare task payload from insight", + "failedToStart": "Failed to start generation", + "failedToUnarchive": "Failed to unarchive insight", + "generateDescription": "Generate insights to get AI-powered recommendations for your project.", + "generateFirst": "Generate First Insights", + "generateInsights": "Generate new insights", + "generateInsightsBtn": "Generate Insights", + "generating": "Generating...", + "generatingInsights": "Generating insights...", + "generationModel": "Insight generation model", + "generationStarted": "Insight generation started", + "hideArchived": "Hide archived insights", + "hideArchivedLabel": "Hide Archived", + "latestRun": "Latest run:", + "loading": "Loading insights...", + "model": "Model", + "modelConfigured": "Model: {{model}}", + "noInsightsYet": "No insights yet", + "runCompleted": "{{created}} created, {{updated}} updated", + "showAllInsights": "Show all insights", + "showArchived": "Show archived insights", + "showArchivedLabel_one": "Show Archived ({{count}})", + "showArchivedLabel_other": "Show Archived ({{count}})", + "showBacklogHealth": "Show only backlog health insights", + "taskCreated": "Task created from \"{{title}}\"", + "taskCreatedMsg": "Task created: {{title}}", + "taskCreationUnavailable": "Task creation is unavailable in this view", + "title": "Insights", + "unarchiveLabel": "Unarchive this insight", + "unarchiveTitle": "Unarchive this insight", + "unarchived": "Unarchived \"{{title}}\"", + "unarchivedMsg": "Insight unarchived: {{title}}", + "unarchiving": "Unarchiving \"{{title}}\"...", + "usePlanningDefault": "Use planning default" + }, + "interview": { + "addContextDirection": "Add any extra context or direction...", + "additionalComments": "Additional comments (optional)", + "aiThinking": "AI is thinking...", + "appliedMessage": "The {{targetType}}'s scope and verification have been applied.", + "description": "Description", + "error": { + "failedToApply": "Failed to apply interview results", + "failedToRestoreQuestion": "Failed to restore session question.", + "failedToRestoreResult": "Failed to restore session result.", + "failedToResume": "Failed to resume session.", + "failedToSkip": "Failed to skip {{targetLabel}} interview", + "failedToStart": "Failed to start {{targetLabel}} interview", + "failedToSubmit": "Failed to submit response", + "sessionEncounteredError": "The session encountered an error.", + "sessionFailed": "Session failed while contacting the AI." + }, + "hideThinking": "Hide thinking", + "interviewDescription": "The AI will interview you to refine the {{targetType}}'s scope, acceptance criteria, and verification methods. Each {{targetType}} can have its own refined plan or inherit context from the mission level.", + "missionContext": "Mission context:", + "no": "No", + "noAdditionalDetails": "No additional details were generated for this item.", + "planningNotes": "Planning Notes", + "preparingQuestion": "Preparing next question...", + "progressText": "Question {{progress}} of ~6", + "reconnecting": "Reconnecting…", + "refineScope": "Refine {{label}} scope with AI", + "refinedScope": "Refined Scope", + "sendToBackground": "Send to background", + "sessionActiveAnotherTab": "Session is active in another tab.", + "showThinking": "Show thinking", + "startInterview": "Start Interview", + "targetLabel": { + "milestone": "Milestone", + "slice": "Slice" + }, + "typeAnswerHere": "Type your answer here...", + "updated": "{{label}} Updated", + "useMissionContext": "Use Mission Context", + "verificationCriteria": "Verification Criteria", + "yes": "Yes" + }, + "lane": { + "collapse": "Collapse {{name}} lane", + "expand": "Expand {{name}} lane" + }, + "listView": { + "apply": "Apply", + "applying": "Applying...", + "archiveSelected": "Archive selected", + "archiveSelectedTitle": "Archive selected tasks that are in Done", + "archiveUnavailable": "Archive action is unavailable", + "archiveViaButton": "Tasks can only be archived via the archive button", + "bulkArchiveDone_one": "Archive {{count}} Done", + "bulkArchiveDone_other": "Archive {{count}} Done", + "bulkArchiveMessage_one": "Archive {{count}} selected task(s)?", + "bulkArchiveMessage_other": "Archive {{count}} selected task(s)?", + "bulkArchiveNoTasks": "No selected tasks can be archived (only done tasks)", + "bulkArchiveSummary": "Archived {{archived}} · {{skipped}} skipped · {{failed}} failed", + "bulkArchiveTitle": "Archive Selected Tasks", + "bulkDeleteAll": "Delete All", + "bulkDeleteArchiveSummary": "Archived {{archived}}, deleted {{deleted}}, failed {{failed}}", + "bulkDeleteMessage_one": "Delete {{count}} selected task(s)?", + "bulkDeleteMessage_other": "Delete {{count}} selected task(s)?", + "bulkDeleteNoTasks": "No selected tasks can be deleted (archived tasks are excluded)", + "bulkDeleteSummary_one": "Deleted {{count}} task · {{skipped}} archived skipped · {{failed}} failed", + "bulkDeleteSummary_other": "Deleted {{count}} tasks · {{skipped}} archived skipped · {{failed}} failed", + "bulkDeleteTitle": "Delete Selected Tasks", + "bulkDeleteWithDoneMessage": "Delete {{deletable}} task(s), or archive the {{done}} done task(s) and delete the rest?", + "bulkEdit": "Bulk Edit", + "bulkEditModelsLabel": "Bulk Edit Models & Node:", + "bulkNoChanges": "No changes to apply", + "bulkPauseNoTasks": "No selected tasks can be paused", + "bulkPauseSummary": "Paused {{paused}} · {{skipped}} skipped · {{failed}} failed", + "bulkUnpauseNoTasks": "No selected tasks can be unpaused", + "bulkUnpauseSummary": "Unpaused {{unpaused}} · {{skipped}} skipped · {{failed}} failed", + "bulkUpdateFailed": "Failed to update models", + "bulkUpdateNoTasks": "No valid tasks to update (archived tasks cannot be modified)", + "bulkUpdateSuccess_one": "Updated {{count}} task(s)", + "bulkUpdateSuccess_other": "Updated {{count}} task(s)", + "cancelMove": "Cancel Move", + "clear": "Clear", + "clearColumnFilter": "Clear column filter", + "colColumn": "Column", + "colDependencies": "Dependencies", + "colProgress": "Progress", + "colRetries": "Retries", + "colStatus": "Status", + "colTitle": "Title", + "deleteSelected": "Delete selected", + "deleteSelectedTitle": "Delete selected tasks", + "dependentsDeleteMessage": "Task {{taskId}} has dependents: {{dependents}}. Remove dependency references and force delete?", + "doneEditing": "Done Editing", + "doneHiddenChip": "Done hidden", + "executorModel": "Executor Model", + "fastMode": "Fast mode", + "filterChip": "Filter: {{column}}", + "forceDelete": "Force Delete", + "forceDeleteTitle": "Force Delete Task", + "hidden_one": "{{count}} hidden", + "hidden_other": "{{count}} hidden", + "hideDone": "Hide Done", + "hideDoneTitle": "Hide done tasks", + "keepProgress": "Keep Progress", + "lastColumnWarning": "At least one column must be visible", + "lineageArchiveMessage": "{{taskId}} has lineage children ({{children}}) that reference it as a source parent.\n\nArchive anyway by unlinking these references first?", + "lineageDeleteMessage": "{{taskId}} has lineage children ({{children}}) that reference it as a source parent.\n\nDelete anyway by unlinking these references first?", + "listControlsLabel": "List controls", + "newTask": "+ New Task", + "noChange": "No change", + "noTasks": "No tasks", + "noTasksMatch": "No tasks match your filter", + "noTasksYet": "No tasks yet", + "nodeOverrideLabel": "Node Override", + "nodeStatusConnecting": "Connecting", + "nodeStatusError": "Error", + "nodeStatusOffline": "Offline", + "nodeStatusOnline": "Online", + "pauseSelected": "Pause selected", + "pauseSelectedTitle": "Pause all selected tasks that are not already paused", + "pauseUnavailable": "Pause action is unavailable", + "pausedByAgent": "paused by agent", + "preserveProgressMessage": "This task has completed steps. Keep progress before moving?", + "preserveProgressTitle": "Preserve Progress?", + "resetProgress": "Reset Progress", + "resetProgressMessage": "Reset all step progress before moving this task?", + "resetProgressTitle": "Reset Progress?", + "resizeSidebar": "Resize task list sidebar", + "reviewerModel": "Reviewer Model", + "selectAll": "Select all visible tasks", + "selectTask": "Select {{taskId}}", + "selectTaskPrompt": "Select a task to view details", + "selectedCount_one": "{{count}} selected", + "selectedCount_other": "{{count}} selected", + "showAll": "Show all", + "showAllTitle": "Show all tasks", + "showDone": "Show Done", + "showDoneTitle": "Show done tasks", + "staleOnly": "Stale only", + "staleOnlyTitle": "Show stale tasks only", + "stalePausedReview": "Stale paused review", + "stalePausedReviewTitle": "Show stale paused review tasks only", + "statsInColumn_one": "{{count}} of {{total}} tasks in {{column}}", + "statsInColumn_other": "{{count}} of {{total}} tasks in {{column}}", + "stats_one": "{{count}} of {{total}} tasks", + "stats_other": "{{count}} of {{total}} tasks", + "statusMergingFix": "Merging fixes…", + "stuck": "Stuck", + "taskCreationUnavailable": "Task creation not available", + "unpauseSelected": "Unpause selected", + "unpauseSelectedTitle": "Unpause selected tasks that are currently paused", + "unpauseUnavailable": "Unpause action is unavailable", + "useProjectDefault": "Use project default", + "viewOptions": "View options" + }, + "mailbox": { + "agent": "Agent", + "agents": "Agents", + "agentsTab": "Agents", + "ago": "ago", + "allAgents": "All agents", + "allAgentsOption": "All agents", + "approvalApprove": "Approve", + "approvalCommentPlaceholder": "Optional comment", + "approvalDeny": "Deny", + "approvalRequested": "Requested", + "approvalRequester": "Requester", + "approvalTask": "Task", + "approvals": "Approvals", + "back": "Back", + "backButton": "← Back", + "closeAriaLabel": "Close", + "closeTitle": "Close", + "compose": "Compose", + "composeButton": "Compose", + "composeMessageTitle": "Compose message", + "composeTitle": "Compose message", + "conversation": "Conversation", + "conversationLabel": "Conversation", + "delete": "Delete", + "deleteButton": "Delete", + "deleteFailed": "Failed to delete message", + "from": "From", + "fromLabel": "From:", + "fromPrefix": "From: {{participant}}", + "history": "History", + "inbox": "Inbox", + "inboxTab": "Inbox", + "justNow": "Just now", + "labelAgent": "Agent: {{id}}", + "labelAgentNamed": "Agent: {{name}}", + "labelSystem": "System", + "labelUser": "User: {{id}}", + "labelYou": "You", + "markAllRead": "Mark all read", + "markAllReadButton": "Mark all read", + "markAllReadTitle": "Mark all as read", + "markReadFailed": "Failed to mark messages as read", + "markedAsRead_one": "Marked {{count}} messages as read", + "markedAsRead_other": "Marked {{count}} messages as read", + "messageDeleted": "Message deleted", + "messageSent": "Message sent", + "noAgentMessages": "No agent-to-agent messages", + "noAgents": "No agents found", + "noAgentsFound": "No agents found", + "noHistoricalApprovals": "No historical approvals", + "noInbox": "No messages in your inbox", + "noMessagesInbox": "No messages in your inbox", + "noOutbox": "No sent messages", + "noPendingApprovals": "No pending approvals", + "noReceivedMessages": "No received messages for this agent", + "noSentMessages": "No sent messages", + "noSentMessagesAgent": "No sent messages for this agent", + "outbox": "Outbox", + "outboxTab": "Outbox", + "pending": "Pending", + "refreshTitle": "Refresh", + "reply": "Reply", + "replyButton": "Reply", + "replyLoadFailed": "Failed to load replied message. Click to retry.", + "replyingTo": "Replying to", + "replyingToMessage": "Replying to message", + "selectMessageToRead": "Select a message to read", + "system": "System", + "timeDaysAgo_one": "{{count}}d ago", + "timeDaysAgo_other": "{{count}}d ago", + "timeHoursAgo_one": "{{count}}h ago", + "timeHoursAgo_other": "{{count}}h ago", + "timeJustNow": "Just now", + "timeMinsAgo_one": "{{count}}m ago", + "timeMinsAgo_other": "{{count}}m ago", + "title": "Mailbox", + "to": "To", + "toLabel": "To:", + "toPrefix": "To: {{recipient}}", + "typeAgentToAgent": "Agent ↔ Agent", + "typeAgentToUser": "Agent → You", + "typeSystem": "System", + "typeUserToAgent": "You → Agent", + "user": "User", + "you": "You" + }, + "memory": { + "auditChecksTitle": "Audit Checks", + "autoSummarizeHint": "Automatically compact memory when it exceeds the threshold on a schedule", + "autoSummarizeLabel": "Auto-Summarize Memory", + "autoSummarizeScheduleHint": "Cron expression for auto-summarize schedule (default: daily at 3 AM)", + "autoSummarizeScheduleLabel": "Schedule (cron)", + "backendFile": "File (.fusion/memory/, agent//memory/)", + "backendQmd": "QMD (Quantized Memory Distillation)", + "backendReadonly": "Read-Only", + "cancel": "Cancel", + "capAtomicWrites": "Atomic Writes", + "capPersistent": "Persistent", + "capReadable": "Readable", + "capWritable": "Writable", + "categories": "Categories", + "charCount_one": "{{count}} characters", + "charCount_other": "{{count}} characters", + "compactFailed": "Failed to compact memory", + "compactSelectedFile": "Compact Selected File", + "compacting": "Compacting…", + "compactionThresholdHint": "Memory will be compacted when it exceeds this character count", + "compactionThresholdLabel": "Compaction Threshold (chars)", + "currentBackendTitle": "Current Backend", + "description": "Working memory, long-term insights, and engine status", + "disabledMessage": "Memory is currently disabled. Enable memory tools in Settings to edit these automations.", + "dreamNow": "Dream Now", + "dreamNowHint": "Manually trigger dream processing now.", + "dreamProcessingComplete": "Dream processing completed", + "dreamProcessingFailed": "Failed to run dream processing", + "dreaming": "Dreaming…", + "dreamsEnabledHint": "Turns daily notes into DREAMS.md and promotes reusable lessons into MEMORY.md.", + "dreamsEnabledLabel": "Process dreams from daily memory", + "dreamsScheduleHint": "Cron expression for dream processing.", + "dreamsScheduleLabel": "Dream Schedule", + "editRaw": "Edit Raw", + "editorDefaultDescription": "Edits the selected memory file.", + "editorLabel": "Memory Editor", + "extractInsightsFailed": "Failed to extract insights", + "extractNow": "Extract Now", + "extracting": "Extracting…", + "extractionFailed": "Failed", + "extractionSuccess": "Success", + "fileCompacted": "Memory file compacted", + "fileLabel": "Memory File", + "fileSummary": "{{size}} bytes · updated {{updatedAt}}", + "fileSwitchDirtyHint": "Save or discard the current edits before switching files.", + "fileSwitchHint": "Choose any project memory file to view or edit.", + "healthHealthy": "Healthy", + "healthIssues": "Issues Found", + "healthStatusTitle": "Health Status", + "healthWarning": "Warning", + "insightCount_one": "{{count}} insights", + "insightCount_other": "{{count}} insights", + "insightsExtracted_one": "{{count}} insights extracted", + "insightsExtracted_other": "{{count}} insights extracted", + "insightsMemoryLabel": "Insights Memory", + "insightsSaved": "Insights saved", + "installQmd": "Install qmd", + "installQmdFailed": "Failed to install qmd", + "installing": "Installing…", + "lastExtractionLabel": "Last Extraction", + "lastUpdated": "Last Updated", + "layerDaily": "Daily", + "layerDescDaily": "Raw daily observations, open loops, and running context for dream processing.", + "layerDescDreams": "Synthesized patterns and open loops promoted from daily memory.", + "layerDescLongTerm": "Curated durable decisions, conventions, constraints, and pitfalls promoted from dreams.", + "layerDreams": "Dreams", + "layerLongTerm": "Long-term", + "loadFileFailed": "Failed to load memory file", + "loadingEngineStatus": "Loading engine status…", + "loadingFile": "Loading memory file…", + "loadingInsights": "Loading insights…", + "localFallbackUsed": "local fallback used", + "memorySaved": "Memory saved", + "noInsights": "No insights extracted yet.", + "noInsightsHint": "Insights are automatically extracted from working memory. Click \"Extract Now\" to trigger extraction manually.", + "noMatchingMemory": "No matching memory found.", + "pruningApplied": "Applied", + "pruningLabel": "Pruning", + "pruningNotNeeded": "Not needed", + "qmdAvailableOnPath": "qmd is available on PATH.", + "qmdChecking": "Checking", + "qmdCheckingAvailability": "Checking qmd availability…", + "qmdInstallSuccess": "qmd installed successfully", + "qmdInstallUnavailable": "qmd install finished, but qmd is still unavailable", + "qmdInstalled": "Installed", + "qmdIntegrationTitle": "QMD Integration", + "qmdNotInstalled": "qmd is not installed. Search will use local files. Install indexed retrieval:", + "qmdPathUsed": "qmd path used", + "qmdStatusAvailable": "available", + "qmdStatusMissing": "missing", + "readOnlyBanner": "This memory backend is read-only. Changes cannot be saved.", + "retrievalTestComplete": "Memory retrieval test complete", + "retrievalTestFailed": "Failed to test memory retrieval", + "retrievalTestFallback": "qmd is not installed; local fallback was used", + "runAudit": "Run Audit", + "save": "Save", + "saveInsights": "Save Insights", + "saveInsightsFailed": "Failed to save insights", + "saveMemoryFailed": "Failed to save memory", + "saveSettings": "Save Settings", + "saveSettingsFailed": "Failed to save memory settings", + "saving": "Saving…", + "searchPlaceholder": "Search memory with qmd", + "sectionCount_one": "{{count}} sections", + "sectionCount_other": "{{count}} sections", + "settingsNote": "Note: Change backend type in", + "settingsNoteLink": "Settings → Memory", + "settingsNoteToast": "Open Settings → Memory to change backend type", + "settingsSaved": "Memory settings saved", + "sizeChars": "{{size}} chars", + "tabEngines": "Engines", + "tabInsights": "Insights", + "tabWorking": "Working Memory", + "testMemorySearchTitle": "Test Memory Search", + "testResultCount_one": "{{count}} result for \"{{query}}\"", + "testResultCount_other": "{{count}} results for \"{{query}}\"", + "testResultStatus": "qmd {{qmdStatus}} · {{fallbackStatus}}", + "testRetrieval": "Test Retrieval", + "testSearchHint": "Runs the same qmd-backed memory_search path agents use.", + "testing": "Testing…", + "title": "Memory", + "totalInsights": "Total Insights", + "workingMemoryLabel": "Working Memory" + }, + "merge": { + "advanced": "Advanced", + "advancedTo": "{{branch}} advanced to {{sha}}.", + "changesWillAutoStash": " (local changes will be auto-stashed and restored)", + "checkedOutBehind": "Your checked-out copy at {{path}} is behind.", + "commit": "Commit", + "disabledMergeLocked": "Push paused — a Fusion merge is in progress.", + "disabledNoRemote": "No `origin` remote configured.", + "disabledNoUpstream": "Branch has no upstream on origin.", + "dismissNotice": "Dismiss merge advance notice", + "filesChanged": "Files in merge commit", + "forceWithLeaseLabel": "Allow force-with-lease (use only when you know origin diverged intentionally)", + "insertionsDeletions": "Merge-commit insertions / deletions", + "mergedAt": "Merged at", + "mergedSuccess": "Merged successfully", + "message": "Message", + "pr": "PR", + "pulling": "Pulling…", + "pushForceWithLease": "Push (force-with-lease)", + "pushHeading_one": "Push {{branch}} to origin — ahead by {{count}} commit{{plural}}.", + "pushHeading_other": "Push {{branch}} to origin — ahead by {{count}} commit{{plural}}.", + "pushSuccess": "Pushed to origin/{{branch}} @ {{sha}}.", + "pushToOrigin": "Push to origin", + "pushing": "Pushing…", + "recordedNoConfirm": "Recorded without local merge confirmation", + "shortstatTitle": "Final commit shortstat; for the full landed diff across all task commits, see the Changes tab.", + "smartPull": "Smart Pull", + "status": "Status", + "title": "Merge Details" + }, + "mesh": { + "ariaLabel": "Node mesh topology visualization", + "connecting": "Connecting", + "error": "Error", + "failedToFetchMeshState": "Failed to fetch mesh state", + "noNodes": "No nodes to display", + "noPeers": "Peer-to-peer discovery data unavailable.", + "offline": "Offline", + "online": "Online" + }, + "missions": { + "acceptance": "Acceptance:", + "acceptanceCriteriaOptional": "Acceptance criteria (optional)", + "actionGenerating": "Generating…", + "actionResume": "Resume", + "actionRetry": "Retry", + "actionReview": "Review", + "activateSlice": "Activate slice", + "activityTime": "Activity {{time}}", + "actual": "Actual: {{value}}", + "addAssertion": "Add assertion", + "addContext": "Add any extra context or direction...", + "addFeature": "Add feature", + "addMilestone": "Add Milestone", + "addSlice": "Add slice", + "additionalComments": "Additional comments (optional)", + "aiThinking": "AI is thinking...", + "aiValidatedAtRuntime": "AI-validated at runtime", + "aiValidatedMissionGate": "AI-validated mission gate", + "allFeaturesLinked": "All features already linked", + "approvePlan": "Approve Plan", + "assertionCreateFailed": "Failed to create assertion", + "assertionCreated": "Assertion created", + "assertionFieldsRequired": "Title and assertion text are required", + "assertionTextEditPlaceholder": "Assertion text", + "assertionTextPlaceholder": "Assertion text (what should be true when complete)", + "assertionTitlePlaceholder": "Assertion title", + "assertionUpdateFailed": "Failed to update assertion", + "assertionUpdated": "Assertion updated", + "attemptRetries_one": "Attempt {{attempt}} · {{count}} {{label}} left", + "attemptRetries_other": "Attempt {{attempt}} · {{count}} {{label}} left", + "autopilotActivatingSlice": "Activating slice", + "autopilotCompleting": "Completing", + "autopilotDescription": "When on, Fusion automatically activates the next slice and plans its features as work completes.", + "autopilotDisabled": "Autopilot disabled", + "autopilotEnabled": "Autopilot enabled", + "autopilotEnabledLabel": "Autopilot enabled", + "autopilotLabel": "Autopilot", + "autopilotLastActivation": "Last activation {{time}}", + "autopilotOff": "Off", + "autopilotUpdateFailed": "Failed to update autopilot", + "autopilotWatching": "Autopilot watching", + "autopilotWatchingSince": "Watching since {{time}}", + "autopilotWatchingState": "Watching", + "backToMissions": "Back to missions", + "backToMissionsList": "Back to missions list", + "blocked": "Blocked:", + "blockedReason": "Blocked reason:", + "branchName": "Branch name", + "branchNameAriaLabel": "Mission branch name", + "branchNamePlaceholder": "e.g. feature/mission-work", + "branchNameRequired": "Branch name is required for selected branch strategy", + "branchStrategy": "Branch strategy", + "branchStrategyAriaLabel": "Mission branch strategy", + "branchStrategyAutoPerTask": "Auto-name a branch per task (from details)", + "branchStrategyCustomNew": "Create custom branch", + "branchStrategyExisting": "Use existing branch", + "branchStrategyProjectDefault": "Use project/default branch", + "buildExample": "e.g., Build a real-time collaborative document editor with presence, comments, and version history...", + "cancelButton": "Cancel", + "clickToViewTask": "Click to view task", + "close": "Close", + "closeMissionManager": "Close Mission Manager", + "collapseDetails": "Collapse details", + "completedFeatures": "{{completed}}/{{total}} features", + "completedMilestones": "{{completed}}/{{total}} milestones", + "completedTasks": "{{completed}}/{{total}} tasks", + "confirmMilestonePlaceholder": "How to confirm this milestone is complete...", + "confirmSlicePlaceholder": "How to confirm this slice is done...", + "contractAssertions": "Contract assertions (AI-validated)", + "createButton": "Create", + "createTask": "Create Task", + "created": "Mission created", + "createdFromInterview": "Mission created from AI interview", + "creatingMission": "Creating Mission...", + "defaultInterviewTitle": "Mission interview", + "deleteAssertion": "Delete assertion", + "deleteButton": "Delete", + "deleteConfirm": "Delete this {{type}}? This cannot be undone.", + "deleteFailed": "Failed to delete mission", + "deleteFeature": "Delete feature", + "deleteMilestone": "Delete milestone", + "deleteMission": "Delete mission", + "deleteSlice": "Delete slice", + "deleted": "Mission deleted", + "describeGoal": "Describe what you want to build. The AI will interview you to understand scope, constraints, and requirements, then produce a structured plan with milestones, slices, and features.", + "descriptionLabel": "Mission Description", + "descriptionOptional": "Description (optional)", + "detailTabs": "Mission detail tabs", + "discardButton": "Discard", + "discardDraft": "Discard draft", + "discardDraftConfirm": "Discard this interview draft? This removes the saved draft and cannot be undone.", + "dismiss": "Dismiss", + "draftDiscardFailed": "Failed to discard draft", + "draftOpenInAnotherTab": "Draft is open in another tab", + "draftsLabel": "Drafts", + "editAssertion": "Edit assertion", + "editFeature": "Edit feature", + "editMilestone": "Edit milestone", + "editMission": "Edit mission", + "editSlice": "Edit slice", + "enrichedDescPreview": "Enriched Description Preview", + "expandRunHistory": "Expand to show run history", + "expected": "Expected: {{value}}", + "failedAssertions": "Failed assertions:", + "failedAssertionsTitle": "Failed Assertions:", + "failedLoadModels": "Failed to load models", + "featureCreated": "Feature created", + "featureCriteriaAwaitingSync": "Feature criteria awaiting assertion sync", + "featureDeleteFailed": "Failed to delete feature", + "featureDeleted": "Feature deleted", + "featureLinkFailed": "Failed to link feature", + "featureLinkTaskFailed": "Failed to link feature to task", + "featureLinkedToAssertion": "Feature linked to assertion", + "featureLinkedToTask": "Feature linked to task", + "featureSaveFailed": "Failed to save feature", + "featureTitlePlaceholder": "Feature title", + "featureTitleRequired": "Feature title is required", + "featureTriageFailed": "Failed to triage feature", + "featureTriaged": "Feature triaged — task created", + "featureUnlinkFailed": "Failed to unlink feature", + "featureUnlinkFromAssertionFailed": "Failed to unlink feature", + "featureUnlinkedFromAssertion": "Feature unlinked from assertion", + "featureUnlinkedFromTask": "Feature unlinked from task", + "featureUpdated": "Feature updated", + "featuresCount_one": "{{count}} features", + "featuresCount_other": "{{count}} features", + "filterAll": "All events", + "filterAutopilot": "Autopilot events", + "filterErrors": "Errors & warnings", + "filterLabel": "Filter", + "filterSlices": "Slice & milestone events", + "filterStateChanges": "State changes", + "filterTasks": "Task events", + "generatedFixFeatures": "Generated fix features:", + "generatedFixFeaturesTitle": "Generated Fix Features", + "generatedFromFeature": "Generated from feature: {{id}}", + "hideDetails": "Hide details", + "hideMetadata": "Hide metadata", + "hideThinking": "Hide thinking", + "hideValidationRounds": "Hide validation rounds", + "interviewActionGenerating": "Generating plan", + "interviewActionResume": "Resume interview", + "interviewActionRetry": "Retry interview", + "interviewActionReview": "Review plan", + "interviewComplete": "Plan ready — review and approve to create the mission.", + "interviewErrorDefault": "Session failed while contacting the AI.", + "interviewErrored": "Interview hit an error. Retry from this list item.", + "interviewGenerating": "Generating mission hierarchy from interview context.", + "interviewInProgress": "Interview in progress", + "interviewWaiting": "Interview is waiting for your next response.", + "lastValidatorStatus": "Last {{status}}", + "linkAFeature": "Link a feature", + "linkButton": "Link", + "linkFeatureButton": "Link Feature", + "linkFeatureToTask": "Link feature to task:", + "linkToTask": "Link to task", + "linkedCount_one": "{{count}} linked", + "linkedCount_other": "{{count}} linked", + "linkedFeaturesCount_one": "{{count}} linked feature", + "linkedFeaturesCount_other": "{{count}} linked features", + "linkedFeaturesLabel": "Linked Features", + "linkedGoals": "Linked goals", + "linkedGoalsTitle": "Linked Goals", + "loadActivityFailed": "Failed to load mission activity", + "loadDetailFailed": "Failed to load mission details", + "loadFailed": "Failed to load missions", + "loadMore": "Load more", + "loadingActivity": "Loading mission activity...", + "loadingMissionDetails": "Loading mission details...", + "loadingMissions": "Loading missions...", + "loadingModels": "Loading models…", + "loopState": "Loop state: {{state}}", + "milestoneCreated": "Milestone created", + "milestoneDeleteFailed": "Failed to delete milestone", + "milestoneDeleted": "Milestone deleted", + "milestoneDescriptionPlaceholder": "Milestone description...", + "milestoneSaveFailed": "Failed to save milestone", + "milestoneTitlePlaceholder": "Milestone title", + "milestoneTitleRequired": "Milestone title is required", + "milestoneUpdated": "Milestone updated", + "milestonesCount_one": "{{count}} milestones", + "milestonesCount_other": "{{count}} milestones", + "missionHealthAriaLabel": "Mission health: {{state}}", + "missionInterviewInProgressDesc": "Mission interview is still in progress. Open this mission to continue planning.", + "missionList": "Mission list", + "missionManagerAriaLabel": "Mission Manager", + "missionTargetBranchAriaLabel": "Mission target branch", + "missionTitlePlaceholder": "Mission title", + "noAssertionFailures": "No assertion failures", + "noAssertionsDefined": "No feature acceptance criteria or contract assertions defined yet.", + "noAssertionsYet": "No linked contract assertions are loaded yet. Feature criteria below will still be AI-validated when mission validation runs.", + "noEventsYet": "No events yet.", + "noFeaturesLinkedYet": "No features linked yet", + "noFixFeaturesGenerated": "No fix features generated.", + "noGoalsLinked": "No goals linked to this mission", + "noLinkedGoals": "No linked goals.", + "noMilestonesYet": "No milestones yet. Add one to get started.", + "noMissionsYetBody": "Missions are large initiatives that bundle milestones, slices, and features into a single plan. Plan a mission to break down a goal end-to-end and let agents work through it autopilot-style.", + "noMissionsYetTitle": "No missions yet", + "noSlicesYet": "No slices yet", + "noValidationRunsYet": "No validation runs yet.", + "none": "None", + "openMissionAriaLabel": "Open mission {{title}}", + "orSelect": "Or select:", + "planMilestone": "Plan milestone", + "planNewMission": "Plan New Mission", + "planReady": "Mission Plan Ready", + "planSlice": "Plan slice", + "planStateNeedsUpdate": "Needs update", + "planStateNotPlanned": "Not planned", + "planStatePlanned": "Planned", + "planTitle": "Plan Mission with AI", + "planningModel": "Planning Model", + "prepareQuestion": "Preparing next question...", + "progressText_one": "Question {{count}} of ~6", + "progressText_other": "Question {{count}} of ~6", + "reconnecting": "Reconnecting…", + "relativeTimeDays_one": "{{count}}d ago", + "relativeTimeDays_other": "{{count}}d ago", + "relativeTimeHours_one": "{{count}}h ago", + "relativeTimeHours_other": "{{count}}h ago", + "relativeTimeJustNow": "just now", + "relativeTimeMinutes_one": "{{count}}m ago", + "relativeTimeMinutes_other": "{{count}}m ago", + "removeFeature": "Remove feature", + "removeMilestone": "Remove milestone", + "removeSlice": "Remove slice", + "resizeSidebar": "Resize mission sidebar", + "resumeFailed": "Failed to resume mission", + "resumeInterviewAriaLabel": "Resume interview {{title}}", + "resumeMission": "Resume mission", + "resumed": "Mission resumed", + "retries": "retries", + "retry": "retry", + "retryBudgetTitle": "Implementation attempts and remaining retry budget", + "retrying": "Retrying...", + "roadmapLabel": "Roadmap", + "run": "Run:", + "runSettings": "Mission run settings", + "runSettingsTitle": "Mission run settings", + "saveButton": "Save", + "saveFailed": "Failed to save mission", + "selectMissionToView": "Select a mission to view details", + "sendToBackground": "Send to background", + "sessionActiveAnother": "Session is active in another tab.", + "sessionActiveHeartbeat": "This session is active in another tab (live heartbeat)", + "sessionActiveTab": "This session is active in another tab", + "showDetails": "Show details", + "showMetadata": "Show metadata", + "showThinking": "Show thinking", + "showValidationRounds": "Show validation rounds", + "sliceActivateFailed": "Failed to activate slice", + "sliceActivated": "Slice activated", + "sliceCreated": "Slice created", + "sliceDeleteFailed": "Failed to delete slice", + "sliceDeleted": "Slice deleted", + "sliceSaveFailed": "Failed to save slice", + "sliceTitlePlaceholder": "Slice title", + "sliceTitleRequired": "Slice title is required", + "sliceTriageFailed": "Failed to triage slice features", + "sliceTriaged_one": "Triaged {{count}} feature", + "sliceTriaged_other": "Triaged {{count}} features", + "sliceUpdated": "Slice updated", + "sliceVerification": "Slice Verification", + "slicesCount_one": "{{count}} slices", + "slicesCount_other": "{{count}} slices", + "source": "Source:", + "startFailed": "Failed to start mission", + "startInterview": "Start Interview", + "startMission": "Start mission", + "startOver": "Start Over", + "started": "Mission started — first slice activated", + "statusActive": "Active", + "statusArchived": "Archived", + "statusBlocked": "Blocked", + "statusComplete": "Complete", + "statusDefined": "Defined", + "statusDone": "Done", + "statusFailed": "Failed", + "statusInProgress": "In Progress", + "statusPassed": "Passed", + "statusPending": "Pending", + "statusPlanning": "Planning", + "statusTriaged": "Triaged", + "stopFailed": "Failed to stop mission", + "stopMission": "Stop mission", + "stopped_one": "Mission stopped ({{count}} task paused)", + "stopped_other": "Mission stopped ({{count}} tasks paused)", + "summaryStats": "{{milestones}} milestones, {{features}} features. Review and edit before approving.", + "tabActivity_one": "Activity ({{count}})", + "tabActivity_other": "Activity ({{count}})", + "tabStructure": "Structure", + "takeControl": "Take Control", + "takingControl": "Taking control...", + "targetBranch": "Target branch", + "targetBranchPlaceholder": "e.g. main", + "taskIdPlaceholder": "Task ID (e.g., FN-001)", + "taskIdRequired": "Task ID is required", + "tasksFailed_one": "{{count}} failed", + "tasksFailed_other": "{{count}} failed", + "title": "Missions", + "titleLabel": "Mission Title", + "titleRequired": "Mission title is required", + "toggleDetails": "Toggle details", + "transformVision": "Transform your vision into a structured mission", + "triageAllFeatures": "Triage all features", + "triageCreateTask": "Triage — create task", + "tryExample": "Try an example:", + "typeAnswer": "Type your answer here...", + "unlinkFeature": "Unlink feature", + "unlinkTask": "Unlink task", + "unlinkedBadge": "Unlinked", + "untitled": "Untitled", + "updateButton": "Update", + "updated": "Mission updated", + "validateFeature": "Validate feature", + "validationRoundsCount_one": "{{count}} round", + "validationRoundsCount_other": "{{count}} rounds", + "validationRoundsLabel_one": "Validation rounds ({{count}})", + "validationRoundsLabel_other": "Validation rounds ({{count}})", + "validationRuns": "Validation Runs", + "validationState": "Validation state", + "validationStateNotStarted": "Not started", + "validationTelemetry": "Validation Telemetry", + "validationTriggerFailed": "Failed to trigger validation", + "validationTriggered": "Validation triggered", + "verification": "Verification:", + "verificationCriteria": "Verification Criteria", + "viewMissionFailures": "View mission failures", + "whatToBuild": "What do you want to build?" + }, + "modalManager": { + "createdFromPlanning": "Created {{id}} from planning mode", + "createdFromSubtask": "Created {{ids}} from subtask breakdown", + "createdMultipleFromPlanning": "Created {{ids}} from planning mode" + }, + "model": { + "noChange": "No change", + "selectPlaceholder": "Select a model…" + }, + "modelSelection": { + "choose": "Choose models for this task. If not selected, default models will be used.", + "custom": "Custom", + "executorModel": "Executor Model", + "executorPlaceholder": "Select executor model…", + "loading": "Loading models…", + "noModels": "No models available. Configure authentication in Settings to enable model selection.", + "preset": "Preset", + "reviewerModel": "Reviewer Model", + "reviewerPlaceholder": "Select reviewer model…", + "title": "Select Models", + "useDefault": "Use default", + "usingDefault": "Using default" + }, + "models": { + "addProviderToFavoritesAriaLabel": "Add {{provider}} to favorites", + "addToFavorites": "Add to favorites", + "addToFavoritesAriaLabel": "Add {{name}} to favorites", + "clearFilter": "Clear filter", + "count_one": "{{count}} model", + "count_other": "{{count}} models", + "descriptions": { + "executor": "The AI model used to implement this task.", + "override": "Override the AI models used for this task. When not specified, project or global defaults are used.", + "planning": "The AI model used for task specification (triage phase).", + "reviewer": "The AI model used to review code and plans for this task.", + "thinkingLevel": "Controls the reasoning effort for the AI agent. Higher levels use more tokens." + }, + "emptyStates": { + "noModels": "No models available. Configure authentication in Settings to enable model selection." + }, + "errors": { + "failedSaveSettings": "Failed to save model settings", + "failedSaveThinking": "Failed to save thinking level", + "failedUpdateFavorites": "Failed to update favorites", + "failedUpdateModelFavorites": "Failed to update model favorites" + }, + "filterPlaceholder": "Filter models…", + "labels": { + "executorModel": "Executor Model", + "planningModel": "Planning Model", + "reviewerModel": "Reviewer Model", + "thinkingLevel": "Thinking Level" + }, + "messages": { + "thinkingLevelSet": "Thinking level set to {{level}}", + "thinkingLevelSetDefault": "Thinking level set to default ({{level}})", + "upToDate": "Model settings are up to date.", + "usingDefaults": "Using project or global default models." + }, + "noResults": "No models match '{{filter}}'", + "options": { + "default": "Default", + "high": "High", + "low": "Low", + "medium": "Medium", + "minimal": "Minimal", + "off": "Off" + }, + "placeholders": { + "selectExecutor": "Select executor model…", + "selectPlanning": "Select planning model…", + "selectReviewer": "Select reviewer model…" + }, + "removeFromFavorites": "Remove from favorites", + "removeFromFavoritesAriaLabel": "Remove {{name}} from favorites", + "removeProviderFromFavoritesAriaLabel": "Remove {{provider}} from favorites", + "states": { + "loading": "Loading available models…", + "usingDefault": "Using default" + }, + "titles": { + "configuration": "Model Configuration" + }, + "useDefault": "Use default" + }, + "nav": { + "activityLog": "Activity Log", + "agents": "Agents", + "automation": "Automation", + "chat": "Chat", + "chatUnreadAriaLabel": "Unread chat response", + "devServer": "Dev Server", + "documents": "Documents", + "evals": "Evals", + "files": "Files", + "gitManager": "Git Manager", + "goals": "Goals", + "importFromGitHub": "Import from GitHub", + "insights": "Insights", + "loadingScripts": "Loading scripts…", + "mailbox": "Mailbox", + "mailboxPendingAriaLabel": "Pending approvals", + "manageScripts": "Manage Scripts…", + "memory": "Memory", + "missions": "Missions", + "more": "More", + "moreSheetTitle": "Navigate", + "noScriptsAddOne": "No scripts — add one…", + "nodes": "Nodes", + "planning": "Planning", + "primaryNavAriaLabel": "Primary navigation", + "projects": "Projects", + "reliability": "Reliability", + "research": "Research", + "scriptsSubmenuAriaLabel": "Scripts submenu", + "secrets": "Secrets", + "settings": "Settings", + "showScriptsAriaLabel": "Show scripts", + "skills": "Skills", + "stashRecovery": "Stash Recovery", + "systemStats": "System Stats", + "tasks": "Tasks", + "terminal": "Terminal", + "todos": "Todos", + "usage": "Usage", + "workflowSteps": "Workflow Steps" + }, + "newTaskModal": { + "addDependencies": "Add dependencies", + "assignAgent": "Assign Agent", + "assignAgentButton": "Assign agent", + "branchRequired": "Branch name is required for this branch strategy.", + "clearSelection": "Clear selection", + "createTask": "Create Task", + "creating": "Creating...", + "dependencies": "Dependencies", + "discardChanges": "Discard Changes", + "failedToCreate": "Failed to create task", + "failedToUpload": "Failed to upload: {{files}}", + "loadingAgents": "Loading agents...", + "noAgentsAvailable": "No agents available", + "noAvailableTasks": "No available tasks", + "searchTasks": "Search tasks…", + "selectAgent": "Select agent", + "selectedCount_one": "{{count}} selected", + "selectedCount_other": "{{count}} selected", + "taskCreated": "Created {{taskId}}", + "title": "New Task", + "unsavedChanges": "You have unsaved changes. Discard them?" + }, + "nodeStatus": { + "local": "Local" + }, + "nodeSync": { + "error": { + "authSyncFailed": "Auth sync failed", + "failedToFetchStatus": "Failed to fetch sync status", + "pullFailed": "Pull settings failed", + "pushFailed": "Push settings failed", + "someRequestsFailed": "Some sync status requests failed" + } + }, + "nodes": { + "actions": { + "connect": "Connect", + "connecting": "Connecting...", + "edit": { + "ariaLabel": "Edit node", + "label": "Edit", + "title": "Edit" + }, + "health": { + "ariaLabel": "Run node health check", + "label": "Health", + "title": "Health Check" + }, + "remove": { + "ariaLabel": "Remove node", + "ariaLabelConfirm": "Confirm remove node", + "label": "Remove", + "labelConfirm": "Confirm", + "title": "Remove", + "titleConfirm": "Confirm remove" + }, + "restart": { + "ariaLabel": "Restart node container", + "label": "Restart", + "title": "Available after FN-3113" + }, + "start": { + "ariaLabel": "Start node container", + "label": "Start", + "title": "Available after FN-3113" + }, + "stop": { + "ariaLabel": "Stop node container", + "label": "Stop", + "title": "Available after FN-3113" + } + }, + "addCliButton": "Add CLI", + "addDockerNode": "Add Docker Node", + "addDockerNodeTitle": "Add a managed Docker node", + "addFirstNode": "Add First Node", + "addMountButton": "Add Mount", + "addNode": "Add Node", + "addVariableButton": "Add Variable", + "adding": "Adding...", + "apiKey": "API Key", + "apiKeyMode": "API Key Mode", + "apiKeyNotConfigured": "Not configured", + "apiKeyPlaceholder": "Enter node API key", + "attachProjects": "Attach Existing Projects", + "attachProjectsHint": "Select existing projects to run on this node and provide the node-specific absolute path for each one.", + "authSync": { + "differ": "credentials differ", + "label": "Auth sync: {{status}}", + "match": "credentials match", + "notSynced": "not synced" + }, + "autoAssignment": "Auto (no assignment)", + "autoGenerate": "Auto-generate", + "availableSoon": "Available after FN-3113", + "cancelButton": "Cancel", + "closeAriaLabel": "Close nodes view", + "closeButton": "Close", + "closeLogsAriaLabel": "Close logs", + "closeModalAriaLabel": "Close node detail modal", + "closeNodeModal": "Close add node modal", + "concurrencyMin": "Concurrency must be at least 1", + "concurrencyRange": "Concurrency must be between {{min}} and {{max}}", + "containerLogs": "Container Logs", + "description": "Register an existing Fusion node by providing its connection details and concurrency settings.", + "discoverBeforeAdding": "Discover remote projects before adding this node.", + "discoverRemoteProjects": "Discover Remote Projects", + "discoveredCount_one": "Discovered {{count}} remote project{{plural}}", + "discoveredCount_other": "Discovered {{count}} remote project{{plural}}", + "discovering": "Discovering...", + "discoveryFailed": "Failed to discover remote projects", + "dismissError": "Dismiss error", + "docker": "Docker", + "dockerBadge": "Managed Docker node", + "dockerConfigSaveFailed": "Failed to save Docker config", + "dockerConfigSaveSuccess": "Docker config saved", + "dockerConfiguration": "Docker Configuration", + "dockerContainerId": "Container ID", + "dockerContainerPath": "Container path", + "dockerContextName": "Context name", + "dockerCpuCount": "CPU count", + "dockerDefaultCpu": "Default CPU", + "dockerDefaultMemory": "Default memory", + "dockerEnvVars": "Environment Variables", + "dockerExitCode": "Exit code:", + "dockerExtraClis": "Extra CLIs", + "dockerFieldImage": "Image", + "dockerHost": { + "local": "Local Docker", + "remote": "Remote: {{host}}" + }, + "dockerHostConfig": "Host Config", + "dockerHostPath": "Host path", + "dockerHostUrl": "Docker host URL", + "dockerLabel": "Docker", + "dockerMemoryBytes": "Memory bytes (2 GB = 2147483648)", + "dockerNeedsRecreate": "Needs Recreate", + "dockerNodeCreated": "Docker node \"{{name}}\" created", + "dockerPersistence": "Persistence", + "dockerPersistentStorage": "Persistent Storage", + "dockerPidsLimit": "PIDs limit", + "dockerPort": "Port", + "dockerResourceDefault": "Default", + "dockerResourceSizing": "Resource Sizing", + "dockerResources": "Resources", + "dockerRetainOnDelete": "Retain on delete", + "dockerStatusUnknown": "Unknown", + "dockerTlsCaCert": "TLS CA cert path", + "dockerTlsCert": "TLS cert path", + "dockerTlsKey": "TLS key path", + "dockerTlsVerify": "TLS verify", + "dockerUptime": "Uptime:", + "dockerVolumeMounts": "Volume Mounts", + "dockerVolumeName": "Volume name", + "editButton": "Edit", + "errorFetching": "Failed to fetch nodes", + "errorPersistMappings": "Failed to persist project mappings", + "errorUnregisterAfterMappingFailure": "Failed to unregister node after mapping failure", + "errors": { + "connectFailed": "Failed to connect", + "connectToNode": "Failed to connect to node" + }, + "failedCreateDocker": "Failed to create Docker node", + "failedRefresh": "Failed to refresh nodes", + "failedRemove": "Failed to remove node", + "fetchContainerLogsFailed": "Failed to fetch container logs", + "fetchContainerStatusFailed": "Failed to fetch container status", + "fetchingLogs": "Fetching logs...", + "fieldApiKey": "API Key", + "fieldCreated": "Created", + "fieldMaxConcurrent": "Max Concurrent", + "fieldName": "Name", + "fieldStatus": "Status", + "fieldType": "Type", + "fieldUpdated": "Updated", + "fieldUrl": "URL", + "fields": { + "authKey": "Auth Key", + "host": "Host / IP Address", + "maxConcurrent": "Max Concurrent", + "name": "Node Name", + "port": "Port", + "url": "URL" + }, + "heading": "Nodes", + "healthCheckButton": "Health Check", + "healthCheckComplete": "Node health check complete", + "healthCheckFailed": "Health check failed", + "healthCheckSuccess": "Health check completed for {{name}}", + "healthLastCheck": "Last check:", + "healthStatus": "Status:", + "local": "Local", + "localDocker": "Local Docker", + "localNodeName": "Local", + "maxConcurrent": "Max Concurrent", + "maxConcurrentHint": "Max simultaneous task agents (1–10)", + "meshTopology": "Mesh Topology", + "meshTopologyAriaLabel": "Mesh Topology", + "metrics": { + "concurrency": "Concurrency", + "projects": "Projects" + }, + "modal": { + "closeButton": "Close connect node modal", + "title": "Connect to Node" + }, + "modalAriaLabel": "Node details for {{name}}", + "modalTitle": "Node Details", + "multipleMatches": "Multiple remote projects matched this name. Enter the correct path manually.", + "name": "Name", + "namePlaceholder": "Build Machine", + "nameRequired": "Name is required", + "no": "No", + "noLogsAvailable": "No logs available", + "noMatch": "No exact remote name match. Enter this path manually.", + "noProjects": "No projects are currently registered.", + "noProjectsAssigned": "No projects are assigned to this node.", + "noProjectsDiscovered": "No projects discovered on remote node.", + "noProjectsRunning": "No projects are running on this node.", + "noRegistered": "No nodes are registered yet.", + "nodeLabel": "{{name}} ({{type}}) — {{status}}", + "offline": "Offline", + "online": "Online", + "pathDiscovered": "Remote-authoritative path discovered: {{path}}", + "pathInvalid": "Path is invalid", + "pathOnNode": "Path on this node", + "placeholders": { + "host": "192.0.2.10 or my-server.local", + "name": "Build Server", + "optional": "Optional" + }, + "provideManually": "Provide key manually", + "pullSettings": "Pull Settings", + "pullSettingsFailed": "Pull settings failed", + "pullSettingsSuccess": "Settings pulled successfully", + "pulling": "Pulling...", + "pushSettings": "Push Settings", + "pushSettingsFailed": "Push settings failed", + "pushSettingsSuccess": "Settings pushed successfully", + "pushing": "Pushing...", + "reachableUrl": "Reachable URL / Hostname", + "readOnly": "Read-only", + "refresh": "Refresh", + "refreshStatus": "Refresh Status", + "refreshing": "Refreshing...", + "registerFailed": "Failed to register node", + "registered": "Node \"{{name}}\" registered", + "registeredCount_one": "{{count}} registered", + "registeredCount_other": "{{count}} registered", + "remote": "Remote", + "removeButton": "Remove", + "removed": "Node removed", + "restartButton": "Restart", + "runtimeNodeLabel": "Runtime Node", + "saveButton": "Save", + "saveDockerConfig": "Save Docker Config", + "saving": "Saving...", + "sectionAssignedProjects": "Assigned Projects", + "sectionDockerManagement": "Docker Management", + "sectionHealth": "Health", + "sectionOverview": "Overview", + "sectionProjects": "Projects", + "sectionSettingsSync": "Settings Sync", + "sectionSyncHistory": "Sync History", + "startButton": "Start", + "statusConnecting": "Connecting", + "statusError": "Error", + "statusOffline": "Offline", + "statusOnline": "Online", + "statusTitle": "Status: {{status}}", + "stopButton": "Stop", + "success": { + "connected": "Connected to \"{{name}}\"" + }, + "syncAuth": "Sync Auth", + "syncAuthFailed": "Auth sync failed", + "syncAuthSuccess": "Auth credentials synced successfully", + "syncDifferences": "Differences:", + "syncLastSync": "Last sync:", + "syncNeverSynced": "Never synced", + "synced": "Synced", + "syncing": "Syncing...", + "total": "Total", + "type": { + "local": "Local", + "remote": "Remote" + }, + "typeLocal": "Local", + "typeRemote": "Remote", + "updateFailed": "Failed to update node", + "updateSuccess": "Updated {{name}}", + "urlRequired": "URL is required for remote nodes", + "validation": { + "concurrencyRange": "Concurrency must be between {{min}} and {{max}}", + "hostRequired": "Host / IP address is required", + "nameRequired": "Node name is required", + "portRange": "Port must be between 1 and 65535" + }, + "viewLogsButton": "View Logs", + "yes": "Yes" + }, + "onboarding": { + "authToken": "Auth token (optional)", + "continue": "Continue", + "continueOnboarding": "Continue onboarding", + "continueSetup": "Continue Setup", + "defaultName": "Remote Server", + "description": "Fusion helps you plan, run, and review AI-assisted engineering work.", + "localFusion": "Local Fusion", + "onTheStep": "You're on the ", + "profileName": "Profile name", + "progressText": "{{completed}} of {{total}} step{{pluralS}} complete — You're on the ", + "remoteServer": "Remote Server", + "resumeOnboarding": "Resume onboarding", + "saving": "Saving…", + "scanQr": "Scan QR", + "scanning": "Scanning…", + "serverUrl": "Server URL", + "serverUrlPlaceholder": "https://your-fusion-host", + "stepContinue": "step. Continue where you left off to complete your dashboard setup.", + "welcome": "Welcome to Fusion" + }, + "openclaw": { + "agentId": "Agent ID", + "agentIdHint": "OpenClaw agent definition to run (default: main).", + "agentIdPlaceholder": "main", + "binaryPath": "Binary path", + "binaryPathHint": "Leave blank to resolve openclaw from your PATH.", + "binaryPathPlaceholder": "openclaw (defaults to PATH)", + "cliTimeout": "OpenClaw timeout (sec)", + "cliTimeoutHint": "OpenClaw-side run timeout in seconds (0 = no timeout).", + "description": "Drives the local openclaw CLI as a subprocess. Each Fusion prompt is dispatched via openclaw agent --local --json; the agent definition, model, and thinking level are resolved by OpenClaw. This card only sets overrides and the binary path.", + "detected": "✓ openclaw detected{{version}}{{path}}.", + "gateway": "Route through OpenClaw gateway (otherwise embedded)", + "gatewayHint": "When enabled, calls pass through the OpenClaw gateway service.", + "githubLink": "openclaw on GitHub", + "model": "Model override", + "modelHint": "Optional — overrides the OpenClaw default model.", + "modelPlaceholder": "e.g. anthropic/claude-haiku-4-5, minimax/MiniMax-M3", + "notDetectedInstall": "openclaw not detected. Install the upstream agent:", + "notFound": "✗ openclaw not found", + "probing": "Probing local openclaw binary…", + "saved": "Settings saved.", + "savedDetected": "Saved · ✓ openclaw detected{{version}}{{path}}.", + "savedNotFound": "Saved · ✗ openclaw not found", + "savedProbeFailed": "Saved, but probe failed.", + "statusDetected": "✓ Detected{{version}}{{path}}", + "statusNotFound": "✗ not detected on PATH", + "subprocess": "CLI subprocess timeout (ms)", + "subprocessHint": "Fusion-side hard cap before the CLI subprocess is killed (default: {{default}}s).", + "testFailed": "Test failed — see status above.", + "thinking": "Thinking level", + "thinkingHint": "Controls how much extended thinking the model uses." + }, + "paperclip": { + "agentHelp": "Pick the Paperclip agent this Fusion runtime will proxy.", + "agentLabel": "Agent", + "apiKeyHelp": "Agent API key. Local-trusted deployments may leave this blank.", + "apiKeyLabel": "API key", + "apiKeyMinted": "API key minted via paperclipai (key 'fusion-runtime' installed for agent {{agentId}}). Click Save to persist.", + "apiKeyPlaceholder": "•••••••• (leave blank to keep existing)", + "apiUrlHelp": "Base URL of the Paperclip server.", + "apiUrlLabel": "API URL", + "cliApiKeyHelp": "Local-trusted deployments do not require a key.", + "cliApiKeyLabel": "API key (override, optional)", + "cliApiKeyPlaceholder": "Optional — only required for non-local-trusted modes", + "cliBinaryHelp": "Optional — informational; the adapter currently reads the instance config file directly.", + "cliBinaryLabel": "paperclipai binary", + "cliConfigLabel": "CLI config:", + "cliConfigPathHelp": "Override the path to config.json. Leave blank for the default.", + "cliConfigPathLabel": "Instance config path", + "cliDiscoveryFailed": "CLI discovery failed: {{reason}}", + "cliDiscoveryFailedLabel": "CLI discovery failed: {{reason}}", + "companyHelp": "Select a Paperclip company.", + "companyIdRequired": "Company ID is required to mint a Paperclip API key.", + "companyLabel": "Company", + "connectToPopulate": "Connect to populate", + "connected": "Connected.", + "connectedAsAgent": "Connected as {{agentName}}{{companyInfo}}.", + "connectionModeAriaLabel": "Paperclip connection mode", + "description": "Drive a Paperclip agent (employee) in a Paperclip company. Each prompt dispatches a task-shaped request; governance, budgets, and approvals are enforced by Paperclip. Expect seconds-to-minutes latency per turn.", + "docsLink": "Paperclip docs", + "githubLink": "GitHub", + "goalIdHelp": "Associate work with a Paperclip goal.", + "goalIdLabel": "Goal ID (optional)", + "mintButton": "Mint API key via paperclipai", + "mintFailed": "Mint failed: {{reason}}. Run `paperclipai onboard` first if your CLI isn't authenticated.", + "modeLabel": "Conversation mode", + "name": "Paperclip", + "noAgentsDiscovered": "No agents discovered", + "noCompaniesDiscovered": "No companies discovered", + "onboardingStep1": "Make sure a Paperclip server is running. To install Paperclip:", + "optionalPlaceholder": "Optional", + "parentIssueIdHelp": "Scope work under an existing parent issue.", + "parentIssueIdLabel": "Parent issue ID (optional)", + "pickCompanyFirst": "Pick a company first", + "projectIdHelp": "Pin work to a specific Paperclip project.", + "projectIdLabel": "Project ID (optional)", + "resolved": "resolved", + "runTimeoutHelp": "Local cap before Fusion gives up on a Paperclip run.", + "runTimeoutLabel": "Run timeout (ms)", + "savedCliDiscoveryFailed": "Saved · CLI discovery failed: {{reason}}", + "savedConnected": "Saved · Connected.", + "savedConnectedAsAgent": "Saved · Connected as {{agentName}}{{companyInfo}}.", + "savedProbeFailed": "Saved, but probe failed.", + "savedUnreachable": "Saved · {{reason}}", + "settingsSaved": "Settings saved.", + "statusCliDiscoveryFailed": "CLI discovery failed: {{reason}}", + "statusConnected": "Connected", + "statusConnectedAs": "Connected as {{agentName}}{{roleInfo}}{{companyInfo}}", + "statusProbing": "Probing Paperclip server…", + "statusUnreachable": "{{reason}}", + "tabApi": "API (URL + token)", + "tabCliAutoDerve": "Local CLI (auto-derive)", + "testFailed": "Test failed — see status above.", + "unreachable": "{{reason}}" + }, + "piManager": { + "addButton": "Add", + "discoveredDescription": "Installed extensions resolved from packages and configured paths.", + "discoveredTitle": "Discovered Extensions", + "enterSource": "Please enter a package source", + "extensionDisabled": "Extension disabled", + "extensionEnabled": "Extension enabled", + "filterExtensions": "Extensions:", + "filterPrompts": "Prompts:", + "filterSkills": "Skills:", + "filterThemes": "Themes:", + "installFailed": "Failed to install package: {{error}}", + "installSuccess": "Package installed successfully", + "installing": "Installing…", + "loadExtensionsFailed": "Failed to load extensions: {{error}}", + "loadSettingsFailed": "Failed to load Pi settings: {{error}}", + "loading": "Loading Pi settings…", + "loadingExtensions": "Loading extensions…", + "loadingFailed": "Failed to load Pi settings.", + "noExtensions": "No extensions discovered.", + "noPackages": "No packages configured.", + "noPackagesHelp": "Add a package source above to get started.", + "packagePlaceholder": "npm:pi-extension-name or git:https://github.com/...", + "packageRemoved": "Package removed", + "refreshButton": "Refresh", + "refreshExtensions": "Refresh extensions", + "reinstallButton": "Reinstall Fusion skill", + "reinstallFailed": "Failed to reinstall Fusion skill: {{error}}", + "reinstallSuccess": "Fusion skill reinstalled successfully", + "reinstalling": "Reinstalling Fusion…", + "removeFailed": "Failed to remove package: {{error}}", + "removePackage": "Remove package", + "removePackageLabel": "Remove package {{label}}", + "removeResource": "Remove {{path}}", + "resourceRemoved": "{{resource}} removed", + "sectionExtensions": "Extensions", + "sectionPrompts": "Prompts", + "sectionSkills": "Skills", + "sectionThemes": "Themes", + "title": "Pi Extensions", + "toggleExtension": "Toggle {{name}}", + "updateFailed": "Failed to update extension: {{error}}", + "updateSettingsFailed": "Failed to update settings: {{error}}" + }, + "planning": { + "addSubtask": "Add subtask", + "additionalComments": "Additional comments (optional)", + "additionalCommentsPlaceholder": "Add any extra context or direction...", + "advancedSettings": "Advanced planning settings", + "aiThinking": "AI is thinking...", + "archiveSession": "Archive session", + "backToSessions": "Back to sessions", + "backToSummary": "Back to Summary", + "baseBranch": "Merge target / base branch (optional)", + "baseBranchPlaceholder": "main", + "branchAutoNew": "Create auto-named branch per task", + "branchCustomNew": "Create custom new branch", + "branchExisting": "Use existing branch", + "branchName": "Branch name", + "branchNameRequired": "Branch name is required for this branch strategy.", + "branchProjectDefault": "Use project/default branch", + "branchStrategy": "Branch strategy", + "breakIntoTasks": "Break into Tasks", + "breakIntoTasksTitle": "Break the plan into multiple tasks with dependencies", + "breakdownSubheading": "Review and edit the subtasks generated from your plan. Adjust titles, descriptions, sizes, priorities, and dependencies before creating.", + "breakingDown": "Breaking down...", + "collapse": "Collapse", + "continue": "Continue", + "createSingleTask": "Create Single Task", + "createTasks": "Create Tasks", + "creating": "Creating...", + "delete": "Delete", + "deleteSession": "Delete session", + "dependencies": "Dependencies", + "dependencyCycle": "Dependencies contain a cycle. Remove circular references before creating tasks.", + "depthBlurb": "Plan size sets default interview depth. Questions lets you override with an exact count.", + "depthLarge": "Large", + "depthMedium": "Medium", + "depthSmall": "Small", + "description": "Description", + "dismiss": "Dismiss", + "dragToReorder": "Drag to reorder", + "examplePlan1": "Build a user authentication system with login and signup", + "examplePlan2": "Add dark mode support to the dashboard", + "examplePlan3": "Create an API endpoint for exporting tasks as CSV", + "examplePlan4": "Refactor the task card component for better performance", + "expand": "Expand", + "failedCreateTask": "Failed to create task", + "failedCreateTasks": "Failed to create tasks", + "failedDeleteSession": "Failed to delete session", + "failedGoBack": "Failed to go back to the previous question", + "failedLoadModels": "Failed to load models", + "failedLoadSession": "Failed to load session", + "failedRefinePlan": "Failed to refine plan", + "failedStartBreakdown": "Failed to start breakdown", + "failedStartSession": "Failed to start planning session", + "failedSubmitResponse": "Failed to submit response", + "firstSubtaskNoDeps": "First subtask cannot have dependencies.", + "generatingQuestion": "Generating next question...", + "generationStopped": "Generation stopped by user. You can retry or start a new session.", + "hideArchived": "Hide archived", + "hideThinking": "Hide thinking", + "initialHeading": "Transform your idea into a detailed task", + "initialSubheading": "Describe what you want to build in plain language. The AI will ask clarifying questions and help you structure a well-defined task.", + "keyDeliverables": "Key Deliverables", + "loadingModels": "Loading models…", + "markdown": "Markdown", + "moveDown": "Move down", + "moveSubtaskDown": "Move subtask down", + "moveSubtaskUp": "Move subtask up", + "moveUp": "Move up", + "newPlanningSession": "New planning session", + "newSession": "New session", + "noActiveQuestion": "No active question in session", + "noPreviousSubtasks": "No previous subtasks available.", + "noSavedSessions": "No saved sessions yet. Start one on the right to see it here.", + "plain": "Plain", + "planningComplete": "Planning Complete!", + "planningCompleteSubheading": "Review and refine your task before creating it.", + "planningDepth": "Planning depth", + "planningModel": "Planning Model", + "planningModelBlurb": "Selects which model runs the planning interview and writes the final draft.", + "planningSessions": "Planning sessions", + "priority": "Priority", + "questionProgress": "Question {{progress}} of ~3", + "questionsAuto": "Auto", + "questionsLabel": "Questions", + "reconnecting": "Reconnecting…", + "refineFurther": "Refine Further", + "relativeTimeDays_one": "{{count}}d ago", + "relativeTimeDays_other": "{{count}}d ago", + "relativeTimeHours_one": "{{count}}h ago", + "relativeTimeHours_other": "{{count}}h ago", + "relativeTimeJustNow": "just now", + "relativeTimeMinutes_one": "{{count}}m ago", + "relativeTimeMinutes_other": "{{count}}m ago", + "relativeTimeWeeks_one": "{{count}}w ago", + "relativeTimeWeeks_other": "{{count}}w ago", + "remove": "Remove", + "retryFailed": "Retry failed. Please try again.", + "retrying": "Retrying...", + "sessionActiveOtherTab": "This session is active in another tab", + "sessionActiveOtherTabLive": "This session is active in another tab (live heartbeat)", + "sessionFailed": "Session failed while contacting the AI.", + "sessionFailed2": "Session failed", + "showArchived": "Show archived", + "showFormattedMarkdown": "Show formatted markdown", + "showQA": "Show user Q&A", + "showRawText": "Show raw text", + "showThinking": "Show thinking", + "sizeLarge": "L (Large)", + "sizeMedium": "M (Medium)", + "sizeSmall": "S (Small)", + "startPlanning": "Start Planning", + "statusComplete": "Complete", + "statusError": "Error", + "statusGenerating": "Generating", + "statusNeedsInput": "Needs input", + "stop": "Stop", + "subtaskDescription": "Description", + "subtaskPriority": "Priority", + "subtaskSize": "Size", + "subtaskTitle": "Title", + "suggestedDependencies": "Suggested Dependencies", + "suggestedSize": "Suggested Size", + "takeControl": "Take Control", + "takingControl": "Taking control...", + "thinkingElapsed": "Thinking… ({{seconds}}s)", + "title": "Planning Mode", + "tryAnExample": "Try an example:", + "typeAnswerPlaceholder": "Type your answer here...", + "unarchiveSession": "Unarchive session", + "untitled": "Untitled", + "untitledSession": "Untitled session", + "usingDefault": "Using default", + "whatToBuild": "What do you want to build?", + "whatToBuildPlaceholder": "e.g., Build a user authentication system with login, signup, and password reset..." + }, + "plugins": { + "addItem": "Add Item", + "aiScanDisabled": "AI scan on load disabled", + "aiScanEnabled": "AI scan on load enabled", + "aiScanHint": "Turning this on only updates configuration. Use Rescan and Reload to run it now.", + "author": "Author:", + "backToList": "Back to plugin list", + "builtinInstallFailed": "Failed to install {{name}}: {{error}}", + "builtinInstalledGlobally": "{{name}} installed globally", + "builtinMetadataOnly": "Built-in metadata only", + "builtinNoPackage": "{{name}} is built in and does not have an installable package yet", + "builtinPluginRecommendations": "Built-in plugin recommendations", + "builtinPlugins": "Built-in Plugins", + "builtinPluginsCatalog": "Built-in plugin catalog for runtimes and integrations.", + "cancel": "Cancel", + "checkingSetup": "Checking setup...", + "componentUnavailable": "Plugin component unavailable", + "couldNotResolve": "The dashboard could not resolve this plugin surface from the static host registry.", + "disableInProject": "Disable in Project", + "disablePlugin": "Disable {{name}}", + "disablePluginFailed": "Failed to disable plugin: {{error}}", + "disabledForProject": "{{name}} disabled for this project", + "droidOnboardingTip": "Tip: Enable Droid CLI to reuse your Factory AI subscription without adding an API key.", + "droidRecommendDesc": "Use your local Droid CLI session as an AI provider in Fusion.", + "droidRecommendTitle": "Enable Droid CLI", + "enableAiScanBeforeLoad": "Enable AI scan before load/reload", + "enableAiSecurityScan": "Enable AI security scan on load", + "enableFailed": "Failed to enable {{name}}: {{error}}", + "enableInProject": "Enable in Project", + "enablePlugin": "Enable {{name}}", + "enablePluginFailed": "Failed to enable plugin: {{error}}", + "enabledForProject": "{{name}} enabled for this project", + "experimental": "Experimental", + "findings_one": "Findings ({{count}})", + "findings_other": "Findings ({{count}})", + "homepage": "Homepage:", + "install": "Install", + "installFailed": "Failed to install plugin: {{error}}", + "installHint": "Browse to a plugin package root (contains manifest.json) or a built dist directory.", + "installNamed": "Install {{name}}", + "installPathPlaceholder": "Absolute path to plugin directory or dist folder", + "installPathRequired": "Please enter a plugin path", + "installPluginGlobally": "Install Plugin Globally", + "installSetup": "Install Setup", + "installedGlobally": "Plugin installed globally", + "installedPlugins": "Installed Plugins", + "installing": "Installing...", + "loadFailed": "Failed to load plugins: {{error}}", + "loading": "Loading...", + "loadingPlugins": "Loading plugins...", + "manage": "Manage", + "noConfigurableSettings": "No configurable settings.", + "noPluginsHint": "Install a plugin to get started, or use the built-in catalog below.", + "noPluginsInstalled": "No plugins installed.", + "noSecurityScan": "No security scan has been run yet.", + "openAuthentication": "Open Authentication", + "openOnboarding": "Open Onboarding", + "refresh": "Refresh", + "refreshPluginList": "Refresh plugin list", + "reload": "Reload", + "reloadFailed": "Failed to reload plugin: {{error}}", + "reloaded": "{{name}} reloaded", + "reloading": "Reloading...", + "removeItem": "Remove item", + "rescanAndReload": "Rescan and Reload", + "rescanFailed": "Failed to rescan plugin: {{error}}", + "rescanned": "{{name}} rescanned", + "saveSettings": "Save Settings", + "saveSettingsFailed": "Failed to save settings: {{error}}", + "securityScan": "Security Scan", + "selectOption": "Select...", + "settingUp": "Setting up...", + "settings": "Settings", + "settingsSaved": "Settings saved", + "setupInstallFailed": "Failed to install {{name}} setup: {{error}}", + "setupInstalled": "{{name}} setup installed", + "setupReady": "Setup ready", + "setupRequired": "Setup required", + "startPluginToCheckSetup": "Start plugin to check setup", + "statusBuiltIn": "Built in", + "statusInstalled": "Installed", + "statusNotInstalled": "Not installed", + "uninstallConfirm": "Are you sure you want to uninstall \"{{name}}\" globally (all projects)?", + "uninstallFailed": "Failed to uninstall plugin: {{error}}", + "uninstallGlobally": "Uninstall Globally", + "uninstallGloballyTitle": "Uninstall globally", + "uninstallTitle": "Uninstall Plugin Globally", + "uninstalledGlobally": "{{name}} uninstalled globally", + "unknownError": "unknown error", + "updateFailed": "Failed to update plugin: {{error}}", + "version": "Version:" + }, + "pr": { + "authFail": "Run gh auth login and try again.", + "authOk": "GitHub CLI auth is available.", + "baseBranch": "Base branch", + "bodyLabel": "Body", + "branchRemoteFail": "Push branch to remote before creating a PR.", + "branchRemoteOk": "Remote branch is available.", + "changedFilesLabel": "Changed files", + "checkAuth": "GitHub auth available", + "checkBranch": "Branch pushed to remote", + "checkCommits": "Commits available", + "checkConflicts": "No conflicts with base", + "commitsFail": "No commits found for this branch.", + "commitsLabel": "Commits", + "commitsOk": "Commits are ready to submit.", + "conflictsDetected": "Conflicts detected. Resolve conflicts or re-run preflight.", + "createAsDraft": "Create as draft", + "createDraftPr": "Create draft PR", + "createPr": "Create PR", + "createTitle": "Create Pull Request", + "dismissError": "Dismiss PR error", + "noConflicts": "No merge conflicts detected.", + "preflightChecks": "Pre-flight checks", + "previewTitle": "Diff & commit preview", + "regenerate": "Regenerate", + "rerunPreflight": "Re-run preflight", + "revertToAi": "Revert to AI version", + "titleLabel": "Title", + "usingTemplate": "Using .github/pull_request_template.md" + }, + "preview": { + "blockedDescription": "You can view the preview in a separate browser tab.", + "blockedTitle": "Preview cannot be embedded", + "errorTitle": "Unable to load preview", + "loading": "Loading preview...", + "openInNewTab": "Open in new tab" + }, + "projectCard": { + "activeTasks": "Active Tasks", + "agents": "Agents", + "cannotPauseWhileInitializing": "Cannot pause while initializing", + "completed": "Completed", + "confirm": "Confirm", + "confirmRemove": "Confirm remove", + "confirmRemoveProject": "Confirm remove project", + "daysAgo_one": "{{count}}d ago", + "daysAgo_other": "{{count}}d ago", + "hoursAgo_one": "{{count}}h ago", + "hoursAgo_other": "{{count}}h ago", + "justNow": "Just now", + "lastActivity": "Last activity:", + "minutesAgo_one": "{{count}}m ago", + "minutesAgo_other": "{{count}}m ago", + "moreItems_one": "+{{count}} more", + "moreItems_other": "+{{count}} more", + "never": "Never", + "noHealthData": "No health data available", + "nodeAvailability": "Project node availability", + "open": "Open", + "openProject": "Open project", + "pause": "Pause", + "pauseProject": "Pause project", + "removeProject": "Remove project", + "resume": "Resume", + "resumeProject": "Resume project" + }, + "projectDetection": { + "editName": "Edit name", + "empty": "No projects detected", + "emptyHint": "Try a different base path or add a project manually", + "noDbWarning": "No fn database found - will be initialized", + "registerAll": "Register All", + "registerSelected_one": "Register Selected ({{count}})", + "registerSelected_other": "Register Selected ({{count}})", + "registering": "Registering...", + "selectAll_one": "Select All ({{count}})", + "selectAll_other": "Select All ({{count}})", + "selectedCount_one": "{{count}} selected", + "selectedCount_other": "{{count}} selected" + }, + "projectSelector": { + "allProjects": "All Projects", + "ariaLabel": "Select project", + "clearSearch": "Clear search", + "noResults": "No projects match your search", + "recent": "Recent", + "searchPlaceholder": "Search projects...", + "selectProject": "Select Project", + "viewAll": "View All Projects" + }, + "projects": { + "actions": { + "favoritesError": "Failed to update favorites", + "modelFavoritesError": "Failed to update model favorites", + "pauseError": "Failed to pause project {{name}}", + "pauseSuccess": "Project {{name}} paused", + "removeError": "Failed to remove project {{name}}", + "removeSuccess": "Project {{name}} removed", + "resumeError": "Failed to resume project {{name}}", + "resumeSuccess": "Project {{name}} resumed" + }, + "activeTasksLabel": "Active Tasks", + "addFirstProject": "Add Your First Project", + "addProject": "Add Project", + "allNodes": "All Nodes", + "completedLabel": "Completed", + "emptyStateDescription": "Get started by adding your first project. Projects allow you to organize and track tasks across multiple repositories.", + "erroredLabel": "Errored", + "filterActive": "Active", + "filterAll": "All", + "filterByNode": "Filter by node", + "filterErrored": "Errored", + "filterPaused": "Paused", + "noMatch": "No projects match the current filter", + "noProjectsFound": "No Projects Found", + "nodesLabel": "Nodes", + "setup": { + "success": "Project {{name}} registered successfully" + }, + "showAll": "Show All Projects", + "sortActivityNewest": "Last Activity (Newest)", + "sortActivityOldest": "Last Activity (Oldest)", + "sortNameAsc": "Name (A-Z)", + "sortNameDesc": "Name (Z-A)", + "sortProjects": "Sort projects", + "sortStatusAsc": "Status (Error → Active)", + "sortStatusDesc": "Status (Active → Error)", + "title": "Projects", + "totalLabel": "Total" + }, + "providers": { + "actions": { + "addModel": "+ Add model", + "detectModels": "Detect Models", + "detectModelsTitle": "Call the provider's /models endpoint to discover available models", + "detecting": "Detecting…", + "removeModel_one": "Remove model", + "removeModel_other": "Remove model", + "save": "Save Provider", + "saving": "Saving..." + }, + "addCustom": "Add Custom Provider", + "apiKeyLabel": "API key", + "apiTypeAnthropic": "Anthropic-compatible", + "apiTypeInvalid": "API type is invalid.", + "apiTypeLabel": "API type", + "apiTypeOpenAi": "OpenAI-compatible", + "apiTypeOpenAiResp": "OpenAI Responses", + "baseUrlLabel": "Base URL", + "deleteConfirm": "Delete custom provider \"{{name}}\"?", + "deleteLabel": "Delete {{name}}", + "detectError": { + "allDuplicate": "All discovered models are already in the list.", + "failed": "Failed to detect models", + "noModels": "No models found. The provider may require an API key.", + "urlRequired": "Base URL is required to detect models." + }, + "detectModels": "Detect Models", + "detectTitle": "Auto-detect models from the provider's /models endpoint", + "detecting": "Detecting…", + "editLabel": "Edit {{name}}", + "failedDelete": "Failed to delete provider.", + "failedDetect": "Failed to detect models", + "failedLoad": "Failed to load custom providers.", + "failedSave": "Failed to save provider.", + "fields": { + "apiKey": "API Key", + "apiType": "API Type", + "baseUrl": "Base URL", + "contextWindow": "Context window", + "id": "Provider ID", + "maxTokens": "Max tokens", + "modelId": "Model ID", + "modelName": "Display name", + "modelNameLabel": "Model name", + "models": "Models", + "name": "Display Name", + "reasoning": "Reasoning" + }, + "llamaCpp": { + "disable": "Disable", + "disabling": "Disabling…", + "enable": "Enable", + "enabling": "Enabling…", + "probing": "Probing llama.cpp server…", + "reachable": "Server reachable at {{url}}", + "test": "Test", + "testing": "Testing…", + "title": "llama.cpp — via HTTP server", + "unavailable": "Server unavailable: {{reason}}" + }, + "loading": "Loading custom providers…", + "modelsLabel": "Available models", + "nameLabel": "Provider name", + "nameRequired": "Provider name is required.", + "noModelsFound": "No models found. The provider may require an API key.", + "noneConfigured": "No custom providers configured.", + "placeholders": { + "apiKey": "sk-..., MY_API_KEY, or !command" + }, + "saveChanges": "Save Changes", + "saveProvider": "Save Provider", + "saving": "Saving…", + "urlInvalid": "Base URL must be a valid http/https URL.", + "urlRequired": "Base URL is required.", + "urlRequiredForDetect": "Base URL is required to detect models.", + "validation": { + "apiTypeRequired": "API type is required.", + "idConflict": "Provider ID conflicts with a built-in provider.", + "idKebabCase": "Provider ID must be kebab-case.", + "idRequired": "Provider ID is required.", + "modelId": "Each model must have a model ID.", + "modelRequired": "At least one model is required.", + "urlProtocol": "Base URL must use http or https.", + "urlRequired": "Base URL is required.", + "urlValid": "Base URL must be a valid URL." + } + }, + "quickChat": { + "errorAttachmentsBeforeSession": "Cannot send attachments before chat session is ready", + "errorAttachmentsWhileStreaming": "Cannot send attachments while a response is streaming", + "errorGettingResponse": "Failed to get response", + "errorInitializingChat": "Failed to initialize chat", + "errorRefreshingSessions": "Failed to refresh chat sessions", + "errorStartingNewChat": "Failed to start a new chat" + }, + "reliability": { + "bouncedEntered": "{{bounced}} bounced / {{entered}} entered (last {{days}}d)", + "countingSince": "Counting since {{date}}", + "details": "Details", + "duration": { + "heading": "Duration", + "moreStats": "More stats", + "p50": "P50", + "p50Raw": "P50 raw: {{value}} ms", + "p95": "P95", + "p95Raw": "P95 raw: {{value}} ms", + "reason": "Reason: {{reason}}", + "sampleCount_one": "Sample count: {{count}}", + "sampleCount_other": "Sample count: {{count}}", + "samples_one": "Samples: {{count}}", + "samples_other": "Samples: {{count}}" + }, + "failureRate": "Failure rate: {{rate}}", + "heading": "Reliability", + "hideEmptyDays": "Hide empty days", + "inReviewFlow": "In-review flow", + "insufficientData": "Insufficient data — {{reason}}", + "mergeAttempts": { + "heading": "Merge attempts", + "histogramTotal_one": "Histogram total: {{count}}", + "histogramTotal_other": "Histogram total: {{count}}", + "max": "Max", + "mean": "Mean", + "moreStats": "More stats", + "reason": "Reason: {{reason}}", + "tasksCounted_one": "Tasks counted: {{count}}", + "tasksCounted_other": "Tasks counted: {{count}}" + }, + "reason": "Reason: {{reason}}", + "resetBaseline": "Reset baseline: {{date}}", + "resetModal": { + "confirm": "Confirm reset", + "description": "This sets a new baseline for reliability statistics. Historical events older than the reset time are excluded from counts but are not deleted.", + "failedError": "Failed to reset reliability stats", + "title": "Reset reliability stats?" + }, + "resetStats": "Reset stats", + "showEmptyDays": "Show empty days", + "successRateLabel": "In-review success rate (last 7d)", + "table": { + "bounced": "Bounced", + "date": "Date", + "entered": "Entered" + }, + "window": "Window: {{start}} → {{end}}" + }, + "research": { + "actionFailed": "Action failed", + "addCredentialsHint": "Add provider credentials in Authentication settings.", + "alwaysOn": "Always on", + "attachExport": "Attach markdown export artifact", + "createRun": "Create Run", + "createRunFailed": "Failed to create run", + "createTask": "Create Task", + "createTaskButton": "Create Task", + "createTaskTitle": "Create task from finding", + "defaultFindingHeading": "Research finding", + "descriptionLabel": "Description", + "disabled": "Research is disabled for this project.", + "enableResearchHint": "Enable project research settings to create runs.", + "enrichTask": "Enrich Task", + "enrichTaskButton": "Enrich Task", + "enrichTaskTitle": "Enrich existing task", + "enterTaskId": "Enter task ID", + "exportFailed": "Export failed", + "exportHtml": "Export HTML", + "exportJson": "Export JSON", + "exportMd": "Export MD", + "exportedFile": "Exported {{filename}}", + "findingLabel": "Finding:", + "loadingRuns": "Loading research runs…", + "loadingTasks": "Loading tasks…", + "missingApiKey": "Missing API key for {{provider}}.", + "noPreview": "No preview available.", + "noRunSelected": "No run selected", + "noRunsYet": "No research runs yet", + "noSourcesAvailable": "No enabled research sources are available for this project.", + "noSummaryYet": "No summary yet.", + "openAuthSettings": "Open Authentication Settings", + "openResearchSettings": "Open Research Settings", + "priorityHigh": "High", + "priorityLabel": "Priority", + "priorityLow": "Low", + "priorityNormal": "Normal", + "priorityUrgent": "Urgent", + "providersLabel": "Providers", + "queryLabel": "Query", + "runCancelled": "Run cancelled", + "runCreated": "Research run created", + "runHistory": "Run history", + "runLabel": "Run:", + "runRetried": "Run retried", + "searchRunsPlaceholder": "Search runs", + "selectRunToViewDetails": "Select a run to view details.", + "statCompleted": "Completed", + "statFailed": "Failed", + "statRunning": "Running", + "subtitle": "Cited search and synthesis runs: gather sources, fetch content, and synthesize findings.", + "targetTaskLabel": "Target task", + "taskCreatedFromResearch": "Task created from research", + "taskEnrichedFromResearch": "Task enriched from research", + "title": "Research", + "titleLabel": "Title", + "unavailable": "Research is unavailable for this project.", + "viewLabel": "Research view" + }, + "routine": { + "andMore_one": "…and {{count}} more", + "andMore_other": "…and {{count}} more", + "delete": "Delete", + "deleteMessage": "Delete routine {{name}}? This cannot be undone.", + "deleteName": "Delete {{name}}", + "deleteTitle": "Delete Routine", + "disable": "Disable", + "disableName": "Disable {{name}}", + "edit": "Edit", + "editName": "Edit {{name}}", + "enable": "Enable", + "enableName": "Enable {{name}}", + "resultFailed": "Failed", + "resultSuccess": "Success", + "runHistory_one": "Run History ({{count}})", + "runHistory_other": "Run History ({{count}})", + "runNameNow": "Run {{name}} now", + "runNow": "Run now", + "running": "Running…", + "stepCount_one": "{{count}} step", + "stepCount_other": "{{count}} steps" + }, + "routing": { + "cannotChangeWhileActive": "Node override cannot be changed while the task is active.", + "clearOverride": "Clear override", + "effectiveNode": "Effective node", + "errorLoadingNodes": "Failed to load nodes", + "errorUpdatingOverride": "Failed to update node override", + "intro": "View the effective execution node and control per-task node override.", + "localNoConfiguration": "Local (no routing configured)", + "lockedWhileActive": "Routing is locked while this task is active. Node override cannot be changed until the task is no longer active.", + "nodeUnavailable": "node unavailable or unknown", + "overrideCleared": "Node override cleared", + "overrideSection": "Node Override", + "overrideSetTo": "Override set to", + "overrideUpdated": "Node override updated", + "selectLabel": "Select execution node", + "source": { + "noRouting": "No routing", + "override": "Per-task override", + "projectDefault": "Project default" + }, + "summarySection": "Routing Summary", + "title": "Task Routing", + "unavailablePolicy": "Unavailable-node policy", + "unhealthy": "Unhealthy", + "useProjectDefault": "Use project default" + }, + "schedule": { + "actionAiPrompt": "AI Prompt", + "actionCommand": "Command", + "actionCreateTask": "Create Task", + "actionModeAdvanced": "Multi-Step", + "actionModeAdvancedHint": "Run multiple actions sequentially", + "actionModeAriaLabel": "Action mode", + "actionModeLabel": "Action Mode", + "actionModeSimple": "Simple", + "actionModeSimpleHint": "Run one command, prompt, or task creation action", + "actionTypeAiPrompt": "AI Prompt", + "actionTypeAriaLabel": "Action type", + "actionTypeCommand": "Command", + "actionTypeCreateTask": "Create Task", + "actionTypeLabel": "Action Type", + "addAiPromptStep": "Add AI Prompt Step", + "addCommandStep": "Add Command Step", + "addCreateTaskStep": "Add Create Task Step", + "advancedMode": "Multi-Step", + "advancedModeHelp": "Run multiple steps sequentially (commands and AI prompts)", + "aiPromptType": "AI Prompt", + "andMore_one": "…and {{count}} more", + "andMore_other": "…and {{count}} more", + "apiEndpointHint": "API endpoint path that triggers this routine", + "apiEndpointLabel": "API Endpoint", + "apiEndpointPlaceholder": "/api/routine/my-routine", + "automationCount_one": "{{count}} automation{{plural}}", + "automationCount_other": "{{count}} automation{{plural}}", + "cancelButton": "Cancel", + "catchUpPolicyHint": "What to do when a scheduled run is missed", + "catchUpPolicyLabel": "Catch-up Policy", + "catchUpPolicyRunAll": "Run all missed runs", + "catchUpPolicyRunOne": "Run the most recent missed run", + "catchUpPolicySkip": "Skip missed runs", + "columnTodo": "To Do", + "columnTriage": "Triage", + "command": "Command", + "commandHelp": "Shell command to execute. Runs with your user permissions.", + "commandHint": "Shell command to execute.", + "commandLabel": "Command", + "commandPlaceholder": "e.g. npx runfusion.ai backup --create", + "commandRequired": "Command is required", + "commandType": "Command", + "continueOnFailure": "Continue on failure", + "continueOnFailureHelp": "If checked, the next step will run even if this one fails", + "continuesOnFailure": "Continues on failure", + "createButton": "Create Schedule", + "createError": "Failed to create routine", + "createFirst": "Create your first automation", + "createNew": "Create new automation", + "createRoutine": "Create Routine", + "createTaskType": "Create Task", + "cronAutoFilled": "Auto-filled from preset: {{cron}}", + "cronAutoFilledHint": "Auto-filled from preset: {{expression}}", + "cronCustomHint": "min hour day month weekday", + "cronExpressionLabel": "Cron Expression", + "cronHelp": "min hour day month weekday — ", + "cronInvalid": "Invalid cron format — expected 5 fields (e.g. '0 */6 * * *')", + "cronLabel": "Cron Expression", + "cronPresetCustom": "Custom cron expression", + "cronPresetDaily": "Every day (midnight)", + "cronPresetHourly": "Every hour", + "cronPresetMonthly": "Every month (1st)", + "cronPresetWeekly": "Every week (Monday)", + "cronRequired": "Cron expression is required for custom schedules", + "delete": "Delete", + "deleteError": "Failed to delete routine", + "deleteMessage": "Delete schedule {{name}}? This cannot be undone.", + "deleteName": "Delete {{name}}", + "deleteStep": "Delete {{name}}", + "deleteTitle": "Delete Schedule", + "descriptionLabel": "Description (optional)", + "descriptionPlaceholder": "What does this routine do?", + "disable": "Disable", + "disableName": "Disable {{name}}", + "edit": "Edit", + "editName": "Edit {{name}}", + "editRoutineHeading": "Edit Routine", + "editStep": "Edit {{name}}", + "editTitle": "Edit Schedule", + "emptyStateDescription": "Create an automation with a schedule, webhook, API, or manual trigger.", + "enable": "Enable", + "enableName": "Enable {{name}}", + "enabledHelp": "When disabled, the schedule will not run automatically", + "enabledHint": "When disabled, the routine will not run automatically", + "enabledLabel": "Enabled", + "errorApiEndpointRequired": "API endpoint is required", + "errorCommandRequired": "Command is required", + "errorCronInvalid": "Invalid cron format — expected 5 fields (e.g. '0 */6 * * *')", + "errorCronRequired": "Cron expression is required", + "errorLoadModels": "Failed to load models", + "errorModelIncomplete": "Both model provider and model ID must be set, or both must be empty", + "errorNameRequired": "Name is required", + "errorPromptRequired": "Prompt is required", + "errorScopeNoProject": "Project-specific entries require an active project.", + "errorStepCommandRequired": "Step {{n}}: Command is required", + "errorStepNameRequired": "Step {{n}}: Name is required", + "errorStepPromptRequired": "Step {{n}}: Prompt is required", + "errorStepTaskDescRequired": "Step {{n}}: Task description is required", + "errorStepsEditing": "Please save or cancel all step edits before saving the routine", + "errorStepsRequired": "At least one step is required", + "errorTaskDescriptionRequired": "Task description is required", + "errorTimeoutMin": "Timeout must be at least 1 second (1000ms)", + "errorWebhookPathRequired": "Webhook path is required", + "executionPolicyHint": "How to handle concurrent executions of this routine", + "executionPolicyLabel": "Execution Policy", + "executionPolicyParallel": "Allow concurrent runs", + "executionPolicyQueue": "Queue after current (one at a time)", + "executionPolicyReject": "Reject new runs while running", + "executorModelDropdownLabel": "Executor Model", + "executorModelHelp": "AI model used to execute the created task. Uses default if not selected.", + "executorModelLabel": "Executor Model (optional)", + "executorModelOptional": "Executor Model (optional)", + "executorModelPlaceholder": "Use default", + "frequencyLabel": "Frequency", + "global": "Global", + "globalScope": "Global", + "globalScopeTitle": "Global scope", + "globalScoped": "This schedule will be created at global scope.", + "loadRoutinesError": "Failed to load routines", + "manualTriggerInfo": "This routine will be triggered manually via the dashboard or API.", + "modeAriaLabel": "Execution mode", + "modeLabel": "Execution Mode", + "model": "Model", + "modelConsistency": "Both model provider and model ID must be set, or both must be empty", + "modelDropdownLabel": "Model", + "modelHelp": "AI model for this prompt. Uses default if not selected.", + "modelLabel": "Model (optional)", + "modelOptional": "Model (optional)", + "modelPlaceholder": "Use default", + "modelProviderRequired": "Both model provider and model ID must be set together", + "moveDown": "Move down", + "moveStepDown": "Move {{name}} down", + "moveStepUp": "Move {{name}} up", + "moveUp": "Move up", + "nameLabel": "Name", + "namePlaceholder": "e.g. Daily standup reminder", + "nameRequired": "Name is required", + "newAutomation": "New Automation", + "newRoutineHeading": "New Routine", + "newTitle": "New Schedule", + "noActiveProject": "No active project. Schedules will be created at global scope.", + "noAutomations": "No automations yet", + "noStepsEmpty": "No steps added yet. Add a command or AI prompt step to get started.", + "project": "Project", + "projectRequired": "Project-specific entries require an active project.", + "projectScope": "Project", + "projectScopeDisabled": "Select a project to enable project scope", + "projectScopeTitle": "Project scope", + "projectScoped": "This schedule will be scoped to the current project.", + "prompt": "Prompt", + "promptHelp": "AI prompt to execute. Provide clear instructions for the task.", + "promptHint": "AI prompt to execute.", + "promptLabel": "Prompt", + "promptPlaceholder": "e.g. Summarize recent activity and create action items", + "promptRequired": "Prompt is required", + "resultFailed": "Failed", + "resultSuccess": "Success", + "routineCreated": "Routine created", + "routineDeleted": "Deleted \"{{name}}\"", + "routineDisabled": "\"{{name}}\" disabled", + "routineEnabled": "\"{{name}}\" enabled", + "routineFailed": "\"{{name}}\" failed: {{error}}", + "routineSuccess": "\"{{name}}\" completed successfully", + "routineUpdated": "Routine updated", + "runError": "Failed to run routine", + "runHistory_one": "Run History ({{count}})", + "runHistory_other": "Run History ({{count}})", + "runNameNow": "Run {{name}} now", + "runNow": "Run now", + "running": "Running…", + "saveChanges": "Save Changes", + "saveStep": "Save Step", + "saving": "Saving…", + "scheduleLabel": "Schedule", + "scopeAriaLabel": "Routine scope", + "scopeGroup": "Scheduling scope", + "scopeHintGlobal": "This routine will be created at global scope.", + "scopeHintNoProject": "No active project. Routines will be created at global scope.", + "scopeHintProject": "This routine will be scoped to the current project.", + "scopeLabel": "Scope", + "scopeLocked": "Scope is locked to {{scope}} for existing schedules", + "scopeLockedTitle": "Scope is locked to {{scope}} for existing routines", + "selectProjectTitle": "Select a project to enable project scope", + "simpleMode": "Simple", + "simpleModeHelp": "Run a single shell command or AI prompt", + "stepCommandRequired": "Step {{index}}: Command is required", + "stepCount_one": "{{count}} step", + "stepCount_other": "{{count}} steps", + "stepName": "Step Name", + "stepNamePlaceholder": "e.g. Run tests", + "stepNameRequired": "Step {{index}}: Name is required", + "stepPromptRequired": "Step {{index}}: Prompt is required", + "stepType": "Step Type", + "steps": "Steps", + "stepsEditing": "Please save or cancel all step edits before saving the schedule", + "stepsRequired": "At least one step is required", + "targetColumn": "Target Column", + "targetColumnHelp": "Column where the new task will be created", + "targetColumnLabel": "Target Column", + "taskColumnLabel": "Target Column", + "taskColumnTodo": "To Do", + "taskColumnTriage": "Triage", + "taskDescription": "Task Description *", + "taskDescriptionHelp": "Describes the task that will be created.", + "taskDescriptionHint": "Describes the task that will be created.", + "taskDescriptionLabel": "Task Description", + "taskDescriptionPlaceholder": "e.g. Check npm dependencies for security vulnerabilities", + "taskDescriptionRequired": "Task description is required", + "taskTitleHelp": "Leave blank to auto-summarize from description", + "taskTitleLabel": "Task Title (optional)", + "taskTitleOptional": "Task Title (optional)", + "taskTitlePlaceholder": "e.g. Review weekly dependencies", + "timeoutHelp": "Maximum execution time in milliseconds (default 300000 = 5 min)", + "timeoutHint": "Maximum execution time in milliseconds.", + "timeoutLabel": "Timeout (ms)", + "timeoutMinimum": "Timeout must be at least 1 second (1000ms)", + "timeoutOptional": "Timeout (ms, optional)", + "timeoutPlaceholder": "Override schedule timeout", + "title": "Automations", + "todoColumn": "To Do", + "toggleError": "Failed to toggle routine", + "triageColumn": "Triage", + "triggerApi": "API", + "triggerCron": "Cron", + "triggerManual": "Manual", + "triggerTypeAriaLabel": "Trigger type", + "triggerTypeLabel": "Trigger Type", + "triggerWebhook": "Webhook", + "typeCustom": "Custom cron expression", + "typeDaily": "Every day (midnight)", + "typeEvery12Hours": "Every 12 hours", + "typeEvery15Min": "Every 15 minutes", + "typeEvery2Hours": "Every 2 hours", + "typeEvery30Min": "Every 30 minutes", + "typeEvery6Hours": "Every 6 hours", + "typeHourly": "Every hour", + "typeMonthly": "Every month (1st)", + "typeWeekdays": "Weekdays at 9 AM (Mon-Fri)", + "typeWeekly": "Every week (Monday)", + "unknownError": "Unknown error", + "updateError": "Failed to update routine", + "useDefault": "Use default", + "webhookPathHint": "URL path for the webhook endpoint", + "webhookPathLabel": "Webhook Path", + "webhookPathPlaceholder": "/trigger/my-routine", + "webhookSecretHint": "HMAC secret for signature verification. Leave empty for unauthenticated webhooks.", + "webhookSecretLabel": "Webhook Secret (optional)", + "webhookSecretPlaceholder": "Optional — leave empty for unauthenticated webhooks" + }, + "scriptsModal": { + "addScript": "Add Script", + "addScriptsHint": "Add scripts to quickly run common commands from the dashboard.", + "cancelDelete": "Cancel delete", + "command": "Command", + "commandPlaceholder": "e.g., npm run build", + "confirmDelete": "Confirm delete", + "confirmDeleteNamed": "Confirm delete {{name}}", + "deleteScriptNamed": "Delete {{name}}", + "editScriptNamed": "Edit {{name}}", + "failedToDelete": "Failed to delete script", + "failedToLoadScripts": "Failed to load scripts", + "failedToSave": "Failed to save script", + "loadingScripts": "Loading scripts...", + "nameErrorMsg": "Name must contain only letters, numbers, hyphens, and underscores (no spaces)", + "nameHint": "Letters, numbers, hyphens, and underscores only", + "noScriptsDefined": "No scripts defined", + "noScriptsYet": "No scripts defined yet.", + "runScript": "Run script", + "runScriptNamed": "Run {{name}}", + "saving": "Saving...", + "scriptAlreadyExists": "A script with this name already exists", + "scriptCommandRequired": "Script command is required", + "scriptCount_one": "{{count}} script", + "scriptCount_other": "{{count}} scripts", + "scriptCreated": "Script created", + "scriptDeleted": "Script deleted", + "scriptName": "Script Name", + "scriptNamePlaceholder": "e.g., build, test, lint", + "scriptNameRequired": "Script name is required", + "scriptUpdated": "Script updated", + "title": "Scripts" + }, + "secrets": { + "accessPolicyAuto": "auto", + "accessPolicyDeny": "deny", + "accessPolicyLabel": "Access policy", + "accessPolicyPrompt": "prompt", + "addSecret": "Add Secret", + "addSecretModalTitle": "Add secret", + "cancelBtn": "Cancel", + "cancelDelete": "Cancel", + "clearSyncPassphrase": "Clear", + "closeAriaLabel": "Close", + "confirmClearSyncPassphrase": "Clear the cross-node sync passphrase? Existing sync pairs will stop working until you set a new passphrase.", + "confirmDelete": "Confirm", + "confirmPassphraseLabel": "Confirm passphrase", + "copied": "Copied", + "copyAriaLabel": "Copy", + "createBtn": "Create", + "deleteAriaLabel": "Delete", + "descriptionLabel": "Description", + "editAriaLabel": "Edit", + "editSecretModalTitle": "Edit secret", + "empty": "No secrets found.", + "envExportableChip": "env exportable", + "envKeyLabel": "Env key", + "errorClearSyncPassphrase": "Failed to clear sync passphrase: {{error}}", + "errorLoadSyncStatus": "Failed to load sync passphrase status: {{error}}", + "errorSaveSyncPassphrase": "Failed to save sync passphrase: {{error}}", + "exportToEnvLabel": "Export to env", + "hideAriaLabel": "Hide", + "hideValueAriaLabel": "Hide value", + "keyLabel": "Key", + "loading": "Loading…", + "neverRead": "Never read", + "passphraseLabel": "Passphrase", + "passphraseMustMatch": "Passphrases must match.", + "refresh": "Refresh", + "revealAriaLabel": "Reveal", + "revealed": "Revealed", + "rotateSyncPassphrase": "Rotate", + "rotateSyncPassphraseModalTitle": "Rotate sync passphrase", + "saveBtn": "Save", + "scopeGlobal": "Global", + "scopeLabel": "Scope", + "scopeProject": "Project", + "setPassphrase": "Set passphrase", + "setSyncPassphraseModalTitle": "Set sync passphrase", + "showValueAriaLabel": "Show value", + "syncConfigured": "Configured", + "syncNotConfigured": "Not configured", + "syncPassphraseCleared": "Sync passphrase cleared", + "syncPassphraseDescription": "Shared passphrase used to wrap cross-node secret bundles. Both nodes in a sync pair must share the same value. Stored locally only; never transmitted.", + "syncPassphraseRotated": "Sync passphrase rotated", + "syncPassphraseSet": "Sync passphrase set", + "syncPassphraseTitle": "Cross-Node Sync Passphrase", + "title": "Secrets", + "valueLabel": "Value" + }, + "sessionBanner": { + "dismissAll": "Dismiss all", + "dismissItem": "Dismiss {{title}}", + "failed": "Failed", + "headerAwaitingAndErrorPlural": "{{awaitingCount}} AI sessions need your input, {{errorCount}} failed", + "headerAwaitingAndErrorSingular": "{{awaitingCount}} AI session needs your input, {{errorCount}} failed", + "headerAwaitingPlural_one": "", + "headerAwaitingPlural_other": "", + "headerAwaitingSingular_one": "", + "headerAwaitingSingular_other": "", + "headerErrorPlural_one": "", + "headerErrorPlural_other": "", + "headerErrorSingular_one": "", + "headerErrorSingular_other": "", + "regionLabel": "AI sessions needing input or failed", + "resume": "Resume", + "retry": "Retry" + }, + "settings": { + "actions": { + "cancel": "Cancel", + "save": "Save" + }, + "appearance": { + "language": "Language", + "languageAuto": "Auto", + "languageAutoHint": "Follow the browser language", + "title": "Appearance" + }, + "auth": { + "apiKeyCleared": "API key cleared", + "apiKeySaved": "API key saved", + "authCodeAlreadySubmitted": "That authorization code was already submitted. Waiting for login…", + "authCodeReceived": "Authorization code received. Finishing login…", + "clearKey": "Clear", + "continueToLogin": "Continue to login", + "copiedCodeToClipboard": "Copied code to clipboard", + "copyCode": "Copy code", + "enterCodeOnGitHub": "Enter this code on GitHub", + "failedToCopyCode": "Failed to copy code — copy it manually from the box above", + "groupAuthenticated": "Authenticated", + "groupAvailable": "Available", + "hint": "Authentication changes take effect immediately — no need to save.", + "loadingStatus": "Loading authentication status…", + "loggedOut": "Logged out", + "loggingOut": "Logging out…", + "login": "Login", + "loginAlreadyInProgress": "Login already in progress. You can cancel it and retry.", + "loginCancelled": "Login cancelled", + "loginDidNotComplete": "Login did not complete. Please try again.", + "loginSuccessful": "Login successful", + "logout": "Logout", + "manualPasteMessage": "After you sign in with {{name}}, the browser will try to redirect to a localhost address that this dashboard can't reach. The redirect tab will look like it failed. Before that happens, copy the full URL from the browser address bar — you'll paste it back here to finish login. Continue?", + "manualPasteTitle": "Heads up — manual paste-back required", + "noProviders": "No providers available", + "openGitHub": "Open GitHub", + "pasteRedirectUrlFirst": "Paste the full redirect URL or authorization code first.", + "reopenOnboarding": "Reopen onboarding guide", + "reopenOnboardingHint": "Re-run the setup wizard to review or update your AI provider and model configuration.", + "savingKey": "Saving…", + "signInHint": "Sign in to at least one provider to get started with AI models.", + "statusActive": "✓ Active", + "statusNotConnected": "✗ Not connected", + "title": "Authentication", + "waitingForLogin": "Waiting for login…" + }, + "backups": { + "backupCreated": "Backup created successfully", + "backupNow": "Backup Now", + "creating": "Creating…" + }, + "closeModal": "Close conflict modal", + "conflictModalTitle": "Resolve Settings Conflicts", + "footer": { + "help": "Help", + "version": "Version {{version}}" + }, + "general": { + "learnMore": "Learn more", + "settingsSaved": "Settings saved", + "upToDate": "You're up to date ✓", + "updateAvailablePrefix": "v{{version}} available" + }, + "header": { + "discord": "Discord" + }, + "importExport": { + "confirmImport": "Confirm Import", + "exportBtn": "Export", + "exportTitle": "Export settings to JSON file", + "importBtn": "Import", + "importTitle": "Import Settings", + "importing": "Importing…", + "loadingFile": "Loading…", + "reviewPrompt": "Review the settings to be imported:" + }, + "jsonPlaceholder": "Enter JSON value...", + "keepLocal": "Keep Local", + "keepRemote": "Keep Remote", + "loading": "Loading…", + "memory": { + "compactSelectedFile": "Compact Selected File", + "compacting": "Compacting…", + "dreamCompleted": "Dream processing completed", + "dreamNow": "Dream Now", + "installQmd": "Install qmd", + "installing": "Installing…", + "memoryCompacted": "Memory file compacted", + "memorySaved": "Memory saved", + "saveMemory": "Save Memory", + "testRetrieval": "Test Retrieval", + "testing": "Testing…" + }, + "mergeManually": "Merge Manually", + "mobileNav": { + "label": "Settings Section" + }, + "models": { + "deletePresetMessage": "Preset \"{{name}}\" is used in auto-selection. Delete it anyway?", + "deletePresetTitle": "Delete Preset", + "loadingModels": "Loading available models…", + "noModels": "No models available. Configure authentication first.", + "presetNameRequired": "Preset name is required", + "savePreset": "Save preset" + }, + "nav": { + "aria": { + "global": "Global setting", + "project": "Project setting" + }, + "tooltip": { + "global": "Shared across all projects", + "project": "Specific to this project" + } + }, + "notifications": { + "sending": "Sending…", + "testMessageInbox": "Test message inbox", + "testNotification": "Test notification", + "testRoomReply": "Test room reply" + }, + "remote": { + "cloudflaredInstalled": "cloudflared installed successfully", + "persistentTokenRegenerated": "Persistent token regenerated", + "restarting": "Restarting…", + "shortLivedTokenGenerated": "Short-lived token generated", + "startFresh": "Start Fresh", + "startTunnel": "Start Tunnel", + "starting": "Starting…", + "stopTunnel": "Stop Tunnel", + "stopping": "Stopping…", + "tunnelRestarted": "Remote tunnel restarted", + "tunnelStarted": "Remote tunnel started", + "tunnelStopped": "Remote tunnel stopped", + "useExisting": "Use Existing" + }, + "resolveAllLocal": "Resolve All: Keep Local", + "resolveAllRemote": "Resolve All: Keep Remote", + "resolveFailed": "Failed to resolve conflicts", + "resolvedSuccess": "Settings conflicts resolved successfully", + "resolving": "Resolving...", + "scheduling": { + "selectCurrentDir": "Select current directory", + "selectIgnoredOverlapPath": "Select ignored overlap path" + }, + "scope": { + "globalBanner": "These settings are shared across all your Fusion projects.", + "projectBanner": "These settings only affect this project." + }, + "title": "Settings", + "worktrees": { + "awaitingApproval": "Awaiting approval — open Approvals to continue.", + "installWorktrunk": "Install worktrunk binary", + "openApprovals": "Open Approvals", + "selectWorktreesDir": "Select worktrees directory", + "tryAgain": "Try again" + } + }, + "setup": { + "addCustomProvider": "Add custom provider", + "addMoreProjectsHint": "You can add more projects anytime from the project overview.", + "advancedProviderSettings": "Advanced provider settings", + "advancedSettings": "Advanced settings", + "advancedSetupDetails": "Advanced setup details", + "aiSetupDescription": "Fusion uses AI models to plan, write, and review code for you. Connect an AI provider below to get started — you can use a hosted service or enter an API key.", + "allProvidersShown": "All currently available providers are already shown above.", + "allSet": "All Set!", + "apiKeyFormatHint": "Format: {{hint}}", + "apiKeyHint": "Key: {{keyHint}}", + "apiKeyRemoved": "API key removed", + "apiKeySaved": "✓ API key saved", + "apiKeySavedToast": "API key saved", + "ariaDismissRecommendations": "Dismiss recommendations", + "ariaSetupRecommendations": "Setup recommendations", + "authCodeAlreadySubmitted": "That authorization code was already submitted. Waiting for login…", + "authCodeReceived": "Authorization code received. Finishing login…", + "authDescription": "This dashboard requires an auth token to communicate with the Fusion daemon. Paste the token below to continue.", + "authToken": "Auth Token", + "authTokenOptional": "Auth token (optional)", + "back": "← Back", + "browserAuthToken": "Browser Auth Token", + "cancelLogin": "Cancel", + "childProcess": "Child-Process", + "childProcessDesc": "Isolated execution with crash containment.", + "childProcessLabel": "Child Process (Isolated)", + "chooseModel": "Choose Model", + "claudeCli": { + "active": "✓ Active", + "binaryNotFound": "`claude` binary not detected on PATH — install Claude CLI first.", + "binaryNotFoundPath": "`claude` not found on PATH", + "connected": "✓ Connected{{version}}", + "description": "Route AI calls through your locally-installed claude CLI. Uses your existing Claude subscription / quota instead of an API key.", + "details": "Details", + "detectedPrompt": "detected{{path}}. Click Enable to route AI calls through it.", + "disable": "Disable", + "disabledMessage": "Claude-CLI-routed models are hidden from the model picker.", + "disabledVerb": "Disabled", + "disabling": "Disabling…", + "enable": "Enable", + "enabledMessage": "Claude-CLI-routed models are now visible in the model picker.", + "enabledVerb": "Enabled", + "enabling": "Enabling…", + "extensionFailed": "Extension load failed: {{reason}}", + "name": "Anthropic — via Claude CLI", + "notConnected": "✗ Not connected", + "notInstalled": "✗ Not installed", + "probing": "Probing local CLI…", + "test": "Test", + "testing": "Testing…", + "validating": "Enabled. Validating…" + }, + "cloneGitHint": "Fusion will run git clone into the destination directory, then register that cloned folder.", + "cloneGitRepository": "Clone Git Repository", + "clonePathHint": "Select or type an absolute destination path. Fusion will clone into this directory.", + "clonePathPlaceholder": "/path/for/new-clone", + "closeWizard": "Close wizard", + "compactWarning": "⚠ Setup incomplete — AI and/or GitHub features will be limited.", + "completeSetup": "Complete these setup items to get the most out of Fusion.", + "connect": "Connect", + "connectAiProvider": "Connect AI Provider", + "connectAiProviderDesc": "Connect an AI provider to enable AI agents for task planning and code generation", + "connectAnyway": "Connect anyway", + "connectGitHub": "Connect GitHub", + "connectGitHubAnytime": "No worries if you're not ready — connect GitHub anytime from Settings → Authentication.", + "connectGitHubButton": "Connect GitHub", + "connectGitHubDesc": "Connect GitHub to import issues and track pull requests", + "connectOauthOptional": "Connect OAuth (optional)", + "connectRemoteServer": "Connect remote Fusion server", + "connectedProviders": "Connected providers", + "continueToLogin": "Continue to login", + "continueWithGhCli": "Continue with gh CLI auth →", + "continueWithoutGitHub": "Continue without GitHub →", + "copiedCodeToClipboard": "Copied code to clipboard", + "copyCode": "Copy code", + "couldNotReachServer": "Could not reach the server. Check your connection and try again.", + "createFirstTask": "Create First Task", + "createNewTask": "Create a New Task", + "createNewTaskSubtitle": "Describe what you need built and AI will work on it", + "createProject": "Create Project", + "createTasksAnytimeNote": "You can create tasks anytime from the board, or use", + "createTasksAnytimeNoteTerminal": "in the terminal.", + "creating": "Creating...", + "creatingTask": "Creating task…", + "cursorCli": { + "active": "✓ Active", + "binaryNotFound": "`cursor-agent` not found on PATH", + "connected": "Connected{{version}}", + "description": "Route AI calls through your local Cursor agent runtime.", + "detectedPrompt": "Detected. Click Enable to route calls through Cursor CLI.", + "disable": "Disable", + "disabling": "Disabling…", + "enable": "Enable", + "enabling": "Enabling…", + "notConnected": "✗ Not connected", + "probing": "Probing local CLI…", + "test": "Test", + "testing": "Testing…" + }, + "defaultModelDescription": "Pick a default model for AI tasks, or leave this blank to choose later. Models vary in speed, capability, and cost.", + "defaultModelLabel": "Default model", + "defaultModelOptional": "Default Model (Optional)", + "describeFirstTask": "Describe your first task", + "destinationDirectory": "Destination Directory", + "directoryPath": "Directory Path", + "disconnect": "Disconnect", + "dismissWarning": "Dismiss setup warning", + "enterApiKey": "Enter API key", + "enterCodeOnGitHub": "Enter this code on GitHub", + "executionMode": "Execution Mode", + "failedToCancelLogin": "Failed to cancel login", + "failedToClearApiKey": "Failed to clear API key", + "failedToCopyCode": "Failed to copy code — copy it manually from the box above", + "failedToCreateCustomProvider": "Failed to create custom provider", + "failedToSaveApiKey": "Failed to save API key", + "failedToSaveShellConnection": "Failed to save shell connection", + "failedToSubmitAuthCode": "Failed to submit authorization code", + "finishSetup": "Finish Setup", + "firstTaskDescription": "Create your first task to start the board and launch AI execution.", + "firstTaskPlaceholder": "Example: Build a login page with email and password", + "firstTaskReady": "Your first task is ready!", + "getApiKeyLink": "Get your API key →", + "getStarted": "Get Started", + "githubCliAlreadyAuth": "GitHub CLI is already authenticated — issue imports and pull request tracking work right now. You're all set; no further action needed.", + "githubCliAuthNote": "GitHub CLI is already authenticated, so imports and PR tracking work now. OAuth from the dashboard is optional and only controls dashboard-managed connect/disconnect.", + "githubCliAuthSuccess": "GitHub CLI is authenticated. Imports and pull request tracking are available. Connect OAuth in Settings → Authentication if you want dashboard-managed sign-in controls.", + "githubConnected": "GitHub is connected — issue imports and pull request tracking are available. You're all set; no further action needed.", + "githubConnectionDescription": "Connecting GitHub unlocks issue imports and pull request tracking. You can skip this — task creation works without it.", + "githubConnectionFailed": "Connection failed or timed out.", + "githubOauthConnected": "GitHub OAuth is connected. You can import issues and track pull requests.", + "githubOauthNotConnected": "GitHub OAuth isn't connected yet. You can set it up in Settings → Authentication, or continue now and connect later.", + "githubSkipped": "GitHub was skipped. You can connect anytime from Settings → Authentication.", + "goBackToStep": "Go back to {{label}}", + "goToDashboard": "Go to Dashboard", + "howDoIChooseModel": "How do I choose a model?", + "howDoIChooseModelBody": "Models vary in speed, capability, and cost. A good default is usually the latest model from your connected provider. You can always change this later in Settings.", + "howDoesLoginWork": "How does login work?", + "howDoesLoginWorkBody": "Clicking Login opens the provider's website in a new tab where you sign in. Once you authorize Fusion, this page will automatically detect the connection. Your credentials are never stored in Fusion.", + "importFromGitHub": "Import from GitHub", + "importFromGitHubSubtitle": "Turn GitHub issues into tasks you can track here", + "inProcess": "In-Process", + "inProcessDesc": "Lower overhead, shared memory. Best for most projects.", + "inProcessLabel": "In-Process (Default)", + "isolationMode": "Isolation Mode", + "keySavedInline": "✓ Key saved", + "loadingProviders": "Loading providers…", + "localNode": "Local node", + "loggedOut": "Logged out", + "loggingOut": "Logging out…", + "login": "Login", + "loginAlreadyInProgress": "Login already in progress. Cancel it to retry.", + "loginCancelled": "Login cancelled", + "loginDidNotComplete": "Login did not complete. Please try again.", + "loginFailed": "Login failed", + "loginFailedInline": "Login failed. Please try again.", + "loginSuccessful": "Login successful", + "loginTimedOut": "Login timed out. Please try again.", + "loginTimedOutInline": "Login timed out. Please try again.", + "logout": "Logout", + "logoutFailed": "Logout failed", + "manualPastebackTitle": "Heads up — manual paste-back required", + "nameHint": "Use letters, numbers, hyphens, and underscores only", + "namePlaceholder": "my-project", + "needHelp": "Need help?", + "next": "Next →", + "noAiProvider": "No AI provider connected", + "noAiProviderBody": "AI features like task planning and code generation won't be available until you connect one. You can set this up later in Settings.", + "noAiProviderConnected": "No AI provider connected", + "noAiProviderDesc": "AI agents won't be able to work on tasks until you connect a provider. Set one up in Settings → AI Setup.", + "noGithub": "GitHub not connected", + "noGithubDesc": "You won't be able to import issues from GitHub, but you can still create tasks manually.", + "noModelsAvailable": "No models available yet. Connect a provider above to see model options.", + "noProvidersConfigured": "No AI providers are configured. Please check your Fusion configuration.", + "noProvidersConnectedYet": "No providers connected yet", + "noQuickStartProviders": "No quick-start providers are available in this environment.", + "noTokenHint": "No token is stored. Use the auth prompt at the top of the wizard, or set one here.", + "onlyNeedOneProvider": "You only need one provider to get started.", + "openGitHub": "Open GitHub", + "openManager": "Open manager", + "optionalBadge": "Optional", + "pasteRedirectUrlFirst": "Paste the full redirect URL or authorization code first.", + "pasteTokenForBrowserPlaceholder": "Paste the auth token for this browser", + "pasteTokenPlaceholder": "Paste the daemon auth token", + "pathHint": "Enter the absolute path to your project directory", + "pathPlaceholder": "/path/to/your/project", + "pleaseEnterTaskDescription": "Please enter a task description.", + "profileName": "Profile name", + "projectDirectory": "Project Directory", + "projectMustBeSelected": "A project must be selected before you can create tasks or import from GitHub.", + "projectName": "Project Name", + "projectNameHintClone": "By default this follows the destination folder name unless you edit it.", + "projectNameHintExisting": "By default this follows the selected directory name unless you edit it.", + "projectNamePlaceholder": "my-project", + "projectPathHint": "Select or type the absolute path to your project", + "projectPathPlaceholder": "/path/to/your/project", + "projectRegisteredSuccess": "Your project has been registered successfully.", + "projectRequired": "A project is required before first-task actions are available.", + "projectSelected": "Project selected — task creation and imports are available.", + "projectSetupDescription": "Choose your first project before creating or importing tasks. You can register an existing local directory or clone a GitHub repository URL through the setup wizard.", + "providersConnectedSummary": "✓ {{connected}} of {{total}} provider(s) connected", + "providersSkippedSummary_one_one": "{{count}} provider skipped", + "providersSkippedSummary_one_other": "{{count}} provider skipped", + "providersSkippedSummary_other_one": "{{count}} providers skipped", + "providersSkippedSummary_other_other": "{{count}} providers skipped", + "quickStartProviders": "Quick start providers", + "readinessAiProviderConnected": "{{name}} connected — AI agents can work on tasks", + "readinessAiProviderLabel": "AI Provider", + "readinessAiProviderMissing": "Connect a provider in Settings → AI Setup", + "readinessAiProviderSkipped": "AI agents won't be available until you connect a provider", + "readinessAllConnected": "✓ All integrations connected", + "readinessDefaultModelLabel": "Default Model", + "readinessGitHubConnected": "Issues and PRs can be imported", + "readinessGitHubLabel": "GitHub", + "readinessGitHubMissing": "Connect to import issues as tasks", + "readinessGitHubSkipped": "You can connect anytime from Settings", + "readinessGitHubViaCli": "Connected via GitHub CLI — imports and PR tracking are available", + "readinessProjectConnected": "Project selected — task creation and imports are available", + "readinessProjectLabel": "Project", + "readinessProjectMissing": "Register a project to enable task creation and imports", + "readinessSummaryHeader": "Setup Summary", + "recommended": "Recommended", + "recommendedNextSteps": "Recommended Next Steps", + "registerProject": "Register Project", + "registering": "Registering...", + "remoteServerNote": "Your native shell needs an active remote profile before dashboard handoff can complete.", + "remoteServerProfileSaved": "Remote server profile saved", + "removeKey": "Remove Key", + "removingKey": "Removing…", + "replaceTokenPlaceholder": "Enter a new token to replace the stored one", + "repositoryUrl": "Repository URL", + "repositoryUrlPlaceholder": "https://github.com/owner/repo.git", + "requiresGitHubConnection": "Requires GitHub connection", + "researchRunsNote": "Research runs require provider credentials and an enabled Research View. After onboarding, verify these in Settings → Authentication and Settings → Experimental Features.", + "resetToken": "Reset token", + "retry": "Retry", + "reviewStep": "Review {{label}}", + "runtimeNode": "Runtime Node", + "saveKey": "Save", + "saveRemoteServer": "Save remote server", + "savedProfileButFailedToActivate": "Saved profile but failed to activate it", + "saving": "Saving…", + "savingKey": "Saving…", + "savingRemoteServer": "Saving…", + "selectDefaultModel": "Select a default model…", + "selectDefaultModelDesc": "Choose a default AI model for task execution", + "selectedModel": "Selected:", + "serverUrl": "Server URL", + "serverUrlPlaceholder": "https://your-fusion-host", + "setAuthToken": "Set Auth Token", + "setToken": "Set token", + "setTokenContinue": "Set Token & Continue", + "setUpAi": "Set Up AI", + "setUpProject": "Set Up Project", + "setupComplete": "Setup complete! Head to the board to create your first task, or explore the dashboard to see what's available.", + "setupMode": "Setup Mode", + "setupWizardHint": "In the setup wizard, pick an existing directory or paste a GitHub clone URL.", + "skip": "Skip", + "skipForNow": "Skip for now", + "skipGitHub": "Skip GitHub →", + "skipOnboardingAriaLabel": "Skip onboarding", + "skipProviderHint": "Skip this step if you'd like — you can always add providers later from Settings.", + "skipSetup": "Skip setup →", + "statusConnected": "✓ Connected", + "statusConnecting": "⏳ Connecting…", + "statusConnectionFailed": "✗ Connection failed", + "statusNotConnected": "Not connected", + "statusRetry": "Retry", + "statusSkipped": "Skipped", + "stepAiSetup": "AI Setup", + "stepFirstTask": "First Task", + "stepGithub": "GitHub", + "stepProject": "Project", + "submitCode": "Submit code", + "submittingCode": "Submitting…", + "taskCreated": "Task created", + "taskCreatedHint": "Your task has been created and will appear on the board.", + "taskCreationFailed": "Something went wrong creating your task. Please try again.", + "taskErrorHint": "Your text has been preserved — fix the issue and try again.", + "titleAiSetup": "Set Up AI", + "titleAllSet": "All Set!", + "titleConnectGitHub": "Connect GitHub", + "titleCreateFirstTask": "Create Your First Task", + "titleSetUpProject": "Set Up Your Project", + "tokenEnvVar": "The token was set via the {{env}} environment variable when starting the dashboard.", + "tokenStoredHint": "A token is already stored in this browser. You can update or reset it below.", + "updateToken": "Update token", + "useExistingDirectory": "Use Existing Directory", + "viewTask": "View Task", + "waitingForGitHubAuth": "Waiting for GitHub authorization…", + "waitingForLogin": "Waiting for login…", + "waitingForOauthLogin": "Waiting for OAuth login…", + "welcomeToFusion": "Welcome to Fusion", + "whatAreAiProviders": "What are AI providers?", + "whatAreAiProvidersBody": "AI providers like OpenAI and Anthropic power the AI capabilities in Fusion. Connecting a provider lets Fusion's agents use AI models to help with your tasks.", + "whatDoesGitHubIntegrationDo": "What does GitHub integration do?", + "whatDoesGitHubIntegrationDoBody": "Without GitHub, you can still create and manage tasks manually. GitHub integration adds the ability to import issues as tasks, track pull request status alongside your work, and automatically link commits to tasks. Connect anytime from Settings → Authentication.", + "whatDoesProjectSetupDo": "What does project setup do?", + "whatDoesProjectSetupDoBody": "Project setup registers a workspace so Fusion knows where to read files, run commands, and track task changes.", + "whatHappensWhenCreateTask": "What happens when I create a task?", + "whatHappensWhenCreateTaskBody": "A task describes something you want done. Fusion's AI agents will read your description and work on implementing it. You can track progress on the board and review the results.", + "whatIsApiKey": "What is an API key?", + "whatIsApiKeyBody": "An API key is a secret token that authenticates Fusion with the provider. You can find your key in the provider's dashboard under API settings. Keys are stored securely on your machine.", + "withGitHub1": "Import issues as tasks", + "withGitHub2": "Sync pull request status", + "withGitHub3": "Link code changes to tasks", + "withGitHubHeading": "With GitHub (after connecting):", + "withoutGitHub1": "Create tasks manually", + "withoutGitHub2": "Describe work for AI agents", + "withoutGitHub3": "Track progress on the board", + "withoutGitHubHeading": "Without GitHub (available now):" + }, + "shell": { + "activePill": "Active", + "addConnection": "Add connection", + "addServer": "Add server", + "authTokenLabel": "Auth token (optional)", + "connectionManager": "Connection Manager", + "connectionManagerLabel": "Connection Manager", + "defaultProfileName": "Remote Server", + "deleteConfirmLabel": "Delete server confirmation", + "deleteConfirmMessage": "Delete {{name}}? This removes the saved profile.", + "deleteProfile": "Delete {{name}}", + "editProfile": "Edit {{name}}", + "modeLocal": "Local", + "modeRemote": "Remote", + "nameLabel": "Name", + "noServersSaved": "No remote servers saved yet.", + "scanQr": "Scan QR", + "serverUrlLabel": "Server URL", + "serverUrlProtocolError": "Server URL must use http or https", + "use": "Use", + "useProfile": "Use {{name}}" + }, + "skills": { + "addSkill": "Add a skill…", + "allSkillsSelected": "All skills selected", + "catalogSection": "Skills Catalog", + "catalogUnavailable": "Catalog is temporarily unavailable. Please try again later.", + "closeDetail": "Close skill detail", + "closeView": "Close skills view", + "disableSkill": "Disable {{name}}", + "disabled": "Skill disabled", + "discovered": "discovered", + "discoveredCount_one": "{{count}} discovered skills", + "discoveredCount_other": "{{count}} discovered skills", + "discoveredSection": "Discovered Skills", + "enableSkill": "Enable {{name}}", + "enabled": "Skill enabled", + "filesLabel": "Files", + "install": "Install", + "installError": "Failed to install skill", + "installFailed": "Failed to install {{name}}: {{message}}", + "installSkill": "Install {{name}}", + "installSuccess": "Installed {{name}}", + "installing": "Installing…", + "installsCount": "{{count}} installs", + "loadCatalogError": "Failed to load catalog", + "loadContentError": "Failed to load skill content", + "loadDiscoveredError": "Failed to load discovered skills", + "loading": "Loading skills…", + "loadingCatalog": "Loading catalog...", + "loadingContent": "Loading skill content...", + "loadingDiscovered": "Loading discovered skills...", + "noCatalogAvailable": "No skills available in the catalog.", + "noDiscovered": "No skills discovered in this project.", + "noMatchingDiscovered": "No discovered skills match your search.", + "noMatchingSearch": "No skills match your search.", + "noSkillMd": "(No SKILL.md found)", + "noSkillsDiscovered": "No skills discovered", + "removeSkill": "Remove {{name}}", + "searchLabel": "Search skills", + "searchPlaceholder": "Search skills...", + "title": "Skills", + "toggleError": "Failed to toggle skill", + "toggleFailed": "Failed to toggle skill: {{message}}", + "viewDetails": "View details for {{name}}" + }, + "specEditor": { + "edit": "Edit", + "empty": "(no specification)", + "feedbackPlaceholder": "e.g., 'Add more details about error handling', 'Split this into smaller steps', 'Include tests for the API endpoints'...", + "keyboardHint": "Press Ctrl+Enter (or Cmd+Enter) to save", + "placeholder": "Enter task specification in Markdown...", + "requestRevision": "Request AI Revision", + "requesting": "Requesting…", + "revisionHelp": "Provide feedback for the AI to improve this specification. The task will move to planning for replanning.", + "revisionTitle": "Ask AI to Revise", + "saving": "Saving…", + "view": "View" + }, + "stashRecovery": { + "applied": "Applied", + "apply": "Apply", + "applyFailed": "Apply failed", + "changedPathsLabel": "Changed paths", + "classificationLabel": "Classification", + "closeDiffDialog": "Close diff dialog", + "diffDialogLabel": "Diff for {{sha}}", + "diffHeader": "Diff for {{sha}}", + "diffTruncated": "Diff output truncated.", + "drop": "Drop", + "dropConfirm": "Drop", + "dropMessage": "This removes the stash entry permanently.", + "dropTitle": "Drop orphaned stash?", + "failedToLoadDiff": "Failed to load diff", + "failedToLoadOrphans": "Failed to load orphans", + "fileCount_one": "{{count}} files", + "fileCount_other": "{{count}} files", + "inspectDiff": "Inspect diff", + "loadingDiff": "Loading diff…", + "noDiffOutput": "No diff output available.", + "noOrphans": "No orphaned merger autostashes found.", + "orphanCount_one": "{{count}} orphans", + "orphanCount_other": "{{count}} orphans", + "shaLabel": "SHA", + "title": "Stash Recovery", + "unknownSource": "Unknown source" + }, + "stepType": { + "aiPrompt": "AI Prompt", + "aiPromptStepTitle": "AI Prompt step", + "command": "Command", + "commandStepTitle": "Command step", + "createTask": "Create Task", + "createTaskStepTitle": "Create Task step" + }, + "subtasks": { + "addSubtask": "Add subtask", + "baseBranchLabel": "Merge target / base branch (optional)", + "baseBranchPlaceholder": "main", + "branchAssignmentPerTask": "Per-task branches derived from planning branch", + "branchAssignmentShared": "Shared merge target — subtasks run on their own branches", + "branchModeAutoNew": "Create auto-named branch per task", + "branchModeCustomNew": "Create custom new branch", + "branchModeExisting": "Use existing branch", + "branchModeProjectDefault": "Use project/default branch", + "branchNameLabel": "Branch name", + "branchStrategyLabel": "Branch strategy", + "createTasks": "Create Tasks", + "creating": "Creating...", + "dependenciesLabel": "Dependencies", + "dependencyCycleError": "Dependencies contain a cycle. Remove circular references before creating tasks.", + "descriptionLabel": "Description", + "discardChangesMessage": "Close subtask breakdown? Unsaved changes will be lost.", + "discardChangesTitle": "Discard Changes", + "dragToReorder": "Drag to reorder", + "errorCreateTasks": "Failed to create tasks", + "errorRefreshSession": "Failed to refresh subtask session.", + "errorResumeSession": "Failed to resume session", + "errorRetryFailed": "Retry failed. Please try again.", + "errorSessionEncountered": "Session encountered an error", + "errorSessionFailed": "Session failed while contacting the AI.", + "errorSessionNoResult": "Subtask session is complete but has no result.", + "errorStartBreakdown": "Failed to start subtask breakdown", + "generatingSubtasks": "AI is generating subtasks...", + "groupedOnSharedBranch": "Grouped on shared branch", + "hideThinking": "Hide thinking", + "modalTitle": "Subtask Breakdown", + "moveDown": "Move down", + "moveSubtaskDownAriaLabel": "Move subtask down", + "moveSubtaskUpAriaLabel": "Move subtask up", + "moveUp": "Move up", + "noDepsFirstSubtask": "First subtask cannot have dependencies.", + "noPreviousSubtasks": "No previous subtasks available.", + "openGroupModal": "Open group modal", + "planningBranchModeLabel": "Planning branch mode", + "preparingBreakdown": "Preparing to break this task into subtasks.", + "reconnecting": "Reconnecting…", + "remove": "Remove", + "retry": "Retry", + "retrying": "Retrying...", + "reviewSubtasksHeading": "Review your subtasks", + "reviewSubtasksHint": "Edit titles, descriptions, sizes, and dependencies before creating all tasks at once.", + "sendToBackgroundAriaLabel": "Send to background", + "sendToBackgroundTitle": "Send to background", + "sessionActiveAnotherTab": "Session is active in another tab.", + "sessionActiveAnotherTabLive": "This session is active in another tab (live heartbeat)", + "sessionActiveAnotherTabTakeover": "This session is active in another tab", + "showThinking": "Show thinking", + "sizeLabel": "Size", + "takeControl": "Take Control", + "takingControl": "Taking control...", + "titleLabel": "Title", + "untitled": "Untitled" + }, + "syncLog": { + "entryCount_one": "{{count}} entry", + "entryCount_other": "{{count}} entries", + "filterAll": "All", + "filterAllNodes": "All Nodes", + "filterDirection": "Direction:", + "filterNode": "Node:", + "filterPull": "Pull", + "filterPush": "Push", + "loading": "Loading...", + "noHistory": "No sync history available", + "resultConflict": "Conflict", + "resultError": "Error", + "resultSuccess": "Success" + }, + "systemStats": { + "agentActive": "active", + "agentError": "error", + "agentIdle": "idle", + "agentRunning": "running", + "autoKillLabel": "Auto-kill vitest on memory pressure", + "autoRefresh": "Auto-refresh · 5s", + "closeAriaLabel": "Close", + "confirmKill": "Confirm Kill?", + "cpuFirstSamplePending": "First sample pending", + "cpuProcessUsage": "process usage", + "cpuProgressLabel": "App CPU usage: {{percent}}%", + "cpuProgressUnavailable": "App CPU usage unavailable: waiting for another sample", + "cpuSampling": "Sampling…", + "errorKillVitest": "Failed to kill vitest processes", + "errorLoadStats": "Failed to load system stats", + "errorLoadVitestSettings": "Failed to load vitest settings", + "errorSaveVitestSettings": "Failed to save vitest settings", + "footerRefreshFailed": "Latest refresh failed: {{error}}", + "killThresholdInputAriaLabel": "Kill threshold (%)", + "killThresholdLabel": "Kill threshold (%)", + "killThresholdSliderAriaLabel": "Kill threshold slider (%)", + "killVitest": "Kill Vitest Processes", + "killedProcesses_one": "Killed {{count}} processes", + "killedProcesses_other": "Killed {{count}} processes", + "lastAutoKill": "Last auto-kill: {{time}}", + "loading": "Loading system stats…", + "notYet": "Not yet", + "refreshAriaLabel": "Refresh system stats", + "refreshTitle": "Refresh", + "rowAppCpu": "App CPU", + "rowArrayBuffers": "Array Buffers", + "rowCores": "Cores", + "rowExternal": "External", + "rowHeapLimit": "Heap Limit", + "rowHeapLimitDetail": "V8 limit", + "rowHeapUsed": "Heap Used", + "rowHeapUsedDetail": "of {{total}}", + "rowLoadAvg": "Load Avg", + "rowMemoryFree": "Memory Free", + "rowMemoryUsed": "Memory Used", + "rowNode": "Node", + "rowPid": "PID", + "rowPlatform": "Platform", + "rowRss": "RSS", + "rowTotal": "Total", + "sectionAgents": "Agents", + "sectionAgentsAriaLabel": "Agent stats", + "sectionCpu": "CPU & Load", + "sectionCpuAriaLabel": "CPU and load stats", + "sectionProcess": "Process", + "sectionProcessAriaLabel": "Process stats", + "sectionSystem": "System", + "sectionSystemMemAriaLabel": "System memory stats", + "sectionTasks": "Tasks", + "sectionTasksAriaLabel": "Task stats", + "sectionVitest": "Vitest Controls", + "sectionVitestAriaLabel": "Vitest controls", + "systemMemProgressLabel": "System memory used: {{percent}}% ({{used}} of {{total}})", + "systemMemUnavailable": "System memory usage unavailable", + "title": "System Stats", + "updatedAt": "Updated {{time}}", + "vitestProcesses": "Vitest Processes", + "waitingFirstUpdate": "Waiting for first update" + }, + "taskChanges": { + "attributionFailed": "Landed-files set may include foreign commits (attribution unavailable).", + "disableWordWrap": "Disable word wrap", + "enableWordWrap": "Enable word wrap", + "error": "Error loading changes: {{error}}", + "expandDiff": "Expand to full-screen diff view", + "expandDiffView": "Expand diff view", + "filesChangedHeading_one": "Files Changed ({{count}})", + "filesChangedHeading_other": "Files Changed ({{count}})", + "loadError": "Failed to load task changes", + "loading": "Loading changes...", + "mergedAt": "Merged {{date}}", + "nextFile": "Next file", + "noExecutionModifications": "The agent did not modify any files during execution.", + "noFilesModified": "No files modified.", + "noMergeCommit": "No merge commit was recorded for this task.", + "noMergeFileChanges": "No file changes were recorded in the merge commit.", + "noOpShortCircuit": "Verified short-circuit — work was already on main (rebase walked foreign commits).", + "noWorktree": "No worktree available for this task.", + "noWorktreeHint": "Changes will be shown once the task is in progress.", + "previousFile": "Previous file", + "summaryHint": "Final commit summary: {{files}} file{{plural}} changed, +{{additions}} additions, -{{deletions}} deletions. Counts only the recorded merge/squash commit, not the full task lineage.", + "toggleWordWrap": "Toggle word wrap", + "unavailable": "Detailed file changes unavailable." + }, + "taskDetail": { + "actions": { + "menuBtn": "Actions" + }, + "ageStaleness": { + "active": "Active", + "age": "Age", + "column": "Column", + "critical": "Critical", + "observed": "Observed", + "paused": "Paused", + "title": "Task age staleness", + "warning": "Warning" + }, + "agent": { + "assignBtn": "Assign Agent", + "assignFailed": "Failed to assign agent: {{error}}", + "assignedUpdated": "Assigned agent updated", + "label": "Agent", + "loadFailed": "Failed to load agents: {{error}}", + "loadingAgents": "Loading agents...", + "noAgents": "No agents available", + "unassignFailed": "Failed to unassign agent: {{error}}", + "unassignTitle": "Unassign agent", + "unassigned": "Agent unassigned" + }, + "agentLink": "agent {{id}}", + "attachments": { + "attachBtn": "Attach Screenshot", + "attached": "Screenshot attached", + "deleteTitle": "Delete attachment", + "deleted": "Attachment deleted", + "heading": "Attachments", + "none": "(no attachments)", + "uploading": "Uploading…" + }, + "blockedByLink": "blocked by {{id}}", + "blocking": { + "heading": "Blocking", + "none": "(no downstream tasks blocked)", + "stale": "(stale)", + "staleTitle": "Stale blockedBy edge: self-healing clearStaleBlockedBy should clear this automatically" + }, + "branchBinding": { + "candidates": "Candidates:", + "copy": "This in-review task isn't currently attached to a fusion branch. If a live fusion branch still exists for it, you can reattach it here.", + "headline": "Branch needs reattachment", + "reattachBtn": "Reattach branch", + "reattached": "Reattached branch for {{id}} ({{branch}})", + "reattachedResult": "Reattached {{branch}} ({{count}} commits ahead of {{base}}).", + "reattachedResult_one": "Reattached {{branch}} ({{count}} commits ahead of {{base}}).", + "reattachedResult_other": "Reattached {{branch}} ({{count}} commits ahead of {{base}}).", + "reattaching": "Reattaching…", + "skipped": "Branch reattachment skipped for {{id}}: {{reason}}", + "skippedResult": "Reattachment skipped: {{reason}}" + }, + "cacheBreakdown": "(read {{read}} / write {{write}} / input {{input}})", + "cacheHitRatio": "Cache hit ratio:", + "cacheRead": "Cache read", + "cacheWrite": "Cache write", + "checkoutLink": "checkout {{id}}", + "delete": { + "actionClosed": "closed", + "actionDeleted": "deleted", + "actionLeft": "left", + "allowRecreation": "Allow re-creation later (operator unlock)", + "allowRecreationDesc": "Lets agents recreate this task ID without --force-resurrect. Leave unchecked to keep this task tombstoned.", + "archiveInstead": "Archive Instead", + "archiveUnlinkPrompt": "Archive anyway by unlinking these references first?", + "archivedAfterUnlink": "Archived {{id}} after unlinking lineage references", + "ariaLabel": "Delete task", + "btn": "Delete", + "closeIssue": "Close Issue", + "confirm": "Delete", + "deleteIssue": "Delete Issue", + "deleteLinkedIssueMessage": "Delete {{issueRef}} on GitHub, or leave it unchanged?", + "deleteLinkedIssueTitle": "Delete Linked GitHub Issue", + "deleteUnlinkDepsPrompt": "Delete anyway by removing these dependency references first?", + "deleteUnlinkLineagePrompt": "Delete anyway by unlinking these references first?", + "deletedAfterRemovingDeps": "Deleted {{id}} after removing dependency references", + "deletedAfterUnlinkLineage": "Deleted {{id}} after unlinking lineage references", + "deletedToast": "Deleted {{id}}{{suffix}}", + "forceDeleteTitle": "Force Delete Task", + "issueSuffix": "and {{action}} issue {{ref}}", + "leaveUnchanged": "Leave Unchanged", + "linkedIssueMessage": "Choose what to do with {{issueRef}} when deleting {{id}}.\n\nClose the issue?", + "linkedIssueTitle": "Linked GitHub Issue", + "message": "Delete {{id}}?", + "moreOptions": "More Options", + "title": "Delete Task" + }, + "deps": { + "addBtn": "Add Dependency", + "clearBlockerTitle": "Clear overlap blocker {{id}}", + "clearBtn": "Clear", + "clickToView": "Click to view {{id}}", + "heading": "Dependencies", + "loadFailed": "Failed to load dependency {{id}}", + "noAvailableTasks": "No available tasks", + "none": "(no dependencies)", + "overlapBlocker": "File scope overlap blocker:", + "removeTitle": "Remove dependency {{id}}", + "searchPlaceholder": "Search tasks…", + "stale": "(stale)" + }, + "description": { + "showLess": "Show less", + "showMore": "Show more" + }, + "duplicate": { + "btn": "Duplicate", + "message": "Duplicate {{id}}? This will create a new task in Triage with the same description and prompt.", + "success": "Duplicated {{id}} → {{newId}}", + "title": "Duplicate Task" + }, + "edit": { + "autosaveHint": "Changes autosave as you edit", + "autosaving": "Autosaving…", + "nodeOverrideLocked": "Execution node override is locked while a task is active/in progress.", + "saveFailed": "Save failed", + "saved": "Saved", + "saving": "Saving…", + "sourceExternalIdPlaceholder": "Issue identifier", + "sourceIssueHint": "Leave all fields empty to clear source issue metadata.", + "sourceIssueIdentifierNumeric": "Source issue identifier must be numeric for new metadata", + "sourceIssueLabel": "Source Issue", + "sourceIssueRequiredFields": "Source issue provider, repository, and issue identifier are required", + "sourceProviderPlaceholder": "Provider (e.g. github)", + "sourceRepositoryPlaceholder": "Repository (e.g. owner/repo)", + "sourceUrlPlaceholder": "Issue URL" + }, + "error": { + "taskFailed": "Task Failed" + }, + "executionAndTokenStats": "Execution & Token Stats", + "executionDetails": "Execution Details", + "executionMode": { + "ariaLabel": "Execution mode: {{mode}}", + "fast": "Fast", + "standard": "Standard", + "updated": "Execution mode updated to {{mode}}" + }, + "executionModeFast": "Fast", + "executionModeStandard": "Standard", + "executionStatsAria": "Task execution statistics", + "executionTiming": "Execution Timing", + "executionTimingMetricsAria": "Execution timing metrics", + "firstUsed": "First used", + "githubTracking": { + "addTitleBeforeCreating": "Add a title before creating a tracking issue", + "checking": "Checking tracking status", + "collapse": "Collapse GitHub tracking details", + "createIssueBtn": "Create tracking issue", + "createIssueDisabledTitle": "Add a title or description so a tracking issue can be created.", + "createIssueHelper": "Tracking issue will be created once this task has a title or description to summarize.", + "disabled": "Tracking is currently disabled", + "enableAriaLabel": "Enable GitHub tracking", + "enableBtn": "Enable", + "enableCheckboxLabel": "Enable GitHub tracking", + "enabling": "Enabling GitHub tracking…", + "enablingAriaLabel": "Enabling GitHub tracking", + "expand": "Expand GitHub tracking details", + "issue": "Issue", + "issueCreationRequested": "Requested GitHub tracking issue creation", + "issueUnlinked": "GitHub issue unlinked", + "label": "GitHub tracking", + "loading": "Loading GitHub tracking status…", + "loadingAriaLabel": "Loading GitHub tracking status", + "notYetCreated": "Issue not yet created", + "repoOverrideFormat": "Repository override must be in owner/repo format", + "state": "State", + "statusAriaLabel": "GitHub tracking status", + "statusDisabled": "Disabled", + "statusEnabled": "Enabled", + "statusLinked": "Linked", + "statusLoading": "Loading", + "unlinkBtn": "Unlink GitHub issue", + "unlinkConfirm": "Unlink", + "unlinkMessage": "This stops Fusion from syncing with the linked GitHub issue. The issue itself will not be modified.", + "unlinkTitle": "Unlink GitHub issue?" + }, + "hasSession": "has session", + "header": { + "back": "Back", + "backToList": "Back to task list", + "editTask": "Edit task" + }, + "inputTokens": "Input", + "lastUsed": "Last used", + "loadingTokenStats": "Loading token statistics…", + "logs": { + "activity": "Activity", + "activityHeading": "Activity", + "agentLog": "Agent Log", + "noActivity": "(no activity)", + "truncated_one": "Showing the most recent {{count}} activity entries.", + "truncated_other": "Showing the most recent {{count}} activity entries." + }, + "longestTimingEvent": "Longest timing event", + "longestWorkflowStep": "Longest workflow step", + "merge": { + "closed": "Closed {{id}} ({{reason}})", + "merged": "Merged {{id}} (branch: {{branch}})", + "merging": "Merging {{id}}…", + "message": "Merge {{id}} into the current branch?", + "noBranchToMerge": "no branch to merge", + "title": "Merge Task" + }, + "move": { + "backToInProgress": "Back to In Progress", + "cancelMove": "Cancel Move", + "keepProgress": "Keep Progress", + "moveTo": "Move to {{column}}", + "movedTo": "Moved to {{column}}", + "preserveProgressMessage": "This task has completed steps. Keep progress before moving?", + "preserveProgressTitle": "Preserve Progress?", + "resetProgress": "Reset Progress", + "resetProgressMessage": "Reset all step progress before moving this task?", + "resetProgressTitle": "Reset Progress?" + }, + "nearDuplicate": { + "actions": "Choose Archive to move this task to archived, or Keep to continue with this task.", + "archiveBtn": "Archive", + "archiveConfirm": "Archive", + "archiveMessage": "Archive {{id}} as a duplicate of {{duplicateOf}}?", + "archiveTitle": "Archive near-duplicate task", + "archived": "Archived {{id}}", + "copy": "This task appears to be a near-duplicate of", + "headline": "Potential duplicate detected", + "keepBtn": "Keep", + "kept": "Kept {{id}} and dismissed duplicate warning" + }, + "nextRecoveryAt": "Next recovery at {{time}}", + "no": "No", + "noCommits": { + "disabled": "No-commits expectation disabled", + "enabled": "No-commits expectation enabled", + "hint": "Allows the task to complete without producing git commits. Use for evaluation, verification, or audit tasks where the deliverable is the recorded decision.", + "label": "No commits expected (decision-only task)" + }, + "noRuntimeLinks": "No runtime links", + "noScheduledRecovery": "No scheduled recovery", + "noSteps": "No steps", + "noTimedEvents": "No timed events recorded yet.", + "noTokenUsage": "No token usage recorded for this task yet.", + "noWorkflowStepTimings": "No completed workflow step timings yet.", + "notSet": "Not set", + "outputTokens": "Output", + "pause": { + "pauseBtn": "Pause", + "paused": "Paused {{id}}", + "pausedByAgent": "Paused by agent", + "unpauseBtn": "Unpause", + "unpaused": "Unpaused {{id}}", + "worktrunkFailed": "Worktrunk operation failed" + }, + "paused": "Paused", + "plan": { + "approveBtn": "Approve Plan", + "approved": "Plan approved — {{id}} moved to Todo", + "rebuildMessage": "Rebuild the plan for this task? The task will move to planning for replanning.", + "rebuildTitle": "Rebuild Plan", + "rejectBtn": "Reject Plan", + "rejectMessage": "Reject this plan? The specification will be discarded and regenerated.", + "rejectTitle": "Reject Plan", + "rejected": "Plan rejected — {{id}} returned to Planning for replanning", + "replanning": "Replanning {{id}}…" + }, + "pr": { + "awaitingChecks": "Awaiting PR checks", + "checkPrStatus": "Check PR Status", + "creatingPr": "Creating PR…", + "finishAndClose": "Finish & Close", + "mergeAndClose": "Merge & Close", + "mergingFixes": "Merging fixes…", + "mergingPr": "Merging PR…", + "startPrReview": "Start PR Review", + "statusRefreshed": "PR status refreshed" + }, + "priority": { + "ariaLabel": "Task priority", + "label": "Priority:", + "updated": "Priority updated to {{priority}}" + }, + "progress": { + "heading": "Progress", + "noSteps": "(no steps defined)", + "stepCount_one": "{{count}}/{{total}} step", + "stepCount_other": "{{count}}/{{total}} steps" + }, + "provenance": { + "createdBy": "Created by", + "createdVia": "Created via" + }, + "recoveryState": "Recovery state", + "refine": { + "btn": "Refine", + "charCount_one": "{{count}}/2000 characters", + "charCount_other": "{{count}}/2000 characters", + "createBtn": "Create Refinement Task", + "creating": "Creating...", + "feedbackRequired": "Please enter feedback describing what needs refinement", + "feedbackTooLong": "Feedback must be 2000 characters or less", + "help": "Describe what needs to be refined or improved...", + "modalTitle": "Refine", + "placeholder": "Enter your feedback here...", + "taskCreated": "Refinement task created: {{id}}" + }, + "reset": { + "btn": "Reset", + "confirmMessage": "This will erase all progress for {{id}} and start the task from scratch. Continue?", + "resetSuccess": "Reset {{id}} — fresh run will be allocated" + }, + "respecify": { + "btn": "Respecify" + }, + "retries": { + "branchConflict": "Branch conflict recovery", + "branchConflictTitle": "FN-4068 branch-conflict recovery retries", + "capReached": "Retry cap reached for this task.", + "collapse": "Collapse retries details", + "expand": "Expand retries details", + "label": "Retries", + "mergeConflict": "Merge conflict bounces", + "mergeConflictTitle": "Merge conflict bounce retries", + "postReviewFix": "Post-review fixes", + "postReviewFixTitle": "Post-review remediation retries", + "recovery": "Recovery retries", + "recoveryTitle": "Transient executor recovery retries", + "reviewerContext": "Reviewer context retries", + "reviewerContextTitle": "FN-4082 compact reviewer retry", + "reviewerFallback": "Reviewer fallback retries", + "reviewerFallbackTitle": "FN-4092 fallback-model retry", + "stuckKill": "Stuck kills", + "stuckKillTitle": "Stuck-task detector forced agent kill retries", + "taskDone": "task_done retries", + "taskDoneTitle": "Agent exited without task_done and task was retried", + "verification": "Verification bounces", + "verificationTitle": "Verification failure bounce retries", + "workflowStep": "Workflow retries", + "workflowStepTitle": "Workflow step failure retries" + }, + "retriesLabel": "Retries (recovery / workflow / merge / task_done)", + "retry": { + "btn": "Retry", + "retried": "Retried {{id}}" + }, + "runtimeLinks": "Runtime links", + "runtimeStatus": "Runtime status", + "selfHealCounters": "Self-heal counters", + "selfHealValues": "stuck kills: {{stuckKills}}, post-review fixes: {{postReviewFixes}}", + "sourceIssue": { + "collapse": "Collapse source issue details", + "expand": "Expand source issue details", + "githubAriaLabel": "GitHub source issue", + "githubBadge": "GitHub", + "identifier": "Issue Identifier", + "label": "Source issue", + "none": "(none)", + "provider": "Provider", + "repository": "Repository", + "url": "URL" + }, + "spec": { + "aiReviseHeading": "Ask AI to Revise", + "aiReviseHelp": "Provide feedback for the AI to improve this specification. The task will move to planning for replanning.", + "editBtn": "Edit", + "feedbackPlaceholder": "e.g., 'Add more details about error handling', 'Split this into smaller steps', 'Include tests for the API endpoints'...", + "hintCancel": "to cancel", + "hintSave": "to save", + "loading": "Loading specification…", + "noPrompt": "(no prompt)", + "placeholder": "Enter task specification in Markdown...", + "requestRevisionBtn": "Request AI Revision", + "requesting": "Requesting…", + "revisionColumnError": "Cannot request revision: Task must be in 'triage', 'todo', 'in-progress', or 'in-review' column.", + "revisionRequested": "AI revision requested. Task moved to planning.", + "saving": "Saving…", + "updated": "Spec updated" + }, + "stall": { + "noLogEntry": "No log entry yet", + "noLogEntryTitle": "No 'In-review stall surfaced' entry on this task yet — self-healing may not have logged one within its rate-limit window.", + "observed": "Observed", + "threshold": "Threshold", + "viewActivityLog": "View activity log" + }, + "stepProgress": "Step progress", + "summary": { + "heading": "Summary" + }, + "tabs": { + "changes": "Changes", + "comments": "Comments", + "definition": "Definition", + "documents": "Documents", + "logs": "Logs", + "model": "Model", + "pullRequest": "Pull Request", + "review": "Review", + "routing": "Routing", + "stats": "Stats", + "workflow": "Workflow" + }, + "timedDuration": "Timed duration", + "timestamps": { + "ariaLabel": "Task timestamps", + "created": "Created", + "updated": "Updated" + }, + "timingEvents": "Timing events", + "tokenTotalsAria": "Task token totals", + "tokenUsage": "Token Usage", + "totalExecutionTime": "Total execution time", + "totalTokens": "Total", + "updateFailed": "Failed to update {{id}}: {{error}}", + "updateSuccess": "Updated {{id}}", + "wallClockSinceFirst": "Wall-clock since first execution", + "workflow": { + "loadFailed": "Failed to load workflow results: {{error}}", + "stepsUpdateFailed": "Failed to update workflow steps: {{error}}", + "stepsUpdated": "Workflow steps updated" + }, + "workflowRuntime": "Workflow runtime", + "workflowTimedSteps": "Workflow timed steps", + "yes": "Yes" + }, + "taskDocuments": { + "cancel": "Cancel", + "collapse": "Collapse", + "contentLabel": "Content", + "contentPlaceholder": "Enter document content…", + "contentRequired": "Content is required", + "create": "Create", + "created": "Document created", + "creating": "Creating…", + "deleteConfirm": "Delete?", + "deleted": "Document deleted", + "edit": "Edit", + "expand": "Expand", + "failedToCreate": "Failed to create document", + "failedToDelete": "Failed to delete document", + "failedToLoad": "Failed to load documents", + "failedToLoadRevisions": "Failed to load revisions", + "failedToSave": "Failed to save document", + "heading": "Documents", + "history": "History", + "invalidKeyFormat": "Invalid key format. Use 1-64 alphanumeric characters, hyphens, or underscores.", + "keyHint": "Alphanumeric, hyphens, underscores (1-64 chars)", + "keyLabel": "Key", + "keyPlaceholder": "e.g., plan, notes, research", + "keyRequired": "Document key is required", + "loading": "Loading documents…", + "loadingRevisions": "Loading…", + "modeMarkdown": "Markdown", + "modePlain": "Plain", + "newDocumentButton": "New Document", + "newDocumentTitle": "New Document", + "no": "No", + "noDocuments": "No documents yet.", + "noPreviousRevisions": "No previous revisions.", + "revisionHistory": "Revision History", + "save": "Save", + "saved": "Document saved", + "saving": "Saving…", + "switchToMarkdown": "Switch to markdown", + "switchToPlainText": "Switch to plain text", + "yes": "Yes" + }, + "taskForm": { + "addDependencies": "Add dependencies", + "attachHint": "You can also paste images or drag & drop", + "attachScreenshot": "Attach Screenshot", + "attachmentsLabel": "Attachments", + "autoMergeDefault": "Default (Follow project setting)", + "autoMergeDisabled": "Disabled", + "autoMergeEnabled": "Enabled", + "autoMergeHint": "Default follows the project auto-merge setting.", + "autoMergeLabel": "Auto-merge", + "autoSaveSaved": "Saved", + "autoSaveSaving": "Saving...", + "baseBranchCustom": "Custom…", + "baseBranchDefault": "(default / project branch)", + "baseBranchLabel": "Merge target / base branch", + "baseBranchPlaceholder": "e.g. main", + "branchModeAutoNew": "Create auto-named branch per task", + "branchModeCustomNew": "Create custom new branch", + "branchModeExisting": "Use existing branch", + "branchModeProjectDefault": "Use project/default branch", + "branchModeSharedGroup": "Merge into a shared feature branch", + "branchNameLabel": "Branch name", + "branchPlaceholder": "e.g. feature/my-task", + "branchSettingsLabel": "Branch Settings", + "branchStrategyLabel": "Branch strategy", + "collapseDescription": "Collapse description", + "dependenciesLabel": "Dependencies", + "dependenciesSelected_one": "{{count}} selected", + "dependenciesSelected_other": "{{count}} selected", + "descriptionLabel": "Description", + "descriptionPlaceholder": "What needs to be done?", + "descriptionRefinedToast": "Description refined with AI", + "editingDescription": "Editing Description", + "enterDescriptionFirst": "Enter a description first", + "executionModeFast": "Fast", + "executionModeLabel": "Execution mode", + "executionModeStandard": "Standard", + "executionOrderLabel": "Execution order:", + "executorLabel": "Executor", + "executorModelLabel": "Executor Model", + "expandDescription": "Expand description", + "githubRepoFormatError": "Repository must be in owner/repo format.", + "githubRepoLabel": "Repository (owner/repo)", + "githubTrackingEnable": "Enable GitHub issue tracking for this task", + "githubTrackingLabel": "GitHub Tracking", + "loadingModels": "Loading models…", + "modelConfigLabel": "Model Configuration", + "moreOptions": "More options", + "moveDown": "Move down", + "moveUp": "Move up", + "noAvailableTasks": "No available tasks", + "noModelsAvailable": "No models available. Configure authentication in Settings.", + "nodeDefaultOption": "Use project default / local", + "nodeOverrideHint": "Task override takes priority over project default node routing.", + "nodeOverrideLabel": "Execution Node Override", + "overridePreset": "Override", + "planButton": "Plan", + "planningLabel": "Planning", + "planningModelLabel": "Planning Model", + "presetCustom": "Custom", + "presetLabel": "Preset", + "presetUseDefault": "Use default", + "priorityLabel": "Priority", + "refineAddDetailsDesc": "Add implementation details and context", + "refineAddDetailsTitle": "Add details", + "refineButton": "Refine", + "refineClarifyDesc": "Make the description clearer and more specific", + "refineClarifyTitle": "Clarify", + "refineExpandDesc": "Expand into a more comprehensive description", + "refineExpandTitle": "Expand", + "refineInProgress": "Refining...", + "refineSimplifyDesc": "Simplify and make more concise", + "refineSimplifyTitle": "Simplify", + "refineTitle": "Refine description with AI", + "removeImage": "Remove image", + "removeStep": "Remove", + "reviewDefault": "Default (Auto — triage decides)", + "reviewLabel": "Review", + "reviewLevel0": "0 — None", + "reviewLevel1": "1 — Plan Only", + "reviewLevel2": "2 — Plan and Code", + "reviewLevel3": "3 — Full", + "reviewerLabel": "Reviewer", + "reviewerModelLabel": "Reviewer Model", + "searchTasksPlaceholder": "Search tasks…", + "sharedBranchPlaceholder": "e.g. clionboarding", + "sharedFeatureBranchLabel": "Shared feature branch", + "subtaskButton": "Subtask", + "thinkingDefault": "Default ({{level}})", + "thinkingHigh": "High", + "thinkingLabel": "Thinking", + "thinkingLow": "Low", + "thinkingMedium": "Medium", + "thinkingMinimal": "Minimal", + "thinkingOff": "Off", + "titleLabel": "Title", + "titlePlaceholder": "Task title", + "useDropdown": "Use dropdown", + "usingPreset": "Using preset: {{name}}", + "workflowStepsDescription": "Select steps to run after task implementation completes", + "workflowStepsLabel": "Workflow Steps", + "workingBranchLabel": "Working branch" + }, + "taskHandlers": { + "githubImported": "Imported {{id}} from GitHub" + }, + "taskReview": { + "autoMergeOff": "Auto-merge off", + "autoMergeOn": "Auto-merge on", + "autoMergePreferenceUpdated": "Per-task auto-merge preference updated", + "completedAtSep": " · Completed: {{timestamp}}", + "createPr": "Create PR", + "effective": "Effective: {{label}}", + "effectiveFrozen": "Effective: {{label}} — frozen on entry to review", + "errorSep": " · Error: {{message}}", + "followDefault": "Follow default", + "loadError": "Failed to load review data.", + "loadingData": "Loading review data…", + "markdown": "Markdown", + "noCapturedFeedback": "No review feedback captured yet.", + "noFeedbackDirect": "No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode.", + "noReviewItems": "No review items yet.", + "perTaskAutoMerge": "Per-task auto-merge", + "plain": "Plain", + "prSummaryLine_one": "{{decision}} · {{count}} review item(s)", + "prSummaryLine_other": "{{decision}} · {{count}} review item(s)", + "queueing": "Queueing…", + "refresh": "Refresh", + "refreshDataFailed": "Failed to refresh review data.", + "refreshFailed": "Refresh failed", + "refreshStatusLine": "{{status}} · Last refreshed: {{timestamp}} · {{source}}", + "refreshed": "Review refreshed", + "refreshing": "Refreshing…", + "requestRevision": "Request revision", + "reviewerSummaryLine_one": "{{reviewer}} · {{count}} review item(s)", + "reviewerSummaryLine_other": "{{reviewer}} · {{count}} review item(s)", + "revisionQueueFailed": "Failed to queue revision", + "revisionStarted": "Same-task AI revision started from selected review feedback", + "selectedAt": "Selected: {{timestamp}}", + "showMarkdown": "Show formatted markdown", + "showRawText": "Show raw text", + "startedAtSep": " · Started: {{timestamp}}", + "upToDate": "Up to date", + "updateFailed": "Failed to update {{taskId}}: {{error}}" + }, + "tasks": { + "addTaskPlaceholder": "Add a task...", + "agent": "Agent", + "agentLabel": "Agent", + "archive": "Archive", + "archiveFailed": "Failed to archive {{taskId}}: {{error}}", + "archiveLineageConflict": "{{taskId}} has lineage children ({{children}}) that reference it as a source parent.\n\nArchive anyway by unlinking these references first?", + "archiveTask": "Archive task", + "archived": "Archived {{taskId}}", + "archivedUnlinked": "Archived {{taskId}} after unlinking lineage references", + "assignedTo": "Assigned to {{name}}", + "attach": "Attach", + "attachCount_one": "Attach ({{count}})", + "attachCount_other": "Attach ({{count}})", + "attachFileFailed": "Failed to attach {{fileName}}: {{error}}", + "attachedFile": "Attached {{fileName}} to {{taskId}}", + "awaitingApproval": "Awaiting Approval", + "baseBranch": "Base", + "blockedByTooltip": "Blocked by {{taskId}} (file overlap)", + "branch": "Branch", + "branchMetadata": "Branch metadata", + "branchProgress": "{{done}}/{{total}} branches", + "branchProgressTitle": "Parallel branches in progress", + "cancelMove": "Cancel Move", + "clearSelection": "Clear selection", + "closeIssue": "Close Issue", + "collapse": "Collapse", + "createFailed": "Failed to create task", + "createPr": "Create PR", + "createPrAriaLabel": "Create pull request", + "createPrTitle": "Create a PR for this task", + "createTaskTitle": "Create task", + "createdByAgent": "Created by agent", + "createdByAgentNamed": "Created by agent: {{name}}", + "createdPr": "Created PR #{{number}}", + "creating": "Creating...", + "decisionOnly": "decision-only", + "decisionOnlyTitle": "Decision-only task", + "deleteConfirm": "Delete {{taskId}}?", + "deleteFailed": "Failed to delete {{taskId}}: {{error}}", + "deleteIssue": "Delete Issue", + "deleteLinkedIssueMessage": "Delete {{issueLabel}} on GitHub, or leave it unchanged?", + "deleteLinkedIssueTitle": "Delete Linked GitHub Issue", + "deleteTask": "Delete task", + "deleteTitle": "Delete Task", + "deleted": "Deleted {{taskId}}{{suffix}}", + "deletedRemovedDeps": "Deleted {{taskId}} after removing dependency references", + "deletedUnlinked": "Deleted {{taskId}} after unlinking lineage references", + "dependencyConflict": "{{taskId}} is a dependency of {{dependentList}}.\n\nDelete anyway by removing these dependency references first?", + "deps": "Deps", + "depsCount_one": "{{count}} deps", + "depsCount_other": "{{count}} deps", + "descriptionPlaceholder": "Task description", + "descriptionRefined": "Description refined with AI", + "doneNoMerge": "Done (no merge)", + "duplicateCheckFailed": "Duplicate check failed; creating task anyway.", + "duplicateDismissed": "Kept {{taskId}}; duplicate warning dismissed", + "duplicateOf": "Duplicate of {{id}}", + "editTask": "Edit task", + "enterDescriptionFirst": "Enter a description first", + "executionTime": "Execution time {{elapsed}}", + "executionTimeCompleted": "Execution time {{elapsed}}. Completed {{completedAt}}", + "executionTimeMergePhase": "Execution time {{elapsed}}. Merge phase {{merge}}", + "executionTimeMerging": "Execution time {{elapsed}}. Merging", + "executorModel": "Executor Model", + "expand": "Expand", + "fanoutBlocks": "Blocks", + "fanoutBottleneck": "Overlap bottleneck", + "fanoutEscalated": "Escalated overlap", + "fanoutEscalationSuffix": " · escalated after {{minutes}}m in blocking column", + "fanoutHighFanoutSuffix": " (overlap bottleneck threshold: {{threshold}})", + "fanoutStale_one": "{{count}} stale", + "fanoutStale_other": "{{count}} stale", + "fanoutTooltip_one": "Blocking {{count}} active task(s); overlap blockedBy queue: {{queueCount}} todo{{highFanout}}{{escalation}}", + "fanoutTooltip_other": "Blocking {{count}} active task(s); overlap blockedBy queue: {{queueCount}} todo{{highFanout}}{{escalation}}", + "fast": "Fast", + "fastMode": "Fast mode", + "filesChanged_one": "{{count}} file changed", + "filesChanged_other": "{{count}} files changed", + "forceDeleteTitle": "Force Delete Task", + "githubTrackingDefaultOff": "off", + "githubTrackingDefaultOn": "on", + "githubTrackingDisabled": "GitHub tracking is disabled for this project — enable it in Settings to use per-task tracking", + "githubTrackingOff": "GitHub tracking OFF for next task", + "githubTrackingOn": "GitHub tracking ON for next task (project default: {{default}})", + "groupBranch": "Group", + "hideSteps": "Hide steps", + "importedFromGitHub": "Imported from GitHub", + "importedFromGitHubUrl": "Imported from GitHub: {{url}}", + "inProgressTime": "In progress {{elapsed}}", + "issueClosed": "closed", + "issueDeleted": "deleted", + "issueLeft": "left", + "keep": "Keep", + "keepFailed": "Failed to keep {{taskId}}: {{error}}", + "keepProgress": "Keep Progress", + "keepTaskTitle": "Keep this task and dismiss duplicate warning", + "leaveUnchanged": "Leave Unchanged", + "lineageConflict": "{{taskId}} has lineage children ({{children}}) that reference it as a source parent.\n\nDelete anyway by unlinking these references first?", + "linkedIssueChipAriaLabel": "Linked GitHub issue #{{number}}", + "linkedIssueChipTitle": "Linked GitHub issue: {{owner}}/{{repo}}#{{number}}", + "linkedIssueMessage": "Choose what to do with {{issueLabel}} when deleting {{taskId}}.\n\nClose the issue?", + "linkedIssueTitle": "Linked GitHub Issue", + "loadAgentsFailed": "Failed to load agents: {{msg}}", + "loadAgentsFailedGeneric": "Failed to load agents", + "loadDependencyFailed": "Failed to load dependency {{depId}}", + "loadModelsFailed": "Failed to load models", + "loadingAgents": "Loading agents...", + "missionBadgeTitle": "Mission: {{name}}", + "modelExecutor": "Executor", + "modelPlan": "Plan", + "modelReviewer": "Reviewer", + "models": "Models", + "modelsCount_one": "{{count}} model", + "modelsCount_other": "{{count}} models", + "moreOptions": "More Options", + "move": "Move", + "moveFailed": "Failed to move {{taskId}}: {{error}}", + "moveTask": "Move task", + "moved": "Moved {{taskId}} to {{column}}", + "nearDuplicateTitle": "Potential near-duplicate of {{id}}", + "needsInput": "Needs input", + "noAgentsAvailable": "No agents available", + "noExistingTasks": "No existing tasks", + "node": "Node", + "openRetryBreakdown": "Open retry breakdown", + "paused": "paused", + "pausedByAgent": "paused by agent", + "plan": "Plan", + "planButtonTitle": "Open planning mode with current description", + "planModel": "Plan Model", + "prBadgeTitle": "PR #{{number}}: {{title}}", + "preserveProgressMessage": "This task has completed steps. Keep progress before moving?", + "preserveProgressTitle": "Preserve Progress?", + "projectDefaultLocal": "Project default / local", + "queued": "Queued", + "quickEntryHint": "Enter to create · Esc to cancel", + "refine": "Refine", + "refineAddDetails": "Add details", + "refineAddDetailsDesc": "Add implementation details and context", + "refineButtonTitle": "Refine description with AI", + "refineClarify": "Clarify", + "refineClarifyDesc": "Make the description clearer and more specific", + "refineExpand": "Expand", + "refineExpandDesc": "Expand into a more comprehensive description", + "refineSimplify": "Simplify", + "refineSimplifyDesc": "Simplify and make more concise", + "refining": "Refining...", + "removeImage": "Remove image", + "resetProgress": "Reset Progress", + "resetProgressMessage": "Reset all step progress before moving this task?", + "resetProgressTitle": "Reset Progress?", + "retriesAriaLabel_one": "{{count}} retries", + "retriesAriaLabel_other": "{{count}} retries", + "retry": "Retry", + "retryFailed": "Failed to retry {{taskId}}: {{error}}", + "retrying": "Retrying…", + "reviewerModel": "Reviewer Model", + "save": "Save", + "saving": "Saving...", + "searchTasksPlaceholder": "Search tasks…", + "selectAgent": "Select agent", + "selectExecutionNode": "Select execution node", + "selectPriority": "Select priority", + "sendBack": "Send back", + "sharedBranch": "Shared", + "showSteps": "Show steps", + "stalled": "Stalled", + "statusMergingFix": "Merging fixes…", + "stepCount_one": "{{count}} step", + "stepCount_other": "{{count}} steps", + "stuck": "Stuck", + "subtask": "Subtask", + "subtaskButtonTitle": "Break down into AI-generated subtasks", + "toggleFastMode": "Toggle fast execution mode", + "unarchive": "Unarchive", + "unarchiveFailed": "Failed to unarchive {{taskId}}: {{error}}", + "unarchiveTask": "Unarchive task", + "unarchived": "Unarchived {{taskId}}", + "updateFailed": "Failed to update {{taskId}}: {{error}}", + "updated": "Updated {{taskId}}", + "uploadFailed": "Failed to upload: {{files}}", + "usingDefault": "Using default", + "viewDependency": "Click to view {{depId}}", + "workflow": "workflow", + "workflowCheck": "Workflow check" + }, + "terminal": { + "clear": "Clear", + "clearTerminal": "Clear terminal", + "closeTab": "Close tab", + "closeTerminal": "Close terminal", + "decreaseFontSize": "Decrease terminal font size", + "exitLabel": "Exit: {{code}}", + "failedToStartTerminal": "Failed to start terminal: {{error}}", + "helpText": "Ctrl++/- zoom • ⌨ Shortcuts panel • Esc close", + "increaseFontSize": "Increase terminal font size", + "initializeError": "Terminal UI failed to initialize: {{error}}", + "newSession": "New Session", + "newTerminal": "New terminal", + "reconnect": "Reconnect", + "refreshPage": "Refresh page", + "reinitialize": "Reinitialize", + "shortcuts": "Shortcuts", + "startingTerminal": "Starting terminal...", + "statusConnected": "Connected", + "statusConnecting": "Connecting...", + "statusDisconnected": "Disconnected", + "statusReconnecting": "Reconnecting..." + }, + "theme": { + "colorTheme": "Color Theme", + "colorThemeLabel": "Color theme", + "currentTheme": "Current theme", + "fontSize": "Font Size", + "fontSizeLabel": "Dashboard font size", + "modeLabel": "Theme mode", + "resetButton": "Reset to defaults", + "resetLabel": "Reset to default theme" + }, + "todo": { + "addItemPlaceholder": "Add a todo item", + "addList": "Add list", + "assignAgent": "Assign {{text}} to agent", + "cancelItemEdit": "Cancel item edit", + "cancelList": "Cancel list", + "cancelListRename": "Cancel list rename", + "createList": "Create List", + "createTaskFrom": "Create task from {{text}}", + "deleteItem": "Delete {{text}}", + "deleteList": "Delete {{title}}", + "deleteListConfirm": "Delete this list and all its items?", + "deleteListTitle": "Delete List", + "editItem": "Edit {{text}}", + "failedCreateItem": "Failed to create item", + "failedCreateItemToast": "Failed to create todo item", + "failedCreateList": "Failed to create list", + "failedCreateListToast": "Failed to create todo list", + "failedDeleteItem": "Failed to delete item", + "failedDeleteItemToast": "Failed to delete todo item", + "failedDeleteList": "Failed to delete list", + "failedDeleteListToast": "Failed to delete todo list", + "failedLoadLists": "Failed to load todo lists", + "failedRenameList": "Failed to rename list", + "failedRenameListToast": "Failed to rename todo list", + "failedReorderItems": "Failed to reorder items", + "failedReorderItemsToast": "Failed to reorder todo items", + "failedToCreateAndAssign": "Failed to create and assign task: {{error}}", + "failedToCreateTask": "Failed to create task: {{error}}", + "failedToLoadAgents": "Failed to load agents: {{error}}", + "failedUpdateItem": "Failed to update item", + "failedUpdateItemToast": "Failed to update todo item", + "itemsLabel": "Todo items", + "lists": "Lists", + "listsLabel": "Todo lists sidebar", + "loading": "Loading todos...", + "loadingAgents": "Loading agents...", + "manageDescription": "Manage reusable todo lists for your project.", + "moveItemDown": "Move {{text}} down", + "moveItemUp": "Move {{text}} up", + "newListTitle": "New list title", + "noAgentsAvailable": "No agents available", + "noItemsEmpty": "No items in this list. Add one above.", + "noListsEmpty": "No todo lists yet. Create one to get started.", + "renameList": "Rename {{title}}", + "saveItemEdit": "Save item edit", + "saveList": "Save list", + "saveListRename": "Save list rename", + "selectList": "Select list {{title}}", + "selectListEmpty": "Select a list from the sidebar", + "startPlanning": "Start planning from {{text}}", + "taskCreatedAndAssigned": "Created {{id}} and assigned to {{agent}}", + "taskCreatedFromTodo": "Created {{id}} from todo", + "todoListsLabel": "Todo lists", + "todos": "Todos" + }, + "trackingRepoSelect": { + "customOption": "Custom…", + "loadingHint": "Loading detected GitHub remotes…" + }, + "updateBanner": { + "dismissLabel": "Dismiss update notice", + "learnMore": "Learn more", + "message": "Update available: v{{latestVersion}} (current: v{{currentVersion}}). Run fn update for an installed CLI, or pull this source checkout.", + "releaseNotes": "Release notes" + }, + "usage": { + "configureAuthHint": "Configure authentication in Settings to see usage data.", + "failedToLoad": "Failed to load usage data", + "hideWindow": "Hide {{label}}", + "lastUpdated": "Last updated: {{time}}", + "moveProviderDown": "Move {{provider}} down", + "moveProviderUp": "Move {{provider}} up", + "noDataAvailable": "No usage data available", + "noProvidersConfigured": "No AI providers configured", + "percentLeft": "{{percent}}% left", + "percentRemaining": "{{percent}}% remaining", + "percentUsed": "{{percent}}% used", + "reorderProvider": "Reorder {{provider}}", + "resetsInDays": "resets in {{days}}d", + "resetsInDaysHours": "resets in {{days}}d {{hours}}h", + "resetsInHours": "resets in {{hours}}h", + "resetsInMinutes": "resets in {{mins}}m", + "showHidden_one": "Show hidden ({{count}})", + "showHidden_other": "Show hidden ({{count}})", + "statusError": "Error", + "statusNotConfigured": "Not configured", + "title": "Usage", + "viewModeLabel": "Usage view mode", + "viewModeRemaining": "Remaining", + "viewModeUsed": "Used" + }, + "workflow": { + "add": "Add", + "addTemplate": "Add template", + "addWorkflowStep": "Add Workflow Step", + "adding": "Adding...", + "advisoryExplanation": "Advisory workflow steps flagged non-blocking improvements:", + "agentPromptLabel": "Agent Prompt", + "agentPromptPlaceholder": "Leave empty to use AI refinement", + "badgeAdvisory": "Advisory", + "badgeAiPrompt": "AI Prompt", + "badgeDefaultOn": "Default on", + "badgeDisabled": "Disabled", + "badgeEnabled": "Enabled", + "badgeGate": "Gate", + "badgePostMerge": "Post-merge", + "badgePreMerge": "Pre-merge", + "badgeScript": "Script", + "cancelDeleteAriaLabel": "Cancel delete", + "cancelDeleteTitle": "Cancel delete", + "clearModelOverrideTitle": "Clear model override (use global default)", + "configuredSteps": "Configured Workflow Steps", + "confirmDeleteAriaLabel": "Confirm delete {{name}}", + "confirmDeleteTitle": "Confirm delete", + "createChooserHint": "Start from a built-in template or create a fully custom workflow step.", + "createChooserTitle": "How would you like to create this workflow step?", + "createCustomStep": "Custom workflow step", + "defaultOnCheckbox": "Default on for new tasks", + "deleteStepAriaLabel": "Delete {{name}}", + "done": "Done", + "doneEditingAriaLabel": "Done editing workflow steps", + "edit": "Edit", + "editAriaLabel": "Edit workflow steps", + "editStepAriaLabel": "Edit {{name}}", + "editStepTitle": "Edit Workflow Step", + "emptySteps": "No workflow steps defined. Create one to get started, or add one from the Templates tab.", + "enabledCheckbox": "Enabled (available for selection on new tasks)", + "errorAddTemplate": "Failed to add workflow step from template", + "errorDeleteStep": "Failed to delete workflow step", + "errorLoadSteps": "Failed to load workflow steps", + "errorLoadTemplates": "Failed to load templates", + "errorNameDescRequired": "Name and description are required", + "errorNameDescRequiredRefine": "Name and description are required before refining", + "errorRefinePrompt": "Failed to refine prompt", + "errorSaveStep": "Failed to save workflow step", + "errorTemplateAlreadyExists": "A workflow step named '{{name}}' already exists", + "executionModeLabel": "Execution Mode", + "executionOrder": "Execution order:", + "executionPhaseLabel": "Execution Phase", + "expandOutput": "Expand output", + "failureBehaviorLabel": "Failure Behavior", + "formDescriptionLabel": "Description", + "formDescriptionPlaceholder": "Brief description of what this step does", + "formNameLabel": "Name", + "formNamePlaceholder": "e.g. Documentation Review", + "gateModeAdvisory": "Advisory", + "gateModeAdvisoryHint": "Failures are recorded as advisory and do not block merge.", + "gateModeGate": "Gate", + "gateModeGateHint": "Failures block merge and request remediation.", + "graphEditor": "Graph editor", + "hideOutput": "Hide output", + "loadingBuiltInTemplates": "Loading built-in templates...", + "loadingResults": "Loading workflow results…", + "loadingTemplates": "Loading templates...", + "markdown": "Markdown", + "modalAriaLabel": "Workflow Steps", + "modalTitle": "Workflow Steps", + "modeAiPrompt": "AI Prompt", + "modeScript": "Run Script", + "modelHintCustom": "Using {{provider}}/{{modelId}}", + "modelHintDefault": "Using global default model", + "modelOverrideDropdownLabel": "Model override for this workflow step", + "modelOverrideLabel": "Model Override", + "modelOverridePlaceholder": "Select a model override…", + "moveDown": "Move down", + "moveUp": "Move up", + "needsReview": "Needs follow-up review.", + "newStepTitle": "New Workflow Step", + "noBuiltInTemplates": "No built-in templates are available right now. You can still create a custom step.", + "noScripts": "No scripts configured. Add scripts in Settings → Scripts first.", + "noStepsConfigured": "No workflow steps configured for this task.", + "noTemplates": "No templates available.", + "notes": "Notes:", + "output": "Output:", + "phasePostMerge": "Post-merge", + "phasePostMergeHint": "Runs after merge success — failures are logged but do not block", + "phasePreMerge": "Pre-merge", + "phasePreMergeHint": "Runs before merge — can block merge on failure", + "plain": "Plain", + "polishNotes": "Polish notes", + "promptRefined": "Prompt refined with AI", + "refineWithAi": "Refine with AI", + "refineWithAiAriaLabel": "Refine prompt with AI", + "refineWithAiTitle": "Refine with AI", + "remove": "Remove", + "scriptLabel": "Script", + "selectScript": "Select a script…", + "selectStepsDescription": "Select steps to run after task implementation completes", + "showOutput": "Show output", + "started": "Started:", + "stepCount_one": "{{count}} step", + "stepCount_other": "{{count}} steps", + "stepCreated": "Workflow step created", + "stepDefinitionNotFound": "Step definition not found.", + "stepDeleted": "Workflow step deleted", + "stepUpdated": "Workflow step updated", + "steps": "Workflow Steps", + "stepsExplanation": "Pre-merge steps run after implementation, before merge. Post-merge steps run after merge succeeds.", + "summaryAdvisory_one": "{{count}} advisory", + "summaryAdvisory_other": "{{count}} advisory", + "summaryFailed_one": "{{count}} failed", + "summaryFailed_other": "{{count}} failed", + "summaryPassed_one": "{{count}} passed", + "summaryPassed_other": "{{count}} passed", + "summaryRunning_one": "{{count}} running", + "summaryRunning_other": "{{count}} running", + "summarySeparator": " · ", + "summarySkipped_one": "{{count}} skipped", + "summarySkipped_other": "{{count}} skipped", + "summaryStepCount_one": "{{count}} step", + "summaryStepCount_other": "{{count}} steps", + "switchToMarkdown": "Switch to markdown", + "switchToPlain": "Switch to plain text", + "tabMySteps_one": "My Workflow Steps ({{count}})", + "tabMySteps_other": "My Workflow Steps ({{count}})", + "tabTemplates_one": "Templates ({{count}})", + "tabTemplates_other": "Templates ({{count}})", + "templateAdded": "Added {{name}} workflow step", + "useDefault": "Use default" + }, + "workflowColumns": { + "add": "Add column", + "compositionBlocked": "Resolve trait conflicts on highlighted columns before saving", + "empty": "No columns yet. Add a column to place nodes into board lanes.", + "moveDown": "Move column down", + "moveUp": "Move column up", + "nameLabel": "Column name", + "newColumnName": "New column", + "nodeUnplaced": "Not placed in a column", + "readOnlyHint": "Built-in workflows are read-only — duplicate to edit", + "remove": "Remove column", + "title": "Columns", + "traits": "Traits", + "traitsLoadFailed": "Failed to load traits", + "unplacedCount_one": "{{count}} nodes not placed in a column", + "unplacedCount_other": "{{count}} nodes not placed in a column" + }, + "workflowNodes": { + "advisory": "Advisory", + "failureCollect": "Collect (wait for all)", + "failureFailFast": "Fail-fast (cancel siblings)", + "failurePolicy": "On branch failure", + "gateBlocks": "Gate (blocks)", + "gateMode": "Gate mode", + "joinAll": "All branches", + "joinAny": "Any branch", + "joinMode": "Join mode", + "joinQuorum": "Quorum (n)", + "mergeBoundaryNote": "Steps before this marker run pre-merge; steps after run post-merge.", + "quorumN": "Quorum count (n)", + "releaseCapacity": "Downstream capacity", + "releaseCondition": "Release condition", + "releaseDependency": "Dependency complete", + "releaseExternal": "External event", + "releaseManual": "Manual promote", + "releaseTimer": "Timer", + "splitNote": "Branches run concurrently from this node. Execute and merge seams are not allowed inside a branch." + }, + "workflowSelector": { + "switchActiveMessage": "This task has an active session. Switching workflows aborts it and re-homes the card into the new workflow's entry column. Continue?", + "switchActiveTitle": "Switch workflow?", + "switchCancel": "Cancel", + "switchConfirm": "Switch and abort" + }, + "workflows": { + "duplicateToCustomize": "Duplicate to customize", + "readOnlyBuiltin": "Read-only built-in workflow", + "saveFailed": "Failed to save workflow", + "saved": "Workflow saved", + "savedNotCompilable": "Workflow saved but cannot be compiled", + "selectOrCreate": "Select or create a workflow to start editing." + }, + "workspace": { + "projectRoot": "Project Root", + "selectWorkspace": "Select workspace", + "taskWorktrees": "Task Worktrees" + }, + "worktree": { + "unassigned": "Unassigned", + "upNext": "Up Next" + }, + "worktrunk": { + "assetUrl": "Asset URL", + "installPath": "Install path", + "installRequestTitle": "Worktrunk install request", + "sha256": "SHA-256", + "version": "Version" + } + }, + "cli": { + "tui": { + "agentDeleteConfirmHints": "[y] confirm delete [any other key] cancel", + "agentDeleteConfirmId": "ID:", + "agentDeleteConfirmName": "Agent:", + "agentDeleteConfirmTitle": "Delete agent?", + "agentDeleted": "Deleted agent {{name}}", + "agentDetailCaps": "Caps:", + "agentDetailLoadError": "Could not load agent detail.", + "agentDetailRole": "Role:", + "agentDetailSelectHint": "Select an agent from the list.", + "agentDetailState": "State:", + "agentDetailTask": "Task:", + "agentDetailTitle": "Agent Detail", + "agentDetailTitle2": "Title:", + "agentNarrowDetail": "detail", + "agentNarrowList": "list", + "agentOpenLogsHint": "[Enter] open logs", + "agentRunHistory": "Run history (latest first):", + "agentRunId": "ID:", + "agentRunLogsBackHint": "[Esc/q] back to runs", + "agentRunLogsTitle": "Run logs ({{index}})", + "agentStarted": "Agent started", + "agentStopped": "Agent stopped", + "agentsFooterHints": "[s] start [x] stop [D] delete [r] refresh [Tab] focus ↑↓ select", + "agentsListTitle_one": "Agents ({{count}})", + "agentsListTitle_other": "Agents ({{count}})", + "agentsNoAgents": "No agents found.", + "boardCreateTaskHints": "Enter to create · Esc to cancel", + "boardCreateTaskNoProject": "No project selected", + "boardCreateTaskTitleEmpty": "Title cannot be empty", + "boardCreatingTask": "Creating…", + "boardNewTaskPlaceholder": "What needs doing?", + "boardNewTaskProject": "Project: {{name}}", + "boardNewTaskTitle": "New Task", + "boardNewTaskTitleLabel": "Title", + "boardOtherReadOnlyHint": "custom column — move disabled here", + "copiedSuccess": "✓ Copied!", + "copyFailed": "✗ Copy failed", + "expandedLogHeader": "Entry {{index}}/{{total}} · [Enter/Esc] close · [c] copy", + "expandedLogLevel": "Level:", + "expandedLogPrefix": "Prefix:", + "expandedLogTime": "Time:", + "filesBinary": "[binary file, {{size}}]", + "filesEmpty": "(empty)", + "filesEmptyFile": "(empty file)", + "filesFooterHints": "[Tab] switch pane [↑↓/jk] move [Enter] open [←/→] collapse/expand [.] hidden [w] wrap [p] project [r] reload", + "filesMoreLines_one": "… {{count}} more lines", + "filesMoreLines_other": "… {{count}} more lines", + "filesSelectProject": "Select Project", + "filesSelectToPreview": "Select a file to preview", + "filesTooLarge": "{{size}} — [too large to preview]", + "filesUnableToRead": "Unable to read file", + "gitFetchFailed": "Fetch failed: {{output}}", + "gitFetched": "Fetched", + "gitFetching": "Fetching…", + "gitFooterHints": "[r] refresh {{push}}[F] fetch [↑↓] rows [←→] status▸branches{{worktrees}}▸commits▸changes [p] project [Esc/s] back", + "gitNoCommits": "No commits", + "gitNoProject": "No project", + "gitPushDismissHint": "[Esc] dismiss", + "gitPushFailed": "Push failed", + "gitPushModalAhead": "ahead", + "gitPushModalBranch": "Branch:", + "gitPushModalCommits": "Commits to push (oldest→newest):", + "gitPushModalHints": "[Enter] push [Esc] cancel", + "gitPushModalTitle": "Push to remote", + "gitPushSuccessful": "Push successful", + "gitPushingToOrigin": "Pushing to origin/{{branch}}", + "gitRefreshing": "refreshing", + "gitWorkingTreeClean": "Working tree clean", + "headerHelpQuitHint": "[?] help [q] quit", + "headerTunnelQrHint": " [^Q] QR", + "headerTunnelRunning": "tunnel", + "headerTunnelStarting": "tunnel starting…", + "helpShortcutAdjustNumber": "Adjust number (Settings)", + "helpShortcutAdjustThreshold": "Adjust vitest kill memory threshold (Utilities)", + "helpShortcutAgents": "Agents view", + "helpShortcutBoard": "Board view", + "helpShortcutClearLogs": "Clear logs (Utilities)", + "helpShortcutCopyEntry": "Copy selected log entry to clipboard (Logs)", + "helpShortcutCopyToken": "Copy auth token to clipboard (System)", + "helpShortcutDeleteAgent": "Delete agent — requires confirm (Agents)", + "helpShortcutExpandEntry": "Expand log entry (Logs)", + "helpShortcutExpandLog": "Expand log + release mouse for text selection (Logs)", + "helpShortcutFiles": "Files (when not on Logs); cycles log severity filter on Logs", + "helpShortcutFirstLast": "First / last log entry (Logs)", + "helpShortcutForceQuit": "Force quit", + "helpShortcutGit": "Git view", + "helpShortcutHiddenFiles": "Toggle hidden files (Files)", + "helpShortcutJumpPanel": "Jump to panel (Main: System/Logs/Stats/Utilities/Settings)", + "helpShortcutKillVitest": "Kill all vitest processes (Utilities)", + "helpShortcutMain": "Main (status mode)", + "helpShortcutMouseToggle": "Manual mouse-mode toggle (auto: on for Logs/Files/Git/Board, off elsewhere)", + "helpShortcutNavigate": "Navigate list / log entries", + "helpShortcutNewTask": "New task (Board)", + "helpShortcutNextPanel": "Next panel (Main; ↑/↓ scroll on Logs)", + "helpShortcutOpenUrl": "Open dashboard URL in browser (System)", + "helpShortcutPrevPanel": "Previous panel (Main; ↑/↓ scroll on Logs)", + "helpShortcutProjectPicker": "Project picker (Board, Files)", + "helpShortcutPushFetch": "Push / fetch (Git)", + "helpShortcutQuit": "Quit", + "helpShortcutRefreshStats": "Refresh stats (Utilities)", + "helpShortcutSettings": "Settings view", + "helpShortcutSwitchPane": "Switch pane (Agents, Settings, Files, Git)", + "helpShortcutTabBackward": "Cycle focused panel / pane backward", + "helpShortcutTabForward": "Cycle focused panel / pane forward", + "helpShortcutToggleAutoKill": "Toggle auto-kill vitest on memory pressure (Utilities)", + "helpShortcutToggleBool": "Toggle boolean (Settings)", + "helpShortcutToggleHelp": "Toggle help", + "helpShortcutWordWrap": "Toggle word wrap (Logs / Files)", + "helpTitle": "KEYBOARD SHORTCUTS", + "interactiveModeUnavailable": "Interactive mode unavailable — no data source", + "loading": "Loading…", + "loadingTasks": "Loading tasks…", + "logsPanelTitle": "Logs", + "narrowModeIndicator": "[narrow]", + "noEntriesMatchFilter": "No entries match filter {{filter}}.", + "noLogEntries": "No log entries yet.", + "noTasks": "No tasks in this project.", + "projectSelectorChangeHint": "[p] change", + "projectSelectorLabel": "Project:", + "projectSelectorNavHints": "↑↓ navigate · Enter select · Esc cancel", + "projectSelectorNoProjects": "(no projects registered)", + "projectSelectorNone": "(none)", + "projectSelectorPickTitle": "Pick a project", + "qrCloseHint": "[Esc] close", + "qrGenerating": "Generating QR…", + "qrNoTunnelRunning": "No remote tunnel is running. Start one in Settings (g).", + "qrOverlayTitle": "Remote Access — Scan to connect", + "readyIn": "Ready in {{secs}}s", + "runLogNone": "No logs captured for this run.", + "runLogResult": "result:", + "runLogSource": "source", + "runLogStderr": "stderr:", + "runLogStdout": "stdout:", + "runLogTrigger": "trigger", + "runStatusActive": "Active", + "runStatusCompleted": "Completed", + "runStatusFailed": "Failed", + "runStatusTerminated": "Terminated", + "runStatusUnknown": "Unknown", + "settingsActivatedProvider": "Activated provider: {{provider}}", + "settingsAdjust1": "[+/-] adjust by 1", + "settingsAdjust5000ms": "[+/-] adjust by 5000ms", + "settingsAvailableModelsHeader": "──── Available Models ────", + "settingsBoolToggleHint": "[Space] toggle", + "settingsCancelledTokenInput": "Cancelled short-lived token input", + "settingsConfigureModelInDashboard": "Configure default model in web dashboard", + "settingsCurrentLabel": "Current:", + "settingsEditModelsTitle": "Edit / Models", + "settingsEnterTtl": "Enter TTL milliseconds and press Enter", + "settingsEnumCycleHint": "[←/→] cycle options:", + "settingsFooterHints": "[Tab] switch panel ↑↓ select setting [Space] toggle bool [+/-] adjust num [←/→] cycle enum [C/V/X/P/L/U/K/R] remote actions", + "settingsInteractivePanelTitle": "Settings", + "settingsLoadingSettings": "Loading settings…", + "settingsMoreModels_one": "… and {{count}} more", + "settingsMoreModels_other": "… and {{count}} more", + "settingsPanelTitle": "Settings", + "settingsPersistentTokenRegenerated": "Persistent token regenerated", + "settingsQrFetched": "QR payload fetched", + "settingsRemoteActions1": "[C] activate provider [V] start [X] stop [P] persistent token [L] short-lived token", + "settingsRemoteActions2": "[U] URL hand-off [K] QR hand-off [R] refresh", + "settingsRemoteAuthUrl": "Auth URL:", + "settingsRemoteHeader": "──── Remote ────", + "settingsRemoteOff": "off", + "settingsRemoteOn": "on", + "settingsRemotePersistentToken": "Persistent token:", + "settingsRemoteProvider": "Provider:", + "settingsRemoteProviderNone": "none", + "settingsRemoteShortLived": "Short-lived:", + "settingsRemoteShortLivedExpires": "Short-lived expires:", + "settingsRemoteState": "State:", + "settingsRemoteStateUnknown": "unknown", + "settingsRemoteStatusRefreshed": "Remote status refreshed", + "settingsRemoteToken": "Token:", + "settingsRemoteTunnelUrl": "Tunnel URL:", + "settingsRemoteUrlFetched": "Remote URL fetched", + "settingsSaved": "Saved", + "settingsSavingInProgress": "Saving…", + "settingsSelectProviderFirst": "Select a remote provider first", + "settingsShortLivedTokenGenerated": "Short-lived token generated", + "settingsTtlHints": "[Enter] generate [Esc] cancel", + "settingsTtlLabel": "TTL ms:", + "settingsTtlMustBePositive": "TTL must be a positive number (ms)", + "settingsTunnelStarting": "Remote tunnel starting", + "settingsTunnelStopped": "Remote tunnel stopped", + "settingsUnavailable": "Settings not available.", + "statsPanelTitle": "Stats", + "statsUnavailable": "Stats not available.", + "statusBarHelp": "Tab cycle panel · 1-5 jump", + "stepDurationDone": " ({{status}} — {{duration}})", + "stepDurationRunning": " (running — {{duration}})", + "switchProjectsHint": "Press [{{key}}] to switch projects.", + "systemCopyTokenHint": "copy token · select token text to copy manually", + "systemDragToSelect": "drag to select", + "systemInfoUnavailable": "System information not available.", + "systemOpenUrl": "open URL", + "systemPanelTitle": "System", + "tabAgents": "Agents", + "tabBoard": "Board", + "tabFiles": "Files", + "tabGit": "Git", + "tabMain": "Main", + "tabSettings": "Settings", + "taskCardUntitled": "(untitled)", + "taskDetailBack": "[Esc] back", + "taskDetailLoading": "Loading task details…", + "taskDetailLogsLive": "[live]", + "taskDetailLogsPaused": "[paused]", + "taskDetailLogsSectionHeader": "── Logs ───────────────────────────────────────", + "taskDetailNoLogEntries": "(no log entries yet)", + "taskDetailNoSteps": "(no steps yet)", + "taskDetailScrollHints": "↑↓/j/k scroll · PgUp/PgDn half-page · g top · G bottom · Esc back", + "taskDetailStepsSectionHeader": "── Steps ──────────────────────────────────────", + "taskDetailUnavailable": "Task no longer available — Esc to go back", + "updateAvailable": "Update available: v{{currentVersion}} → v{{latestVersion}}. Run `fn update` for an installed CLI, or pull this source checkout.", + "utilitiesAdjustThreshold": "Adjust Threshold ({{pct}}%)", + "utilitiesAutoKillVitest": "Auto-Kill Vitest >{{pct}}% Mem: {{state}}", + "utilitiesClearLogs": "Clear Logs", + "utilitiesHelp": "Help", + "utilitiesKillVitest": "Kill Vitest Processes", + "utilitiesPanelTitle": "Utilities", + "utilitiesRefreshStats": "Refresh Stats", + "utilitiesToggleEnginePause": "Toggle Engine Pause" + } + }, + "common": { + "agents": { + "ratings": { + "trendDeclining": "↓ Declining", + "trendImproving": "↑ Improving", + "trendInsufficient": "Insufficient data", + "trendStable": "→ Stable" + }, + "reflections": { + "triggerManual": "Manual", + "triggerPeriodic": "Periodic", + "triggerPostTask": "Post-Task", + "triggerUserRequested": "User Requested" + }, + "time": { + "daysAgo_one": "{{count}}d ago", + "daysAgo_other": "{{count}}d ago", + "hoursAgo_one": "{{count}}h ago", + "hoursAgo_other": "{{count}}h ago", + "inAMoment": "in a moment", + "inDays_one": "in {{count}}d", + "inDays_other": "in {{count}}d", + "inHours_one": "in {{count}}h", + "inHours_other": "in {{count}}h", + "inMinutes_one": "in {{count}}m", + "inMinutes_other": "in {{count}}m", + "justNow": "just now", + "minutesAgo_one": "{{count}}m ago", + "minutesAgo_other": "{{count}}m ago" + } + }, + "board": { + "rejection": { + "capacityExhausted": "That column is at capacity. Try again when a slot frees up.", + "guardRejected": "This move is not allowed by the workflow.", + "mergeBlocked": "This task is blocked from completing until its merge step finishes.", + "unknownColumn": "That column doesn't exist in this task's workflow.", + "workflowMismatch": "Drag can't move a card between workflows. Use the workflow switcher instead." + } + }, + "chat": { + "failedToGetResponse": "Failed to get response", + "failureReferenceId": "ID", + "failureReferenceKind": "Kind", + "failureReferenceLabel": "Reference", + "failureReferenceMetaLabel": "Label", + "openMailboxMessage": "Open mailbox message", + "toolCallArgsPrefix": "args", + "toolCallResultPrefix": "result", + "toolCallStatusCompleted": "completed", + "toolCallStatusError": "error", + "toolCallStatusErrors": "errors", + "toolCallStatusRunning": "running", + "toolCallsCount_one": "{{count}} tool calls", + "toolCallsCount_other": "{{count}} tool calls", + "toolCallsHeader": "Tool calls", + "viewFailureDetails": "View failure details" + }, + "health": { + "anomaly": { + "duplicateActiveId": "Duplicate active task ID", + "idInBothStorages": "Task ID present in active and archived storage", + "sequenceOverlap": "Allocator next sequence overlaps an existing task ID", + "unknownPrefix": "Task row uses a prefix outside allocator state" + } + }, + "inline": { + "connecting": "Connecting", + "error": "Error", + "offline": "Offline", + "online": "Online" + }, + "merge": { + "unknown": "Unknown" + }, + "missions": { + "autopilotStateActivating": "Activating slice", + "autopilotStateCompleting": "Completing", + "autopilotStateInactive": "Off", + "autopilotStateUnknown": "Unknown", + "autopilotStateWatching": "Watching", + "interviewStatusAwaitingInput": "Awaiting input", + "interviewStatusComplete": "Plan ready", + "interviewStatusError": "Needs retry", + "interviewStatusGenerating": "Generating plan", + "runHelperActive": "Stopping pauses linked tasks and marks the mission blocked.", + "runHelperBlocked": "Resuming re-activates the mission and continues execution.", + "runHelperPlanning": "Starting activates the first slice so work can begin." + }, + "models": { + "messages": { + "modelSetTo": "{{label}} model set to {{provider}}/{{modelId}}", + "modelSetToDefault": "{{label}} model set to default" + } + }, + "nodeStatus": { + "connecting": "Connecting", + "error": "Error", + "offline": "Offline", + "online": "Online", + "unknown": "Unknown" + }, + "nodes": { + "auth": { + "differ": "Auth credentials differ", + "differProviders": "Auth credentials differ: {{providers}}", + "match": "Auth credentials match", + "notSynced": "Auth not synced" + }, + "status": { + "connecting": "Connecting", + "creating": "Creating", + "deleting": "Deleting", + "error": "Error", + "exited": "Exited", + "offline": "Offline", + "online": "Online", + "recreating": "Recreating", + "running": "Running", + "stopped": "Stopped" + } + }, + "research": { + "providerGitHub": "GitHub", + "providerLlmSynthesis": "LLM Synthesis", + "providerLocalDocs": "Local Docs", + "providerPageFetch": "Page Fetch", + "providerWebSearch": "Web Search" + }, + "routing": { + "policyLabel": { + "block": "Block execution", + "fallback": "Fall back to local", + "notConfigured": "Not configured" + } + }, + "setup": { + "apiKeyFormatError": "{{providerName}} keys should follow this format: {{hint}} (e.g. {{example}})", + "apiKeyLabel": { + "fallback": "API Key", + "kimiCoding": "Kimi API Key", + "minimax": "MiniMax API Key", + "ollama": "Ollama Endpoint", + "openai": "OpenAI API Key", + "openrouter": "OpenRouter API Key", + "zai": "Zhipu AI API Key" + }, + "apiKeyPlaceholder": { + "fallback": "Enter API key", + "kimiCoding": "Enter your Kimi API key", + "minimax": "Enter your MiniMax API key", + "zai": "Enter your Zhipu AI API key" + }, + "apiKeyRequired": "API key is required", + "apiKeySetup": { + "fallback": "Enter your API key for this provider.", + "kimiCoding": "Create your API key in the Moonshot platform account settings.", + "minimax": "Generate an API key from the MiniMax platform developer console.", + "ollama": "Enter your Ollama endpoint URL (for example http://localhost:11434).", + "openai": "Create an API key from your OpenAI dashboard under API keys.", + "openrouter": "Create an API key from your OpenRouter account key management page.", + "zai": "Create an API key in the Zhipu AI open platform account settings." + }, + "apiKeyUsage": { + "fallback": "Used by Fusion to authenticate requests to this provider", + "kimiCoding": "Used for Kimi/Moonshot AI models in task execution and planning", + "minimax": "Used for MiniMax models in task execution", + "ollama": "Connects to your local Ollama instance", + "openai": "Used for GPT models in task execution and planning", + "openrouter": "Routes to multiple AI model providers through a single key", + "zai": "Used for GLM models in task execution" + }, + "providerDesc": { + "anthropic": "Claude models — strong at reasoning, analysis, and code", + "fallback": "AI provider — connect to start using AI models", + "gemini": "Gemini models — multimodal with strong reasoning", + "google": "Gemini models — multimodal with strong reasoning", + "kimi": "Kimi by Moonshot AI — long-context capabilities", + "kimiCoding": "Kimi by Moonshot AI — long-context capabilities", + "minimax": "MiniMax models — cost-effective for high-volume usage", + "moonshot": "Kimi by Moonshot AI — long-context capabilities", + "ollama": "Run open-source models locally on your machine", + "openai": "GPT models — versatile for a wide range of tasks", + "openaiCodex": "Codex models by OpenAI — optimized for coding tasks", + "openrouter": "OpenRouter — route requests across multiple AI providers", + "zai": "GLM models by Zhipu AI — strong multilingual support" + } + }, + "taskForm": { + "nodeStatusConnecting": "Connecting", + "nodeStatusError": "Error", + "nodeStatusOffline": "Offline", + "nodeStatusOnline": "Online", + "phasePostMerge": "Post-merge", + "phasePreMerge": "Pre-merge" + }, + "taskReview": { + "never": "Never", + "refreshSourceBackground": "Background", + "refreshSourceInitialLoad": "Initial load", + "refreshSourceManual": "Manual" + }, + "workflow": { + "postMerge": "Post-merge", + "preMerge": "Pre-merge", + "statusAdvisory": "Advisory failure", + "statusFailed": "Failed", + "statusPassed": "Passed", + "statusRunning": "Running…", + "statusSkipped": "Skipped", + "waitingForOutput": "Waiting for agent output…" + } + }, + "errors": { + + } +} diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 10bdd9c090..686fc89c09 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -78,6 +78,9 @@ export type { PluginLogger, PluginSkillContribution, PluginWorkflowStepContribution, + PluginTraitContribution, + PluginTraitHookDescriptor, + PluginTraitFlags, PluginPromptSurface, PluginPromptContribution, PluginPromptContributions, @@ -95,6 +98,12 @@ export type { import type { FusionPlugin } from "@fusion/core"; +// NOTE (U8): trait-contribution VALIDATION lives in @fusion/core +// (validatePluginTraitContribution) and runs engine-side at registration. +// It is deliberately NOT re-exported here — plugin-sdk's built artifact must +// carry no @fusion runtime specifiers (see cli plugin-sdk-export test); only +// type-level re-exports are allowed from @fusion/core. + const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; export function validatePluginManifest(manifest: unknown): { valid: boolean; errors: string[] } { diff --git a/plugins/fusion-plugin-dependency-graph/src/filters.ts b/plugins/fusion-plugin-dependency-graph/src/filters.ts index c872aedabc..8f08f14d01 100644 --- a/plugins/fusion-plugin-dependency-graph/src/filters.ts +++ b/plugins/fusion-plugin-dependency-graph/src/filters.ts @@ -1,7 +1,7 @@ -import type { Column, Task } from "@fusion/core"; +import type { ColumnId, Task } from "@fusion/core"; -export const INCLUDED_COLUMNS: ReadonlySet = new Set(["triage", "todo", "in-progress", "in-review"]); -export const EXCLUDED_COLUMNS: ReadonlySet = new Set(["done", "archived"]); +export const INCLUDED_COLUMNS: ReadonlySet = new Set(["triage", "todo", "in-progress", "in-review"]); +export const EXCLUDED_COLUMNS: ReadonlySet = new Set(["done", "archived"]); export function filterGraphTasks(tasks: Task[]): Task[] { return tasks.filter((task) => INCLUDED_COLUMNS.has(task.column)); diff --git a/plugins/fusion-plugin-even-realities-glasses/src/notifications/diff.ts b/plugins/fusion-plugin-even-realities-glasses/src/notifications/diff.ts index db5ab5dbf9..f193899d42 100644 --- a/plugins/fusion-plugin-even-realities-glasses/src/notifications/diff.ts +++ b/plugins/fusion-plugin-even-realities-glasses/src/notifications/diff.ts @@ -1,10 +1,10 @@ -import type { Task, Column } from "@fusion/core"; +import type { Task, ColumnId } from "@fusion/core"; import type { NotificationEvent, Snapshot } from "./types.js"; export function diffSnapshots( prev: Snapshot, next: ReadonlyArray, - opts: { notifyOnColumns: ReadonlySet; alsoNotifyOnDone?: boolean }, + opts: { notifyOnColumns: ReadonlySet; alsoNotifyOnDone?: boolean }, ): NotificationEvent[] { const events: NotificationEvent[] = []; diff --git a/plugins/fusion-plugin-even-realities-glasses/src/notifications/types.ts b/plugins/fusion-plugin-even-realities-glasses/src/notifications/types.ts index 2e105afb54..341c4ef007 100644 --- a/plugins/fusion-plugin-even-realities-glasses/src/notifications/types.ts +++ b/plugins/fusion-plugin-even-realities-glasses/src/notifications/types.ts @@ -1,18 +1,18 @@ -import type { Column } from "@fusion/core"; +import type { ColumnId } from "@fusion/core"; export type NotificationReason = "entered-column" | "new-task" | "left-column" | "completed"; export interface NotificationEvent { taskId: string; reason: NotificationReason; - column: Column; - previousColumn: Column | null; + column: ColumnId; + previousColumn: ColumnId | null; updatedAt: string; } export interface SnapshotRow { taskId: string; - lastColumn: Column; + lastColumn: ColumnId; updatedAt: string; } diff --git a/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts b/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts index eb2d6be9d3..069ce0b064 100644 --- a/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts +++ b/plugins/fusion-plugin-roadmap/src/store/__tests__/roadmap-store.test.ts @@ -743,10 +743,10 @@ describe("RoadmapStore", () => { }); describe("schema version", () => { - it("schema version is 105 after init", () => { + it("schema version is 107 after init", () => { // Tracks @fusion/core's SCHEMA_VERSION (the roadmap store layers on core's // Database). Bump this in lockstep when core adds a migration. - expect(db.getSchemaVersion()).toBe(105); + expect(db.getSchemaVersion()).toBe(107); }); });